diff --git a/backend/tests/integration/top-up.test.ts b/backend/tests/integration/top-up.test.ts index 26d4e086..c9067e77 100644 --- a/backend/tests/integration/top-up.test.ts +++ b/backend/tests/integration/top-up.test.ts @@ -200,4 +200,64 @@ describe('POST /v1/streams/:streamId/top-up', () => { expect(res.status).toBe(400); expect(mockPrisma.stream.update).not.toHaveBeenCalled(); }); + + it('handles concurrent top-ups correctly without lost updates', async () => { + // Both requests read the same initial state + vi.mocked(mockPrisma.stream.findUnique).mockResolvedValue(mockStream as any); + + // Simulate some delay in the on-chain transaction + vi.mocked(topUpStream).mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return 'txhash-mock'; + }); + + // We also need to implement $executeRaw to mock the atomic DB update + // because that's what we will change the implementation to use. + // However, if it's the OLD implementation, it calls update(). + // We should make the mocked executeRaw update a local variable and + // also make update() do the same, so we can test both the failure and success. + + let currentDeposited = BigInt(mockStream.depositedAmount); + + vi.mocked(mockPrisma.$executeRawUnsafe).mockImplementation(async (query: any, ...values: any[]) => { + const amountToAdd = BigInt(values[0]); + currentDeposited += amountToAdd; + return 1 as any; + }); + + vi.mocked(mockPrisma.stream.findUnique).mockImplementation(async () => { + return { ...mockStream, depositedAmount: currentDeposited.toString() } as any; + }); + + // Actually, to make the test pass after the fix, the best way to do atomic update is to do a transaction where we re-fetch the stream. + // Let's first just test if it works with the old implementation (should fail). + + const req1 = request(app) + .post('/v1/streams/42/top-up') + .set('Authorization', 'Bearer dummy') + .send({ amount: '1000' }); + + const req2 = request(app) + .post('/v1/streams/42/top-up') + .set('Authorization', 'Bearer dummy') + .send({ amount: '2000' }); + + const [res1, res2] = await Promise.all([req1, req2]); + + expect(res1.status).toBe(200); + expect(res2.status).toBe(200); + + // The initial amount is 86400. We add 1000 and 2000. + // 86400 + 1000 + 2000 = 89400. + // Because we mock Prisma in vitest, the controller's Prisma calls will hit our mock. + // We will assert on the final 'depositedAmount' returned in the response, or what was sent to Prisma. + // Since the API returns depositedAmount, let's just check the response body. + + // If it lost update, one response will be 87400 and the other 88400. + // If it didn't lose update, the second one should be 89400. + const amt1 = parseInt(res1.body.depositedAmount); + const amt2 = parseInt(res2.body.depositedAmount); + expect(Math.max(amt1, amt2)).toBe(89400); + }); + }); diff --git a/backend/tests/workers.index.test.ts b/backend/tests/workers.index.test.ts index c3bacf47..4fa7cc9b 100644 --- a/backend/tests/workers.index.test.ts +++ b/backend/tests/workers.index.test.ts @@ -12,6 +12,8 @@ vi.mock('../src/workers/soroban-event-worker.js', () => ({ vi.mock('../src/logger.js', () => ({ default: { info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), }, }));