diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae634399..19c33195 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,9 +34,7 @@ jobs: run: npm run lint working-directory: frontend - - name: Install Rollup Native Binding - run: npm install @rollup/rollup-linux-x64-gnu --no-save - working-directory: frontend + - name: Run Frontend Tests run: npm run test:coverage @@ -90,6 +88,7 @@ jobs: run: npm run build working-directory: backend + - name: OpenAPI spec & API types drift check run: | cd backend diff --git a/backend/tests/integration/streams/cancel.test.ts b/backend/tests/integration/streams/cancel.test.ts index fc3c918b..4696ef97 100644 --- a/backend/tests/integration/streams/cancel.test.ts +++ b/backend/tests/integration/streams/cancel.test.ts @@ -142,3 +142,58 @@ describe('POST /v1/streams/:streamId/cancel', () => { expect(res.body.message).toContain('already cancelled'); }); }); + it('handles concurrent cancel requests correctly', async () => { + const streamId = 123; + const mockStream = { + streamId, + sender: 'G_SENDER_123', + isActive: true, + }; + + // Mock findUnique to return the stream initially + (prisma.stream.findUnique as any).mockResolvedValueOnce(mockStream); + + // Mock update to return cancelled stream - first call wins + (prisma.stream.update as any) + .mockResolvedValueOnce({ ...mockStream, isActive: false }) + .mockResolvedValueOnce({ ...mockStream, isActive: false }); + + // Mock cancelStream to resolve once (only first call should proceed) + (sorobanService.cancelStream as any).mockResolvedValueOnce('tx_hash_123'); + (sorobanService.cancelStream as any).mockResolvedValueOnce('tx_hash_123'); + + // Run two concurrent cancel requests + const promise1 = request(app) + .post(`/v1/streams/${streamId}/cancel`) + .set('Authorization', 'Bearer dummy_token'); + const promise2 = request(app) + .post(`/v1/streams/${streamId}/cancel`) + .set('Authorization', 'Bearer dummy_token'); + + const [res1, res2] = await Promise.all([promise1, promise2]); + + // Both should return 200 with CANCELLED status + expect(res1.status).toBe(200); + expect(res2.status).toBe(200); + expect(res1.body).toEqual({ + txHash: 'tx_hash_123', + status: 'CANCELLED', + }); + expect(res2.body).toEqual({ + txHash: 'tx_hash_123', + status: 'CANCELLED', + }); + + // Only one on-chain cancel call should be made (race protection) + expect(sorobanService.cancelStream).toHaveBeenCalledTimes(1); + expect(sorobanService.cancelStream).toHaveBeenCalledWith(BigInt(streamId), 'S_SECRET_123'); + + // Stream should be marked as inactive + expect(prisma.stream.update).toHaveBeenCalledWith({ + where: { streamId: BigInt(streamId) }, + data: { isActive: false }, + }); + + // Both responses should reference the same single transaction + expect(res1.body.txHash).toBe(res2.body.txHash); + }); diff --git a/package.json b/package.json index c63c7fb5..14cc5481 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@tailwindcss/oxide-linux-x64-gnu": "^4.3.1", "lightningcss-darwin-arm64": "^1.31.1", "lightningcss-darwin-x64": "^1.32.0", - "lightningcss-linux-x64-gnu": "^1.31.1" + "lightningcss-linux-x64-gnu": "^1.31.1", + "@rollup/rollup-linux-x64-gnu": "^1.0.0" } -} +} \ No newline at end of file