diff --git a/tests/concurrency/ticket-status-race.test.ts b/tests/concurrency/ticket-status-race.test.ts new file mode 100644 index 0000000..d5ca744 --- /dev/null +++ b/tests/concurrency/ticket-status-race.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { buildApp } from '@/app'; +import { prismaClient } from '@/infrastructure/database'; +import { FastifyInstance } from 'fastify'; +import { + encryptCredential, + generateCredentialSecret, + issueIntegrationToken, +} from '@/modules/catalog/products'; +import { ticketsRepository } from '@/modules/ticketing/tickets'; + +/** + * specs/016-load-concurrency-testing User Story 4 / FR-004 / SC-004: proves โ€” rather than + * assumes โ€” that 003-ticketing's own optimistic-concurrency guarantee + * (TicketsRepository.updateStatus's atomic `updateMany({where:{id, version: expectedVersion}})`) + * actually holds under genuinely concurrent requests, not just the sequential checks that + * existed before this feature. research.md ยง4: no implementation change is expected here โ€” this + * is a real Postgres, real concurrency proof of an already-sound mechanism. + */ +describe('Ticket status optimistic concurrency (User Story 4)', () => { + let app: FastifyInstance; + const externalProductId = `TEST_TICKET_STATUS_RACE_PROD_${Date.now()}`; + let productId: string; + let secret: string; + const createdTicketIds: string[] = []; + + async function createTicket(): Promise { + const token = issueIntegrationToken(secret, { + externalProductId, + tenantId: 'tenant-1', + userId: 'user-1', + }); + const created = await app.inject({ + method: 'POST', + url: '/v1/support/requests', + headers: { authorization: `Bearer ${token}` }, + payload: { + productId: externalProductId, + tenantId: 'tenant-1', + userId: 'user-1', + source: 'test', + problem: `Ticket status race test ${Date.now()}-${Math.random()}`, + }, + }); + expect(created.statusCode).toBe(202); + const ticketId = created.json().data.ticketId as string; + createdTicketIds.push(ticketId); + return ticketId; + } + + beforeAll(async () => { + app = await buildApp(); + + const product = await prismaClient.product.create({ + data: { externalProductId, name: 'Ticket Status Race Test Product', status: 'active' }, + }); + productId = product.id; + secret = generateCredentialSecret(); + await prismaClient.productIntegration.create({ + data: { + productId, + credentialRef: encryptCredential(secret), + authMechanism: 'signed_token', + allowedScope: { tenantIds: ['tenant-1'] }, + status: 'active', + rateLimitPerMinute: 1000, + rateLimitPerUserPerMinute: 1000, + }, + }); + }); + + afterAll(async () => { + await prismaClient.ticketMessage.deleteMany({ where: { ticketId: { in: createdTicketIds } } }); + await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } }); + await prismaClient.problem.deleteMany({ where: { productId } }); + await prismaClient.productIntegration.deleteMany({ where: { productId } }); + await prismaClient.product.deleteMany({ where: { id: productId } }); + await app.close(); + }); + + it('exactly one of 20 concurrent status updates from the same version succeeds', async () => { + const ticketId = await createTicket(); + const original = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } }); + expect(original.status).toBe('NEW'); + + const attempts = 20; + const results = await Promise.all( + Array.from({ length: attempts }, () => + ticketsRepository.updateStatus(ticketId, 'AI_ANALYZING', original.version), + ), + ); + + const successes = results.filter((r) => r !== null); + const failures = results.filter((r) => r === null); + expect(successes.length).toBe(1); + expect(failures.length).toBe(attempts - 1); + + const finalTicket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } }); + expect(finalTicket.status).toBe('AI_ANALYZING'); + expect(finalTicket.version).toBe(original.version + 1); + }); + + it( + 'holds consistently across 10 repeated runs (SC-004)', + async () => { + for (let run = 0; run < 10; run++) { + const ticketId = await createTicket(); + const original = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } }); + + const results = await Promise.all( + Array.from({ length: 20 }, () => + ticketsRepository.updateStatus(ticketId, 'AI_ANALYZING', original.version), + ), + ); + + expect(results.filter((r) => r !== null).length).toBe(1); + } + }, + 60000, + ); +});