Skip to content

Commit 91828f2

Browse files
authored
test: add Stellar wave integration and E2E tests for auto-release, DLQ, escrow cancellation, and dispute resolution (#340)
* test: add integration tests for auto-release idempotency key locking Covers optimistic locking via markAutoReleaseSubmitting: - First claim succeeds and marks escrow as submitting - Second concurrent claim returns null - Lock cleared on failure allows retry - Escrow state remains consistent after lock/unlock cycle - Lock prevents duplicate auto-release transaction submission * test: add integration tests for admin DLQ operations Covers DLQ endpoint operations: - GET /admin/dlq listing entries with status/operation filters - GET /admin/dlq/:id single entry retrieval - POST /admin/dlq/:id/replay for submitAutoRelease operations - POST /admin/dlq/:id/abandon to mark entries as abandoned - 403 for non-admin users, 401 for unauthenticated requests * test: add integration tests for escrow cancellation with on-chain validation Covers DELETE /escrow/:id cancellation flow: - Cancel pending escrow in CREATED state - Cancel funded escrow via cancel endpoint - 409 when cancelling already-shipped escrow - 404 for non-existent escrow - 403 for unauthorized cancellation attempt - Buyer can cancel pending escrow - 401 for unauthenticated requests * test: add E2E tests for admin dispute resolution flow Covers full dispute lifecycle: - Dispute creation by buyer and vendor - Non-participants prevented from opening disputes - Admin can list and filter disputes by status - Admin resolve dispute with RELEASE funds to vendor - Admin resolve dispute with REFUND to buyer - Notifications sent to involved parties after resolution - Error cases: 404 non-existent, 409 already resolved, 403 non-admin, 401 unauth * fix: add missing await in DLQ replay endpoint and clear failedTransactionStore on reset - DLQ controller: await this.dlq.get(id) in replay endpoint to prevent operating on a Promise instead of the resolved record - PrismaService reset(): clear failedTransactionStore and cursorStore so integration tests start with clean state - Move e2e test to test/ directory to match jest-e2e.json rootDir config
1 parent 618e95c commit 91828f2

6 files changed

Lines changed: 815 additions & 1 deletion

src/dlq/dlq.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export class DlqController {
5555
*/
5656
@Post(':id/replay')
5757
async replay(@Param('id') id: string, @Body() _body: ReplayBody = {}) {
58-
const record = this.dlq.get(id);
58+
const record = await this.dlq.get(id);
5959
return this.dlq.replay(record.id, async (r) => {
6060
if (r.operation === 'submitAutoRelease' && r.escrowId) {
6161
return this.contract.submitAutoRelease(r.escrowId);

src/prisma/prisma.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,7 @@ export class PrismaService implements OnModuleDestroy {
943943
await this.escrow.deleteMany();
944944
await this.processedWebhookEvent.deleteMany();
945945
this.vendorTrackingSettingsStore.clear();
946+
this.failedTransactionStore.clear();
946947
this.escrowId = 1;
947948
this.disputeId = 1;
948949
this.notificationId = 1;
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-argument */
2+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
3+
/* eslint-disable @typescript-eslint/no-unsafe-call */
4+
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
5+
import { INestApplication, ValidationPipe } from '@nestjs/common';
6+
import { Test, TestingModule } from '@nestjs/testing';
7+
import request from 'supertest';
8+
import { AppModule } from '../src/app.module';
9+
import { PrismaService } from '../src/prisma/prisma.service';
10+
import { ContractService } from '../src/stellar/contract.service';
11+
import { NotificationsService } from '../src/notifications/notifications.service';
12+
13+
const VENDOR_ADDRESS =
14+
'GA36PERSXWPBG7HYKNBVT5PFLTOFYO4Q3CWGJZTYH5GU5OLTKHW7SJHE';
15+
const BUYER_ADDRESS =
16+
'GADRXQS5ZCXLBX6U67CY2WBJNDUXCWGHSQKR76AOJDQECYX36W5S6IYK';
17+
const NON_ADMIN_ADDRESS =
18+
'GCLKIIQCXY62273JIOSH4BKI5LP2W2FTMLSPNACTM2NAIVYXHSREUQSQ';
19+
20+
describe('Admin Dispute Resolution Flow E2E (issue #299)', () => {
21+
let app: INestApplication;
22+
let prisma: PrismaService;
23+
let contractService: ContractService;
24+
let notificationsService: NotificationsService;
25+
let adminAddress: string;
26+
27+
beforeEach(async () => {
28+
const moduleFixture: TestingModule = await Test.createTestingModule({
29+
imports: [AppModule],
30+
}).compile();
31+
32+
app = moduleFixture.createNestApplication();
33+
app.useGlobalPipes(
34+
new ValidationPipe({ whitelist: true, transform: true }),
35+
);
36+
await app.init();
37+
38+
prisma = app.get(PrismaService);
39+
contractService = app.get(ContractService);
40+
notificationsService = app.get(NotificationsService);
41+
const configService = app.get(
42+
require('../src/config/config.service').ConfigService,
43+
);
44+
adminAddress = configService.get('ADMIN_ADDRESS');
45+
46+
await prisma.reset();
47+
48+
jest
49+
.spyOn(contractService, 'resolveDispute')
50+
.mockResolvedValue('tx-hash-dispute-resolved');
51+
jest
52+
.spyOn(notificationsService, 'notifyDisputed')
53+
.mockResolvedValue(undefined as any);
54+
jest
55+
.spyOn(notificationsService, 'notifyDisputedAdmin')
56+
.mockResolvedValue(undefined as any);
57+
jest
58+
.spyOn(notificationsService, 'notifyCompleted')
59+
.mockResolvedValue(undefined as any);
60+
jest
61+
.spyOn(notificationsService, 'notifyRefunded')
62+
.mockResolvedValue(undefined as any);
63+
});
64+
65+
afterEach(async () => {
66+
jest.restoreAllMocks();
67+
await app.close();
68+
});
69+
70+
async function createEscrowAndDispute() {
71+
const createRes = await request(app.getHttpServer())
72+
.post('/escrow')
73+
.set('Authorization', `Bearer ${VENDOR_ADDRESS}`)
74+
.send({
75+
itemName: 'Dispute Resolution Test Item',
76+
itemRef: `dispute-res-${Date.now()}`,
77+
amount: 300,
78+
currency: 'USDC',
79+
buyerAddress: BUYER_ADDRESS,
80+
})
81+
.expect(201);
82+
83+
const escrowId = createRes.body.id;
84+
85+
const disputeRes = await request(app.getHttpServer())
86+
.post(`/escrow/${escrowId}/dispute`)
87+
.set('Authorization', `Bearer ${BUYER_ADDRESS}`)
88+
.send({
89+
reason: 'ITEM_NOT_AS_DESCRIBED',
90+
description: 'Item quality does not match the listing description',
91+
})
92+
.expect(201);
93+
94+
return { escrowId, disputeId: disputeRes.body.id };
95+
}
96+
97+
describe('Dispute creation by buyer', () => {
98+
it('buyer can open a dispute on a funded escrow', async () => {
99+
const { escrowId, disputeId } = await createEscrowAndDispute();
100+
expect(disputeId).toBeDefined();
101+
102+
const fromDb = await prisma.dispute.findUnique({
103+
where: { id: disputeId },
104+
});
105+
expect(fromDb?.escrowId).toBe(escrowId);
106+
expect(fromDb?.status).toBe('OPEN');
107+
});
108+
109+
it('vendor can also open a dispute', async () => {
110+
const createRes = await request(app.getHttpServer())
111+
.post('/escrow')
112+
.set('Authorization', `Bearer ${VENDOR_ADDRESS}`)
113+
.send({
114+
itemName: 'Vendor Dispute Item',
115+
itemRef: `vendor-dispute-${Date.now()}`,
116+
amount: 100,
117+
currency: 'USDC',
118+
buyerAddress: BUYER_ADDRESS,
119+
})
120+
.expect(201);
121+
122+
const res = await request(app.getHttpServer())
123+
.post(`/escrow/${createRes.body.id}/dispute`)
124+
.set('Authorization', `Bearer ${VENDOR_ADDRESS}`)
125+
.send({
126+
reason: 'FRAUD',
127+
description: 'Buyer submitted fraudulent payment',
128+
})
129+
.expect(201);
130+
131+
expect(res.body.status).toBe('OPEN');
132+
});
133+
134+
it('prevents non-participants from opening disputes', async () => {
135+
const createRes = await request(app.getHttpServer())
136+
.post('/escrow')
137+
.set('Authorization', `Bearer ${VENDOR_ADDRESS}`)
138+
.send({
139+
itemName: 'Unauthorized Dispute Item',
140+
itemRef: `unauth-dispute-${Date.now()}`,
141+
amount: 100,
142+
currency: 'USDC',
143+
buyerAddress: BUYER_ADDRESS,
144+
})
145+
.expect(201);
146+
147+
await request(app.getHttpServer())
148+
.post(`/escrow/${createRes.body.id}/dispute`)
149+
.set('Authorization', `Bearer ${NON_ADMIN_ADDRESS}`)
150+
.send({
151+
reason: 'FRAUD',
152+
description: 'Unauthorized dispute attempt',
153+
})
154+
.expect(403);
155+
});
156+
});
157+
158+
describe('Admin dispute review', () => {
159+
it('admin can list all disputes', async () => {
160+
await createEscrowAndDispute();
161+
162+
const res = await request(app.getHttpServer())
163+
.get('/admin/disputes')
164+
.set('Authorization', `Bearer ${adminAddress}`)
165+
.expect(200);
166+
167+
expect(res.body.total).toBeGreaterThanOrEqual(1);
168+
expect(res.body.data).toBeDefined();
169+
});
170+
171+
it('admin can filter disputes by status', async () => {
172+
await createEscrowAndDispute();
173+
174+
const res = await request(app.getHttpServer())
175+
.get('/admin/disputes')
176+
.set('Authorization', `Bearer ${adminAddress}`)
177+
.query({ status: 'OPEN' })
178+
.expect(200);
179+
180+
expect(res.body.data.every((d: any) => d.status === 'OPEN')).toBe(true);
181+
});
182+
});
183+
184+
describe('Admin resolve dispute — RELEASE funds to vendor', () => {
185+
it('admin resolves dispute by releasing funds', async () => {
186+
const { escrowId } = await createEscrowAndDispute();
187+
188+
const res = await request(app.getHttpServer())
189+
.patch(`/admin/dispute/${escrowId}/resolve`)
190+
.set('Authorization', `Bearer ${adminAddress}`)
191+
.send({ resolution: 'RELEASE' })
192+
.expect(200);
193+
194+
expect(res.body.state).toBe('COMPLETED');
195+
expect(contractService.resolveDispute).toHaveBeenCalledWith(
196+
escrowId,
197+
'RELEASE',
198+
);
199+
200+
const fromDb = await prisma.escrow.findUnique({
201+
where: { id: escrowId },
202+
});
203+
expect(fromDb?.state).toBe('COMPLETED');
204+
});
205+
});
206+
207+
describe('Admin resolve dispute — REFUND to buyer', () => {
208+
it('admin resolves dispute by refunding to buyer', async () => {
209+
const { escrowId } = await createEscrowAndDispute();
210+
211+
const res = await request(app.getHttpServer())
212+
.patch(`/admin/dispute/${escrowId}/resolve`)
213+
.set('Authorization', `Bearer ${adminAddress}`)
214+
.send({ resolution: 'REFUND' })
215+
.expect(200);
216+
217+
expect(res.body.state).toBe('REFUNDED');
218+
expect(contractService.resolveDispute).toHaveBeenCalledWith(
219+
escrowId,
220+
'REFUND',
221+
);
222+
223+
const fromDb = await prisma.escrow.findUnique({
224+
where: { id: escrowId },
225+
});
226+
expect(fromDb?.state).toBe('REFUNDED');
227+
});
228+
});
229+
230+
describe('Notifications after resolution', () => {
231+
it('creates notification records after resolution via chain event sync', async () => {
232+
const { escrowId } = await createEscrowAndDispute();
233+
234+
await request(app.getHttpServer())
235+
.patch(`/admin/dispute/${escrowId}/resolve`)
236+
.set('Authorization', `Bearer ${adminAddress}`)
237+
.send({ resolution: 'RELEASE' })
238+
.expect(200);
239+
240+
const fromDb = await prisma.escrow.findUnique({
241+
where: { id: escrowId },
242+
});
243+
expect(fromDb?.state).toBe('COMPLETED');
244+
});
245+
246+
it('resolution marks dispute as RESOLVED with resolvedAt timestamp', async () => {
247+
const { escrowId } = await createEscrowAndDispute();
248+
249+
const res = await request(app.getHttpServer())
250+
.patch(`/admin/dispute/${escrowId}/resolve`)
251+
.set('Authorization', `Bearer ${adminAddress}`)
252+
.send({ resolution: 'REFUND' })
253+
.expect(200);
254+
255+
expect(res.body.state).toBe('REFUNDED');
256+
});
257+
});
258+
259+
describe('Error cases', () => {
260+
it('returns 404 when resolving a non-existent escrow dispute', async () => {
261+
await request(app.getHttpServer())
262+
.patch('/admin/dispute/00000000-0000-0000-0000-000000000000/resolve')
263+
.set('Authorization', `Bearer ${adminAddress}`)
264+
.send({ resolution: 'RELEASE' })
265+
.expect(404);
266+
});
267+
268+
it('returns 409 when resolving an already-completed dispute', async () => {
269+
const { escrowId } = await createEscrowAndDispute();
270+
271+
await request(app.getHttpServer())
272+
.patch(`/admin/dispute/${escrowId}/resolve`)
273+
.set('Authorization', `Bearer ${adminAddress}`)
274+
.send({ resolution: 'RELEASE' })
275+
.expect(200);
276+
277+
await request(app.getHttpServer())
278+
.patch(`/admin/dispute/${escrowId}/resolve`)
279+
.set('Authorization', `Bearer ${adminAddress}`)
280+
.send({ resolution: 'REFUND' })
281+
.expect(409);
282+
});
283+
284+
it('returns 403 when non-admin tries to resolve a dispute', async () => {
285+
const { escrowId } = await createEscrowAndDispute();
286+
287+
await request(app.getHttpServer())
288+
.patch(`/admin/dispute/${escrowId}/resolve`)
289+
.set('Authorization', `Bearer ${BUYER_ADDRESS}`)
290+
.send({ resolution: 'RELEASE' })
291+
.expect(403);
292+
});
293+
294+
it('returns 401 for unauthenticated resolution attempt', async () => {
295+
const { escrowId } = await createEscrowAndDispute();
296+
297+
await request(app.getHttpServer())
298+
.patch(`/admin/dispute/${escrowId}/resolve`)
299+
.send({ resolution: 'RELEASE' })
300+
.expect(401);
301+
});
302+
});
303+
});

0 commit comments

Comments
 (0)