|
1 | | -import type { CartItem } from '@/types/cart'; |
2 | | - |
3 | | -const mockProperty = (overrides = {}) => ({ |
4 | | - id: 'prop-1', |
5 | | - title: 'Test Property', |
6 | | - tokenInfo: { available: 100, price: 0.1 }, |
7 | | - status: 'active', |
8 | | - ...overrides, |
9 | | -}); |
| 1 | +import type { CartItem } from "@/types/cart"; |
| 2 | +import { |
| 3 | + calculateMinimumAmount, |
| 4 | + type BatchPurchaseExecutor, |
| 5 | + type BatchPurchaseRequest, |
| 6 | +} from "../batchTransaction"; |
| 7 | + |
| 8 | +jest.mock("@/utils/logger", () => ({ |
| 9 | + logger: { |
| 10 | + info: jest.fn(), |
| 11 | + error: jest.fn(), |
| 12 | + }, |
| 13 | +})); |
| 14 | + |
| 15 | +jest.mock("@/utils/revertDecoder", () => ({ |
| 16 | + decodeRevertReason: jest.fn(() => "Insufficient balance"), |
| 17 | +})); |
| 18 | + |
| 19 | +const walletAddress = "0x1234567890123456789012345678901234567890"; |
| 20 | +const transactionHash = |
| 21 | + "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd" as const; |
10 | 22 |
|
11 | 23 | const validItem: CartItem = { |
12 | | - id: 'item-1', |
13 | | - property: mockProperty(), |
14 | | - quantity: 1, |
15 | | - addedAt: new Date().toISOString(), |
| 24 | + id: "item-1", |
| 25 | + property: { |
| 26 | + id: "prop-1", |
| 27 | + name: "Test Property", |
| 28 | + description: "A test property", |
| 29 | + location: { |
| 30 | + address: "1 Test Street", |
| 31 | + city: "Test City", |
| 32 | + state: "TS", |
| 33 | + country: "Test Country", |
| 34 | + zipCode: "12345", |
| 35 | + coordinates: { lat: 0, lng: 0 }, |
| 36 | + }, |
| 37 | + price: { total: 10, perToken: 0.1, currency: "ETH" }, |
| 38 | + propertyType: "residential", |
| 39 | + blockchain: "ethereum", |
| 40 | + tokenInfo: { |
| 41 | + totalSupply: 100, |
| 42 | + available: 100, |
| 43 | + sold: 0, |
| 44 | + contractAddress: walletAddress, |
| 45 | + tokenSymbol: "PROP", |
| 46 | + }, |
| 47 | + metrics: { |
| 48 | + roi: 5, |
| 49 | + annualReturn: 1, |
| 50 | + transactionVolume: 0, |
| 51 | + appreciationRate: 2, |
| 52 | + }, |
| 53 | + details: { |
| 54 | + squareFeet: 1000, |
| 55 | + yearBuilt: 2020, |
| 56 | + amenities: [], |
| 57 | + }, |
| 58 | + images: ["/property.jpg"], |
| 59 | + listedDate: "2026-01-01", |
| 60 | + status: "active", |
| 61 | + }, |
| 62 | + quantity: 2, |
| 63 | + addedAt: "2026-01-01T00:00:00.000Z", |
16 | 64 | }; |
17 | 65 |
|
18 | | -describe('BatchTransactionService', () => { |
19 | | - const walletAddress = '0x1234567890123456789012345678901234567890'; |
| 66 | +const createExecutor = ( |
| 67 | + response: Awaited<ReturnType<BatchPurchaseExecutor["execute"]>>, |
| 68 | +): BatchPurchaseExecutor => ({ |
| 69 | + execute: jest.fn(async () => response), |
| 70 | +}); |
20 | 71 |
|
21 | | - beforeEach(() => { |
22 | | - delete process.env.NEXT_PUBLIC_DEMO_TX; |
23 | | - jest.resetModules(); |
| 72 | +describe("BatchTransactionService", () => { |
| 73 | + it("rejects an empty cart without invoking an executor", async () => { |
| 74 | + const { BatchTransactionService } = await import("../batchTransaction"); |
| 75 | + const executor = createExecutor({ |
| 76 | + transactionHash, |
| 77 | + receiptStatus: "success", |
| 78 | + }); |
| 79 | + |
| 80 | + const result = await BatchTransactionService.executeBatchPurchase( |
| 81 | + [], |
| 82 | + walletAddress, |
| 83 | + 0.005, |
| 84 | + executor, |
| 85 | + ); |
| 86 | + |
| 87 | + expect(result.success).toBe(false); |
| 88 | + expect(result.error).toBe("At least one item is required."); |
| 89 | + expect(executor.execute).not.toHaveBeenCalled(); |
24 | 90 | }); |
25 | 91 |
|
26 | | - describe('executeBatchPurchase', () => { |
27 | | - it('returns validation error when item quantity exceeds available', async () => { |
28 | | - const { BatchTransactionService } = await import('../batchTransaction'); |
29 | | - const overPurchased: CartItem = { |
30 | | - ...validItem, |
31 | | - property: mockProperty({ tokenInfo: { available: 1, price: 0.1 } }), |
32 | | - quantity: 5, |
33 | | - }; |
34 | | - |
35 | | - const result = await BatchTransactionService.executeBatchPurchase( |
36 | | - [overPurchased], |
37 | | - walletAddress |
38 | | - ); |
39 | | - |
40 | | - expect(result.success).toBe(false); |
41 | | - expect(result.error).toContain('Validation failed'); |
42 | | - expect(result.results[0].error).toContain('Insufficient tokens'); |
| 92 | + it("rejects a disconnected wallet before submission", async () => { |
| 93 | + const { BatchTransactionService } = await import("../batchTransaction"); |
| 94 | + const executor = createExecutor({ |
| 95 | + transactionHash, |
| 96 | + receiptStatus: "success", |
43 | 97 | }); |
44 | 98 |
|
45 | | - it('returns validation error when property is inactive', async () => { |
46 | | - const { BatchTransactionService } = await import('../batchTransaction'); |
47 | | - const inactiveItem: CartItem = { |
48 | | - ...validItem, |
49 | | - property: mockProperty({ status: 'inactive' }), |
50 | | - }; |
| 99 | + const result = await BatchTransactionService.executeBatchPurchase( |
| 100 | + [validItem], |
| 101 | + "", |
| 102 | + 0.005, |
| 103 | + executor, |
| 104 | + ); |
51 | 105 |
|
52 | | - const result = await BatchTransactionService.executeBatchPurchase( |
53 | | - [inactiveItem], |
54 | | - walletAddress |
55 | | - ); |
| 106 | + expect(result.success).toBe(false); |
| 107 | + expect(result.error).toBe("A connected wallet is required."); |
| 108 | + expect(executor.execute).not.toHaveBeenCalled(); |
| 109 | + }); |
56 | 110 |
|
57 | | - expect(result.success).toBe(false); |
| 111 | + it("rejects quantities above the available balance before submission", async () => { |
| 112 | + const { BatchTransactionService } = await import("../batchTransaction"); |
| 113 | + const executor = createExecutor({ |
| 114 | + transactionHash, |
| 115 | + receiptStatus: "success", |
58 | 116 | }); |
| 117 | + const item = { |
| 118 | + ...validItem, |
| 119 | + quantity: validItem.property.tokenInfo.available + 1, |
| 120 | + }; |
59 | 121 |
|
60 | | - it('throws when no items provided and catches error', async () => { |
61 | | - const { BatchTransactionService } = await import('../batchTransaction'); |
62 | | - const result = await BatchTransactionService.executeBatchPurchase([], walletAddress); |
| 122 | + const result = await BatchTransactionService.executeBatchPurchase( |
| 123 | + [item], |
| 124 | + walletAddress, |
| 125 | + 0.005, |
| 126 | + executor, |
| 127 | + ); |
63 | 128 |
|
64 | | - expect(result.success).toBe(false); |
65 | | - expect(result.results).toHaveLength(0); |
66 | | - }); |
| 129 | + expect(result.success).toBe(false); |
| 130 | + expect(result.error).toContain("Insufficient tokens available"); |
| 131 | + expect(executor.execute).not.toHaveBeenCalled(); |
| 132 | + }); |
67 | 133 |
|
68 | | - it('uses demo mode when NEXT_PUBLIC_DEMO_TX is true', async () => { |
69 | | - process.env.NEXT_PUBLIC_DEMO_TX = 'true'; |
70 | | - jest.resetModules(); |
| 134 | + it("fails honestly when no deployed contract executor is configured", async () => { |
| 135 | + const { BatchTransactionService } = await import("../batchTransaction"); |
71 | 136 |
|
72 | | - const { BatchTransactionService } = await import('../batchTransaction'); |
73 | | - const result = await BatchTransactionService.executeBatchPurchase( |
74 | | - [validItem], |
75 | | - walletAddress |
76 | | - ); |
| 137 | + const result = await BatchTransactionService.executeBatchPurchase( |
| 138 | + [validItem], |
| 139 | + walletAddress, |
| 140 | + 0.005, |
| 141 | + ); |
77 | 142 |
|
78 | | - expect(result.success).toBe(true); |
79 | | - expect(result.transactionHash).toMatch(/^0x[a-f0-9]{64}$/); |
80 | | - expect(result.totalGasUsed).toBeGreaterThan(0); |
| 143 | + expect(result).toEqual({ |
| 144 | + success: false, |
| 145 | + results: [ |
| 146 | + { |
| 147 | + propertyId: "prop-1", |
| 148 | + success: false, |
| 149 | + error: "Batch purchase is not configured for this network.", |
| 150 | + }, |
| 151 | + ], |
| 152 | + error: "Batch purchase is not configured for this network.", |
81 | 153 | }); |
| 154 | + expect(result.transactionHash).toBeUndefined(); |
82 | 155 | }); |
83 | 156 |
|
84 | | - describe('estimateGas', () => { |
85 | | - it('returns base gas for empty items', async () => { |
86 | | - const { BatchTransactionService } = await import('../batchTransaction'); |
87 | | - const gas = BatchTransactionService.estimateGas([]); |
88 | | - expect(gas).toBe(0.005); |
| 157 | + it("returns the executor hash only after a successful receipt", async () => { |
| 158 | + const { BatchTransactionService } = await import("../batchTransaction"); |
| 159 | + const executor = createExecutor({ |
| 160 | + transactionHash, |
| 161 | + receiptStatus: "success", |
89 | 162 | }); |
90 | 163 |
|
91 | | - it('calculates gas proportionally to item count', async () => { |
92 | | - const { BatchTransactionService } = await import('../batchTransaction'); |
93 | | - const gas1 = BatchTransactionService.estimateGas([validItem]); |
94 | | - const gas3 = BatchTransactionService.estimateGas([validItem, validItem, validItem]); |
95 | | - expect(gas3).toBeGreaterThan(gas1); |
| 164 | + const result = await BatchTransactionService.executeBatchPurchase( |
| 165 | + [validItem], |
| 166 | + walletAddress, |
| 167 | + 0.005, |
| 168 | + executor, |
| 169 | + ); |
| 170 | + |
| 171 | + expect(result.success).toBe(true); |
| 172 | + expect(result.transactionHash).toBe(transactionHash); |
| 173 | + expect(result.results).toEqual([ |
| 174 | + { |
| 175 | + propertyId: "prop-1", |
| 176 | + success: true, |
| 177 | + transactionHash, |
| 178 | + }, |
| 179 | + ]); |
| 180 | + expect(executor.execute).toHaveBeenCalledWith({ |
| 181 | + walletAddress, |
| 182 | + slippageTolerance: 0.005, |
| 183 | + items: [ |
| 184 | + { |
| 185 | + propertyId: "prop-1", |
| 186 | + quantity: 2, |
| 187 | + expectedAmount: 0.2, |
| 188 | + minimumAmount: 0.199, |
| 189 | + }, |
| 190 | + ], |
| 191 | + } satisfies BatchPurchaseRequest); |
| 192 | + }); |
| 193 | + |
| 194 | + it("does not report success when the receipt is reverted", async () => { |
| 195 | + const { BatchTransactionService } = await import("../batchTransaction"); |
| 196 | + const executor = createExecutor({ |
| 197 | + transactionHash, |
| 198 | + receiptStatus: "reverted", |
96 | 199 | }); |
| 200 | + |
| 201 | + const result = await BatchTransactionService.executeBatchPurchase( |
| 202 | + [validItem], |
| 203 | + walletAddress, |
| 204 | + 0.005, |
| 205 | + executor, |
| 206 | + ); |
| 207 | + |
| 208 | + expect(result.success).toBe(false); |
| 209 | + expect(result.error).toBe("Batch purchase transaction reverted."); |
| 210 | + expect(result.transactionHash).toBeUndefined(); |
97 | 211 | }); |
98 | 212 |
|
99 | | - describe('getTransactionStatus', () => { |
100 | | - it('returns pending when receipt is not available', async () => { |
101 | | - const { BatchTransactionService } = await import('../batchTransaction'); |
102 | | - const result = await BatchTransactionService.getTransactionStatus( |
103 | | - '0x0000000000000000000000000000000000000000000000000000000000000000' |
104 | | - ); |
| 213 | + it("returns a decoded reason for a provider revert", async () => { |
| 214 | + const { BatchTransactionService } = await import("../batchTransaction"); |
| 215 | + const executor: BatchPurchaseExecutor = { |
| 216 | + execute: jest.fn(async () => { |
| 217 | + throw Object.assign(new Error("execution reverted"), { |
| 218 | + data: "0x08c379a0", |
| 219 | + }); |
| 220 | + }), |
| 221 | + }; |
105 | 222 |
|
106 | | - expect(result.status).toBe('pending'); |
107 | | - }); |
| 223 | + const result = await BatchTransactionService.executeBatchPurchase( |
| 224 | + [validItem], |
| 225 | + walletAddress, |
| 226 | + 0.005, |
| 227 | + executor, |
| 228 | + ); |
| 229 | + |
| 230 | + expect(result.success).toBe(false); |
| 231 | + expect(result.error).toBe("Insufficient balance"); |
108 | 232 | }); |
109 | 233 |
|
110 | | - describe('waitForConfirmation', () => { |
111 | | - it('returns timeout when transaction is not found', async () => { |
112 | | - const { BatchTransactionService } = await import('../batchTransaction'); |
113 | | - const result = await BatchTransactionService.waitForConfirmation( |
114 | | - '0x0000000000000000000000000000000000000000000000000000000000000000', |
115 | | - 100 |
116 | | - ); |
| 234 | + it("returns a user rejection without fabricating a hash", async () => { |
| 235 | + const { BatchTransactionService } = await import("../batchTransaction"); |
| 236 | + const executor: BatchPurchaseExecutor = { |
| 237 | + execute: jest.fn(async () => { |
| 238 | + throw Object.assign(new Error("User denied transaction"), { |
| 239 | + code: 4001, |
| 240 | + }); |
| 241 | + }), |
| 242 | + }; |
| 243 | + |
| 244 | + const result = await BatchTransactionService.executeBatchPurchase( |
| 245 | + [validItem], |
| 246 | + walletAddress, |
| 247 | + 0.005, |
| 248 | + executor, |
| 249 | + ); |
117 | 250 |
|
118 | | - expect(result.status).toBe('timeout'); |
| 251 | + expect(result).toEqual({ |
| 252 | + success: false, |
| 253 | + results: [ |
| 254 | + { |
| 255 | + propertyId: "prop-1", |
| 256 | + success: false, |
| 257 | + error: "Transaction rejected by the user.", |
| 258 | + }, |
| 259 | + ], |
| 260 | + error: "Transaction rejected by the user.", |
119 | 261 | }); |
120 | 262 | }); |
| 263 | + |
| 264 | + it("computes minimum amounts from the requested slippage", () => { |
| 265 | + expect(calculateMinimumAmount(0.2, 0.1)).toBeCloseTo(0.18); |
| 266 | + }); |
121 | 267 | }); |
0 commit comments