Skip to content

Commit 618e95c

Browse files
authored
test(e2e): add concurrent auto-release collision detection tests (#302) (#348)
Closes #302
1 parent c361649 commit 618e95c

1 file changed

Lines changed: 232 additions & 0 deletions

File tree

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { INestApplication, Logger, ValidationPipe } from '@nestjs/common';
2+
import { Test, TestingModule } from '@nestjs/testing';
3+
import { AppModule } from '../src/app.module';
4+
import { PrismaService } from '../src/prisma/prisma.service';
5+
import { AutoReleaseWorker } from '../src/workers/auto-release.worker';
6+
import { ContractService } from '../src/stellar/contract.service';
7+
8+
/**
9+
* E2E tests for concurrent auto-release collision detection (issues #302, #307, #308).
10+
*
11+
* Because Node.js is single-threaded, two Promise.all concurrent worker.run() calls
12+
* interleave at every `await` point. Both workers call findAutoReleaseEligible()
13+
* before either has finished processing, so both receive the same eligible escrow
14+
* in their snapshot. The collision guard relies on:
15+
* 1. The in-memory check: `escrow.state === 'COMPLETED' || escrow.autoReleaseTxHash`
16+
* (stale snapshot — does NOT protect against concurrent runs that fetched
17+
* the list before the first write completed).
18+
* 2. The DB-level guard: findAutoReleaseEligible filters autoReleaseTxHash: null,
19+
* but both workers have already fetched their snapshots, so this only helps
20+
* on the NEXT poll cycle.
21+
* 3. markAutoReleaseSubmitting atomically sets autoReleaseSubmittedAt and returns
22+
* null if already set — this is the true optimistic lock the worker should use.
23+
*
24+
* These tests verify that even under concurrent execution:
25+
* - submitAutoRelease is called exactly once per escrow
26+
* - The final DB state reflects exactly one successful auto-release
27+
* - The worker correctly skips an escrow it detects as already processed
28+
*/
29+
describe('Auto-Release Worker — concurrent collision detection (issues #302/#307/#308)', () => {
30+
let app: INestApplication;
31+
let prisma: PrismaService;
32+
let worker: AutoReleaseWorker;
33+
let contractService: ContractService;
34+
let loggerWarnSpy: jest.SpyInstance;
35+
36+
beforeEach(async () => {
37+
const moduleFixture: TestingModule = await Test.createTestingModule({
38+
imports: [AppModule],
39+
}).compile();
40+
41+
app = moduleFixture.createNestApplication();
42+
app.useGlobalPipes(
43+
new ValidationPipe({ whitelist: true, transform: true }),
44+
);
45+
await app.init();
46+
47+
prisma = app.get(PrismaService);
48+
worker = app.get(AutoReleaseWorker);
49+
contractService = app.get(ContractService);
50+
51+
await prisma.reset();
52+
53+
// Spy on the Logger prototype so we can assert skip/warn log entries
54+
// without coupling to internal Logger instances.
55+
loggerWarnSpy = jest
56+
.spyOn(Logger.prototype, 'warn')
57+
.mockImplementation(() => undefined);
58+
});
59+
60+
afterEach(async () => {
61+
jest.restoreAllMocks();
62+
await app.close();
63+
});
64+
65+
/**
66+
* Helper: create a single escrow that is eligible for auto-release.
67+
* deliveredAt is 50 hours ago (well past the 48-hour threshold).
68+
*/
69+
async function createEligibleEscrow(suffix: string) {
70+
const pastDelivery = new Date(Date.now() - 50 * 60 * 60 * 1000);
71+
return prisma.escrow.create({
72+
data: {
73+
itemName: `Concurrent Item ${suffix}`,
74+
itemRef: `concurrent-item-${suffix}`,
75+
amount: 500,
76+
currency: 'USDC',
77+
buyerAddress: `buyer-concurrent-${suffix}`,
78+
vendorAddress: `vendor-concurrent-${suffix}`,
79+
state: 'SHIPPED',
80+
trackingId: `TRK-CONCURRENT-${suffix}`,
81+
shippedAt: new Date(Date.now() - 60 * 60 * 60 * 1000),
82+
deliveredAt: pastDelivery,
83+
deliveryRecordedAt: pastDelivery,
84+
},
85+
});
86+
}
87+
88+
it('calls submitAutoRelease exactly once when two workers race for the same eligible escrow', async () => {
89+
const TX_HASH = 'tx-hash-concurrent-001';
90+
91+
// Slow down submitAutoRelease so both workers advance past their
92+
// findAutoReleaseEligible fetch before either completes processing.
93+
// A 10 ms delay is enough to expose the interleaving on Node's event loop.
94+
jest
95+
.spyOn(contractService, 'submitAutoRelease')
96+
.mockImplementation(
97+
() =>
98+
new Promise((resolve) =>
99+
setTimeout(() => resolve(TX_HASH), 10),
100+
),
101+
);
102+
103+
const escrow = await createEligibleEscrow('001');
104+
105+
// Run two workers concurrently. Both will pick up the same eligible
106+
// snapshot before either has finished writing.
107+
await Promise.all([worker.run(), worker.run()]);
108+
109+
// Only one on-chain submission should have been made.
110+
expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(1);
111+
expect(contractService.submitAutoRelease).toHaveBeenCalledWith(escrow.id);
112+
113+
// The escrow must be in its terminal state with the correct tx hash.
114+
const after = await prisma.escrow.findUnique({ where: { id: escrow.id } });
115+
expect(after).not.toBeNull();
116+
expect(after!.autoReleaseTxHash).toBe(TX_HASH);
117+
expect(after!.autoReleaseSubmittedAt).toBeTruthy();
118+
// State is set to COMPLETED by markAutoReleaseCompleted.
119+
expect(after!.state).toBe('COMPLETED');
120+
});
121+
122+
it('does not double-process an escrow when the second run starts after the first has already committed', async () => {
123+
const TX_HASH = 'tx-hash-sequential-002';
124+
125+
jest
126+
.spyOn(contractService, 'submitAutoRelease')
127+
.mockResolvedValue(TX_HASH);
128+
129+
const escrow = await createEligibleEscrow('002');
130+
131+
// First run completes fully before the second starts.
132+
await worker.run();
133+
134+
// Escrow should now be COMPLETED and excluded from the second run's
135+
// eligible query (autoReleaseTxHash is no longer null).
136+
await worker.run();
137+
138+
expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(1);
139+
140+
const after = await prisma.escrow.findUnique({ where: { id: escrow.id } });
141+
expect(after!.autoReleaseTxHash).toBe(TX_HASH);
142+
expect(after!.state).toBe('COMPLETED');
143+
});
144+
145+
it('skips an escrow mid-loop when a sibling concurrent worker has already written autoReleaseTxHash to the DB', async () => {
146+
const TX_HASH = 'tx-hash-midloop-003';
147+
148+
// Track invocation order to confirm only one submission was attempted.
149+
const callOrder: string[] = [];
150+
151+
jest
152+
.spyOn(contractService, 'submitAutoRelease')
153+
.mockImplementation(async (id: string) => {
154+
callOrder.push(id);
155+
// Pause long enough for the second worker's loop to reach its own
156+
// in-memory check, giving the test a deterministic interleave window.
157+
await new Promise((resolve) => setTimeout(resolve, 20));
158+
return TX_HASH;
159+
});
160+
161+
const escrow = await createEligibleEscrow('003');
162+
163+
await Promise.all([worker.run(), worker.run()]);
164+
165+
// Regardless of interleave order, the escrow must only be submitted once.
166+
expect(callOrder.filter((id) => id === escrow.id)).toHaveLength(1);
167+
168+
const after = await prisma.escrow.findUnique({ where: { id: escrow.id } });
169+
expect(after!.autoReleaseTxHash).toBe(TX_HASH);
170+
expect(after!.state).toBe('COMPLETED');
171+
});
172+
173+
it('processes multiple independent escrows exactly once each under concurrent workers', async () => {
174+
const TX_HASH_A = 'tx-hash-multi-004a';
175+
const TX_HASH_B = 'tx-hash-multi-004b';
176+
177+
// Return distinct hashes keyed by call order so we can assert both escrows
178+
// end up with their respective hash (the spy always returns the same value
179+
// here because both IDs map to the same mock; adjust if per-id routing matters).
180+
jest
181+
.spyOn(contractService, 'submitAutoRelease')
182+
.mockResolvedValueOnce(TX_HASH_A)
183+
.mockResolvedValueOnce(TX_HASH_B);
184+
185+
const escrowA = await createEligibleEscrow('004a');
186+
const escrowB = await createEligibleEscrow('004b');
187+
188+
await Promise.all([worker.run(), worker.run()]);
189+
190+
// Total across both workers must equal the number of distinct escrows.
191+
expect(contractService.submitAutoRelease).toHaveBeenCalledTimes(2);
192+
193+
const afterA = await prisma.escrow.findUnique({ where: { id: escrowA.id } });
194+
const afterB = await prisma.escrow.findUnique({ where: { id: escrowB.id } });
195+
196+
// Each escrow must be in a terminal auto-release state.
197+
expect(afterA!.autoReleaseTxHash).not.toBeNull();
198+
expect(afterA!.state).toBe('COMPLETED');
199+
expect(afterB!.autoReleaseTxHash).not.toBeNull();
200+
expect(afterB!.state).toBe('COMPLETED');
201+
});
202+
203+
it('leaves escrow in a consistent state when submitAutoRelease throws during a concurrent run', async () => {
204+
const TX_HASH = 'tx-hash-error-005';
205+
let callCount = 0;
206+
207+
jest
208+
.spyOn(contractService, 'submitAutoRelease')
209+
.mockImplementation(async () => {
210+
callCount += 1;
211+
if (callCount === 1) {
212+
// First call fails; simulate a transient network error.
213+
await new Promise((resolve) => setTimeout(resolve, 10));
214+
throw new Error('Stellar node timeout');
215+
}
216+
return TX_HASH;
217+
});
218+
219+
const escrow = await createEligibleEscrow('005');
220+
221+
// One worker fails, the other should still complete successfully.
222+
await Promise.all([worker.run(), worker.run()]);
223+
224+
// Exactly two attempts were made (one from each concurrent worker).
225+
expect(callCount).toBe(2);
226+
227+
const after = await prisma.escrow.findUnique({ where: { id: escrow.id } });
228+
// The successful call's hash must be persisted.
229+
expect(after!.autoReleaseTxHash).toBe(TX_HASH);
230+
expect(after!.state).toBe('COMPLETED');
231+
});
232+
});

0 commit comments

Comments
 (0)