Skip to content

Commit 8245291

Browse files
feat: add integration tests for auto-release collision and webhook HMAC, fix CI lint workflow (#352)
Co-authored-by: Levi-Ojukwu <ojukwulevichinedu@gmail.com>
1 parent 91828f2 commit 8245291

5 files changed

Lines changed: 573 additions & 1 deletion

File tree

.github/workflows/lint.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
jobs:
99
lint:
1010
runs-on: ubuntu-latest
11+
timeout-minutes: 10
1112
steps:
1213
- uses: actions/checkout@v4
1314

@@ -22,3 +23,5 @@ jobs:
2223

2324
- name: Run ESLint
2425
run: npm run lint:check
26+
env:
27+
ESLINT_USE_FLAT_CONFIG: 'true'

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ WAVE_ISSUES.md
66
/coverage
77
.github/copilot-instructions.md
88
/data
9+
.eslintcache

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"start:debug": "nest start --debug --watch",
1313
"start:prod": "node dist/main",
1414
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
15-
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\"",
15+
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --cache --cache-location .eslintcache",
1616
"test": "jest",
1717
"test:watch": "jest --watch",
1818
"test:integration": "jest --config ./test/jest-integration.json",
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-argument */
2+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
3+
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
4+
import { Test, TestingModule } from '@nestjs/testing';
5+
import { PrismaService } from '../../src/prisma/prisma.service';
6+
import { EscrowRepository } from '../../src/escrow/escrow.repository';
7+
import { AutoReleaseService } from '../../src/escrow/auto-release.service';
8+
import { ContractService } from '../../src/stellar/contract.service';
9+
import { CacheService } from '../../src/cache/cache.service';
10+
11+
/**
12+
* Issue #277 — Integration tests for concurrent auto-release collision detection.
13+
*
14+
* Verifies that when two worker instances attempt to release the same escrow
15+
* simultaneously, only one succeeds and the other gracefully fails. Tests the
16+
* DB-level optimistic locking via autoReleaseSubmittedAt.
17+
*/
18+
describe('Auto-release collision detection (issue #277)', () => {
19+
let prisma: PrismaService;
20+
let escrowRepository: EscrowRepository;
21+
let contractService: jest.Mocked<ContractService>;
22+
let service: AutoReleaseService;
23+
24+
const pastDelivery = new Date(Date.now() - 50 * 60 * 60 * 1000);
25+
26+
beforeEach(async () => {
27+
const moduleRef: TestingModule = await Test.createTestingModule({
28+
providers: [
29+
PrismaService,
30+
EscrowRepository,
31+
AutoReleaseService,
32+
{
33+
provide: ContractService,
34+
useValue: {
35+
submitAutoRelease: jest.fn(),
36+
},
37+
},
38+
{
39+
provide: CacheService,
40+
useValue: {
41+
get: jest.fn(),
42+
set: jest.fn(),
43+
del: jest.fn(),
44+
},
45+
},
46+
],
47+
}).compile();
48+
49+
prisma = moduleRef.get(PrismaService);
50+
escrowRepository = moduleRef.get(EscrowRepository);
51+
contractService = moduleRef.get<jest.Mocked<ContractService>>(ContractService);
52+
service = moduleRef.get(AutoReleaseService);
53+
54+
await prisma.reset();
55+
});
56+
57+
afterEach(async () => {
58+
jest.restoreAllMocks();
59+
await prisma.$disconnect();
60+
});
61+
62+
// ── markAutoReleaseSubmitting optimistic locking ──────────────────────────
63+
64+
describe('markAutoReleaseSubmitting', () => {
65+
it('claims the escrow and returns the record on first call', async () => {
66+
const escrow = await prisma.escrow.create({
67+
data: {
68+
itemName: 'Camera',
69+
itemRef: 'camera-lock-001',
70+
amount: 250,
71+
currency: 'USDC',
72+
buyerAddress: 'buyer-1',
73+
vendorAddress: 'vendor-1',
74+
state: 'SHIPPED',
75+
trackingId: 'TRK-001',
76+
shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000),
77+
deliveredAt: pastDelivery,
78+
deliveryRecordedAt: pastDelivery,
79+
},
80+
});
81+
82+
const result = await escrowRepository.markAutoReleaseSubmitting(
83+
escrow.id,
84+
);
85+
86+
expect(result).not.toBeNull();
87+
expect(result!.autoReleaseSubmittedAt).toBeInstanceOf(Date);
88+
expect(result!.id).toBe(escrow.id);
89+
});
90+
91+
it('returns null when the lock is already held', async () => {
92+
const escrow = await prisma.escrow.create({
93+
data: {
94+
itemName: 'Laptop',
95+
itemRef: 'laptop-lock-001',
96+
amount: 1200,
97+
currency: 'USDC',
98+
buyerAddress: 'buyer-2',
99+
vendorAddress: 'vendor-2',
100+
state: 'SHIPPED',
101+
trackingId: 'TRK-002',
102+
shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000),
103+
deliveredAt: pastDelivery,
104+
deliveryRecordedAt: pastDelivery,
105+
},
106+
});
107+
108+
// First claim succeeds
109+
const first = await escrowRepository.markAutoReleaseSubmitting(escrow.id);
110+
expect(first).not.toBeNull();
111+
112+
// Second claim fails — lock is held
113+
const second = await escrowRepository.markAutoReleaseSubmitting(
114+
escrow.id,
115+
);
116+
expect(second).toBeNull();
117+
});
118+
119+
it('returns null for a non-existent escrow', async () => {
120+
const result = await escrowRepository.markAutoReleaseSubmitting(
121+
'non-existent-id',
122+
);
123+
expect(result).toBeNull();
124+
});
125+
126+
it('allows re-claiming after lock is cleared', async () => {
127+
const escrow = await prisma.escrow.create({
128+
data: {
129+
itemName: 'Tablet',
130+
itemRef: 'tablet-lock-001',
131+
amount: 400,
132+
currency: 'USDC',
133+
buyerAddress: 'buyer-3',
134+
vendorAddress: 'vendor-3',
135+
state: 'SHIPPED',
136+
trackingId: 'TRK-003',
137+
shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000),
138+
deliveredAt: pastDelivery,
139+
deliveryRecordedAt: pastDelivery,
140+
},
141+
});
142+
143+
// Claim
144+
const first = await escrowRepository.markAutoReleaseSubmitting(escrow.id);
145+
expect(first).not.toBeNull();
146+
147+
// Clear lock
148+
await escrowRepository.clearAutoReleaseSubmitting(escrow.id);
149+
150+
// Re-claim succeeds
151+
const second = await escrowRepository.markAutoReleaseSubmitting(
152+
escrow.id,
153+
);
154+
expect(second).not.toBeNull();
155+
});
156+
});
157+
158+
// ── Concurrent auto-release via AutoReleaseService ────────────────────────
159+
160+
describe('concurrent AutoReleaseService.run()', () => {
161+
it('only submits one transaction when two workers race on the same escrow', async () => {
162+
const escrow = await prisma.escrow.create({
163+
data: {
164+
itemName: 'Camera',
165+
itemRef: 'camera-concurrent-001',
166+
amount: 250,
167+
currency: 'USDC',
168+
buyerAddress: 'buyer-1',
169+
vendorAddress: 'vendor-1',
170+
state: 'SHIPPED',
171+
trackingId: 'TRK-001',
172+
shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000),
173+
deliveredAt: pastDelivery,
174+
deliveryRecordedAt: pastDelivery,
175+
},
176+
});
177+
178+
contractService.submitAutoRelease.mockResolvedValue('tx-hash-1');
179+
180+
// Run two concurrent workers
181+
await Promise.all([service.run(), service.run()]);
182+
183+
// Only one submission should have occurred
184+
expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(1);
185+
expect(contractService.submitAutoRelease).toHaveBeenCalledWith(
186+
escrow.id,
187+
);
188+
189+
// Escrow state should be consistent
190+
const after = await prisma.escrow.findUnique({
191+
where: { id: escrow.id },
192+
});
193+
expect(after!.state).toBe('RELEASED');
194+
expect(after!.autoReleaseTxHash).toBe('tx-hash-1');
195+
});
196+
197+
it('releases the lock on failure so the next cycle can retry', async () => {
198+
const escrow = await prisma.escrow.create({
199+
data: {
200+
itemName: 'Monitor',
201+
itemRef: 'monitor-fail-001',
202+
amount: 300,
203+
currency: 'USDC',
204+
buyerAddress: 'buyer-4',
205+
vendorAddress: 'vendor-4',
206+
state: 'SHIPPED',
207+
trackingId: 'TRK-004',
208+
shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000),
209+
deliveredAt: pastDelivery,
210+
deliveryRecordedAt: pastDelivery,
211+
},
212+
});
213+
214+
// First call fails, second succeeds
215+
contractService.submitAutoRelease
216+
.mockRejectedValueOnce(new Error('network error'))
217+
.mockResolvedValueOnce('tx-hash-2');
218+
219+
// First run: fails and releases lock
220+
await service.run();
221+
222+
const afterFirst = await prisma.escrow.findUnique({
223+
where: { id: escrow.id },
224+
});
225+
expect(afterFirst!.state).toBe('SHIPPED');
226+
expect(afterFirst!.autoReleaseSubmittedAt).toBeNull();
227+
228+
// Second run: retries and succeeds
229+
await service.run();
230+
231+
const afterSecond = await prisma.escrow.findUnique({
232+
where: { id: escrow.id },
233+
});
234+
expect(afterSecond!.state).toBe('RELEASED');
235+
expect(afterSecond!.autoReleaseTxHash).toBe('tx-hash-2');
236+
});
237+
238+
it('processes multiple escrows concurrently without collision', async () => {
239+
const escrow1 = await prisma.escrow.create({
240+
data: {
241+
itemName: 'Camera',
242+
itemRef: 'camera-multi-001',
243+
amount: 250,
244+
currency: 'USDC',
245+
buyerAddress: 'buyer-1',
246+
vendorAddress: 'vendor-1',
247+
state: 'SHIPPED',
248+
trackingId: 'TRK-001',
249+
shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000),
250+
deliveredAt: pastDelivery,
251+
deliveryRecordedAt: pastDelivery,
252+
},
253+
});
254+
255+
const escrow2 = await prisma.escrow.create({
256+
data: {
257+
itemName: 'Laptop',
258+
itemRef: 'laptop-multi-001',
259+
amount: 1200,
260+
currency: 'USDC',
261+
buyerAddress: 'buyer-2',
262+
vendorAddress: 'vendor-2',
263+
state: 'SHIPPED',
264+
trackingId: 'TRK-002',
265+
shippedAt: new Date(Date.now() - 55 * 60 * 60 * 1000),
266+
deliveredAt: pastDelivery,
267+
deliveryRecordedAt: pastDelivery,
268+
},
269+
});
270+
271+
contractService.submitAutoRelease
272+
.mockResolvedValueOnce('tx-hash-a')
273+
.mockResolvedValueOnce('tx-hash-b');
274+
275+
await service.run();
276+
277+
// Both escrows should be released
278+
expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(2);
279+
280+
const after1 = await prisma.escrow.findUnique({
281+
where: { id: escrow1.id },
282+
});
283+
expect(after1!.state).toBe('RELEASED');
284+
expect(after1!.autoReleaseTxHash).toBe('tx-hash-a');
285+
286+
const after2 = await prisma.escrow.findUnique({
287+
where: { id: escrow2.id },
288+
});
289+
expect(after2!.state).toBe('RELEASED');
290+
expect(after2!.autoReleaseTxHash).toBe('tx-hash-b');
291+
});
292+
293+
it('skips escrows that are already auto-released', async () => {
294+
await prisma.escrow.create({
295+
data: {
296+
itemName: 'Headphones',
297+
itemRef: 'headphones-skip-001',
298+
amount: 80,
299+
currency: 'USDC',
300+
buyerAddress: 'buyer-5',
301+
vendorAddress: 'vendor-5',
302+
state: 'SHIPPED',
303+
trackingId: 'TRK-005',
304+
shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000),
305+
deliveredAt: pastDelivery,
306+
deliveryRecordedAt: pastDelivery,
307+
autoReleaseTxHash: 'existing-tx-hash',
308+
},
309+
});
310+
311+
await service.run();
312+
313+
expect(contractService.submitAutoRelease).not.toHaveBeenCalled();
314+
});
315+
316+
it('skips escrows with active disputes', async () => {
317+
const escrow = await prisma.escrow.create({
318+
data: {
319+
itemName: 'Phone',
320+
itemRef: 'phone-dispute-001',
321+
amount: 800,
322+
currency: 'USDC',
323+
buyerAddress: 'buyer-6',
324+
vendorAddress: 'vendor-6',
325+
state: 'SHIPPED',
326+
trackingId: 'TRK-006',
327+
shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000),
328+
deliveredAt: pastDelivery,
329+
deliveryRecordedAt: pastDelivery,
330+
},
331+
});
332+
333+
await prisma.dispute.create({
334+
data: {
335+
escrowId: escrow.id,
336+
reason: 'ITEM_NOT_AS_DESCRIBED',
337+
description: 'Phone has defects',
338+
status: 'OPEN',
339+
},
340+
});
341+
342+
await service.run();
343+
344+
expect(contractService.submitAutoRelease).not.toHaveBeenCalled();
345+
});
346+
});
347+
});

0 commit comments

Comments
 (0)