diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 5c6f2fe..24b350a 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -2,6 +2,49 @@ Postgres via Prisma. Schema: [`prisma/schema.prisma`](../prisma/schema.prisma). +## Connection pool configuration (#215) + +Prisma maintains a Postgres connection pool per `PrismaClient` instance, +sized by **query engine defaults** unless the `DATABASE_URL` overrides it: + +| Param | Default | Meaning | +| ----------------- | ---------------------------- | ---------------------------------------------- | +| `connection_limit`| `num_cpus * 2 + 1` | Max pool size (physical connections) | +| `pool_timeout` | `10` seconds | How long a query waits for a free connection | +| `connect_timeout` | `5` seconds | TCP connect handshake timeout | + +Because the default scales with the **host's** CPU count, it is wrong on +container platforms where `num_cpus` is the node's, not the container's — +set `connection_limit` explicitly everywhere except tiny local setups: + +``` +DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=10&connect_timeout=5" +``` + +### Guidance by environment + +| Environment | Suggested `connection_limit` | Why | +| ---------------------------------- | ---------------------------- | --- | +| Local dev / `docker-compose.yml` | `5` (or omit) | One developer, default engine sizing is fine | +| API pods on Kubernetes | `5–10` per pod | `pods * limit` must stay below Postgres `max_connections` minus superuser/system reserve (~10%) | +| Migration / one-off CLI runs | `2` | Migrations don't need a large pool | +| Serverless / Vercel functions | `1` **and** a pooled proxy (PgBouncer in transaction mode or Supabase/Neon pooled URL on port 6543) | Many ephemeral runtimes × per-runtime pools exhaust the database instantly | + +Rules of thumb: + +- **Budget first:** `sum(connection_limit across all deployable processes) + ≤ max_connections - reserve`. Check `SHOW max_connections;` and every + deployment's replica count before raising limits. +- **Watch for pool timeouts:** `P2024` ("Timed out fetching a connection + from the pool") means queries are holding connections too long (long + interactive transactions) or the pool is undersized — prefer shrinking + transaction scopes (see #217) before raising the limit. +- **One pool per process, not per request:** the `PrismaService` singleton + already guarantees this; never construct `PrismaClient` inside request + handlers. +- Postgres reserves superuser slots, so keep the total well under + `max_connections` (default `100`) — roughly 80% as a ceiling. + ## Key relationships - `User` — `OrganizationMember` (many-to-many via join table) — `Organization` @@ -62,6 +105,67 @@ Migration: `prisma/migrations/20260926100000_ticket_owner_status_index/`. Supports `WHERE "ownerId" = $1 [AND "status" = $2] ORDER BY "createdAt" DESC` as a single index scan. +## Resale ticket/status index (#214) + +Resale lookups filter listings by ticket and status — active listing per +ticket, ticket-scoped feeds, and expiry sweeps all issue: + +```sql +WHERE "ticketId" = $1 [AND "status" = $2] +``` + +Covered by a composite index declared in the schema and migration: + +```prisma +@@index([ticketId, status]) // on ResaleListing +``` + +```sql +CREATE INDEX "ResaleListing_ticketId_status_idx" + ON "ResaleListing"("ticketId", "status"); +``` + +Migration: `prisma/migrations/20260926130000_resale_ticket_status_index/`. + +## One ACTIVE resale listing per ticket (#216) + +Nothing else stops two `ACTIVE` `ResaleListing` rows for the same ticket. +Enforced by a **partial** unique index (raw SQL — Prisma cannot express +`WHERE`): + +```sql +CREATE UNIQUE INDEX "ResaleListing_ticketId_active_key" + ON "ResaleListing"("ticketId") + WHERE "status" = 'ACTIVE'; +``` + +- Concurrent `confirmListForResale` calls for the same ticket cannot both + succeed: the loser gets a `P2002` that `TicketsService` translates to + `409 Conflict`. +- Non-`ACTIVE` rows (`SOLD`, `CANCELLED`) are excluded, so a ticket can be + re-listed after its listing is sold or cancelled while the historical + listing rows stay intact. +- Migration: `prisma/migrations/20260926140000_resale_listing_active_partial_unique/`. +- Tested against the schema: two concurrent ACTIVE creates — exactly one + succeeds, the other maps to `409 Conflict`. + +## Purchase concurrency & row-level locking (#217) + +`quantityIssued` increments (`confirmIssue`, `confirmPurchase`) run inside +the same interactive transaction as the ticket insert, guarded by a +row-level lock: + +```sql +SELECT "quantityIssued", "quantityTotal" FROM "TicketType" WHERE "id" = $1 FOR UPDATE +``` + +Concurrent transactions serialize on the `TicketType` row; each one +re-checks `quantityIssued < quantityTotal` **after** acquiring the lock, so +overselling `quantityTotal` is impossible even when the pre-transaction +capacity check raced. Losing transactions abort with +`TICKET_TYPE_SOLD_OUT` (`409` via the domain exception filter). + + ## Soft-delete for Event and Organization (#207) `Event.deletedAt` and `Organization.deletedAt` (`NULL` = live) replace diff --git a/prisma/migrations/20260926130000_resale_ticket_status_index/migration.sql b/prisma/migrations/20260926130000_resale_ticket_status_index/migration.sql new file mode 100644 index 0000000..ef3ae87 --- /dev/null +++ b/prisma/migrations/20260926130000_resale_ticket_status_index/migration.sql @@ -0,0 +1,5 @@ +-- #214 — resale lookups filter by ticket and status +-- (e.g. active listings per ticket, listing feeds, expiry sweeps). +-- Supports: WHERE "ticketId" = $1 [AND "status" = $2] as a single index scan. +CREATE INDEX IF NOT EXISTS "ResaleListing_ticketId_status_idx" + ON "ResaleListing"("ticketId", "status"); diff --git a/prisma/migrations/20260926140000_resale_listing_active_partial_unique/migration.sql b/prisma/migrations/20260926140000_resale_listing_active_partial_unique/migration.sql new file mode 100644 index 0000000..37f2e20 --- /dev/null +++ b/prisma/migrations/20260926140000_resale_listing_active_partial_unique/migration.sql @@ -0,0 +1,9 @@ +-- #216 — nothing in the schema prevented two ACTIVE ResaleListing rows for +-- a ticket. Partial unique index (Prisma cannot express WHERE, so this is +-- raw SQL by design): concurrent creates for the same ticket cannot both +-- succeed — the loser gets a P2002 that TicketsService maps to 409. +-- Non-ACTIVE rows are excluded so a ticket can be re-listed after its +-- listing is sold or cancelled while history stays intact. +CREATE UNIQUE INDEX IF NOT EXISTS "ResaleListing_ticketId_active_key" + ON "ResaleListing"("ticketId") + WHERE "status" = 'ACTIVE'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2a8d8e7..d1eae6c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -225,6 +225,9 @@ model ResaleListing { priceHistory ResalePriceHistory[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + // #214 — resale lookups filter by ticket and status. + @@index([ticketId, status]) } model ResalePriceHistory { diff --git a/src/tickets/tickets.service.spec.ts b/src/tickets/tickets.service.spec.ts index 4a6d99b..43a6258 100644 --- a/src/tickets/tickets.service.spec.ts +++ b/src/tickets/tickets.service.spec.ts @@ -15,6 +15,7 @@ import { ListingInactiveError, TicketTypeSoldOutError, } from '../common/errors/domain.error'; +import { Prisma } from '@prisma/client'; import { TicketsService } from './tickets.service'; import type { PrismaService } from '../prisma/prisma.service'; import type { OrganizationsService } from '../organizations/organizations.service'; @@ -53,6 +54,7 @@ function buildTicketType(overrides: Partial> = {}) { describe('TicketsService', () => { let service: TicketsService; let prisma: { + $queryRaw: jest.Mock; ticketType: { findUnique: jest.Mock; update: jest.Mock }; ticket: { findUnique: jest.Mock; @@ -69,6 +71,7 @@ describe('TicketsService', () => { findMany: jest.Mock; count: jest.Mock; findUnique: jest.Mock; + findFirst: jest.Mock; update: jest.Mock; }; resalePriceHistory: { @@ -85,6 +88,9 @@ describe('TicketsService', () => { beforeEach(() => { prisma = { + $queryRaw: jest + .fn() + .mockResolvedValue([{ quantityIssued: 0, quantityTotal: 100 }]), ticketType: { findUnique: jest.fn(), update: jest.fn() }, ticket: { findUnique: jest.fn(), @@ -101,6 +107,7 @@ describe('TicketsService', () => { findMany: jest.fn(), count: jest.fn().mockResolvedValue(0), findUnique: jest.fn(), + findFirst: jest.fn().mockResolvedValue(null), update: jest.fn(), }, resalePriceHistory: { @@ -252,6 +259,60 @@ describe('TicketsService', () => { }); }); + it('locks the TicketType row and re-checks capacity under the lock (#217)', async () => { + prisma.ticketType.findUnique.mockResolvedValue(buildTicketType()); + prisma.user.findUnique.mockResolvedValue( + createUser({ id: 'buyer-1', stellarPublicKey: 'GBUYER' }), + ); + prisma.ticketType.update.mockResolvedValue({}); + prisma.ticket.create.mockResolvedValue( + createTicket({ id: 'ticket-new', ownerId: 'buyer-1' }), + ); + + await service.confirmIssue( + 'organizer-1', + 'tt-1', + 'buyer-1', + 'GBUYER', + undefined, + 'signed-xdr', + ); + + // The row lock precedes the increment inside the same transaction. + expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); + const [query] = prisma.$queryRaw.mock.calls[0]; + expect(query[0]).toContain('FOR UPDATE'); + expect(prisma.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan( + prisma.ticketType.update.mock.invocationCallOrder[0], + ); + }); + + it('uses the locked row as the capacity authority, not the stale read (#217)', async () => { + prisma.ticketType.findUnique.mockResolvedValue(buildTicketType()); + prisma.user.findUnique.mockResolvedValue( + createUser({ id: 'buyer-1', stellarPublicKey: 'GBUYER' }), + ); + // The stale pre-transaction read said capacity was available, but the + // locked row shows a concurrent transaction already filled the tier. + prisma.$queryRaw.mockResolvedValueOnce([ + { quantityIssued: 100, quantityTotal: 100 }, + ]); + + await expect( + service.confirmIssue( + 'organizer-1', + 'tt-1', + 'buyer-1', + 'GBUYER', + undefined, + 'signed-xdr', + ), + ).rejects.toBeInstanceOf(TicketTypeSoldOutError); + + expect(prisma.ticketType.update).not.toHaveBeenCalled(); + expect(prisma.ticket.create).not.toHaveBeenCalled(); + }); + it('rejects a duplicate assigned seat for the same event (#211)', async () => { prisma.ticketType.findUnique.mockResolvedValue(buildTicketType()); prisma.ticket.findFirst.mockResolvedValueOnce({ id: 'existing-ticket' }); @@ -489,6 +550,59 @@ describe('TicketsService', () => { }); }); + it('rejects listing a ticket that already has an ACTIVE listing (#216)', async () => { + prisma.ticket.findUnique.mockResolvedValue({ + id: 'ticket-1', + ownerId: 'owner-1', + chainTicketId: 7n, + event: { + organizationId: 'org-1', + organization: { stellarAccount: 'GORG' }, + maxResaleMultiplierBps: 20_000, + }, + ticketType: { price: 1_000n }, + }); + prisma.resaleListing.findFirst.mockResolvedValueOnce({ id: 'existing-listing' }); + + await expect( + service.confirmListForResale('owner-1', 'ticket-1', '1200', 'signed-xdr'), + ).rejects.toBeInstanceOf(ConflictException); + + // Fail fast: the chain is never touched and no listing row is written. + expect(stellar.submitSignedTransaction).not.toHaveBeenCalled(); + expect(prisma.resaleListing.create).not.toHaveBeenCalled(); + }); + + it('maps the DB unique-index violation to 409 when a concurrent create wins (#216)', async () => { + prisma.ticket.findUnique.mockResolvedValue({ + id: 'ticket-1', + ownerId: 'owner-1', + chainTicketId: 7n, + event: { + organizationId: 'org-1', + organization: { stellarAccount: 'GORG' }, + maxResaleMultiplierBps: 20_000, + }, + ticketType: { price: 1_000n }, + }); + // Both transactions race past the pre-check; the DB partial unique + // index rejects the loser with a P2002 on the active-listing index. + prisma.$transaction.mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError( + 'Unique constraint failed', + { + code: 'P2002', + clientVersion: 'test', + meta: { target: ['ticketId', 'ResaleListing_ticketId_active_key'] }, + }, + ), + ); + + await expect( + service.confirmListForResale('owner-1', 'ticket-1', '1200', 'signed-xdr'), + ).rejects.toBeInstanceOf(ConflictException); + }); + it('enforces soft limit on active resale listings per user (409 Conflict)', async () => { prisma.resaleListing.count.mockResolvedValue(5); diff --git a/src/tickets/tickets.service.ts b/src/tickets/tickets.service.ts index 31305b1..a6a501d 100644 --- a/src/tickets/tickets.service.ts +++ b/src/tickets/tickets.service.ts @@ -95,6 +95,18 @@ export class TicketsService { try { return await this.prisma.$transaction(async (tx) => { + // #217 — serialize concurrent issuances: lock the TicketType row and + // re-check capacity FROM the locked row. The pre-transaction + // assertHasCapacity only sees a possibly-stale read. + const locked = await tx.$queryRaw< + { quantityIssued: number; quantityTotal: number }[] + >`SELECT "quantityIssued", "quantityTotal" FROM "TicketType" WHERE "id" = ${ticketTypeId} FOR UPDATE`; + const row = locked[0]; + if (!row) { + throw new BadRequestException('Ticket type not found'); + } + this.assertHasCapacity(row.quantityIssued, row.quantityTotal); + await tx.ticketType.update({ where: { id: ticketTypeId }, data: { quantityIssued: { increment: 1 } }, @@ -171,6 +183,19 @@ export class TicketsService { let ticket; try { ticket = await this.prisma.$transaction(async (tx) => { + // #217 — serialize concurrent purchases: lock the TicketType row and + // re-check capacity FROM the locked row, so concurrent buyers cannot + // both pass the pre-transaction capacity check and oversell + // quantityTotal. + const locked = await tx.$queryRaw< + { quantityIssued: number; quantityTotal: number }[] + >`SELECT "quantityIssued", "quantityTotal" FROM "TicketType" WHERE "id" = ${ticketTypeId} FOR UPDATE`; + const row = locked[0]; + if (!row) { + throw new BadRequestException('Ticket type not found'); + } + this.assertHasCapacity(row.quantityIssued, row.quantityTotal); + await tx.ticketType.update({ where: { id: ticketTypeId }, data: { quantityIssued: { increment: 1 } }, @@ -522,6 +547,9 @@ export class TicketsService { expiresAt?: string, ) { await this.assertWithinResaleLimit(userId); + // #216 — fail fast when the ticket is already listed before touching the + // chain. The DB-level partial unique index remains the source of truth. + await this.assertNoActiveResaleListing(ticketId); const ticket = await this.getOwnedTicketWithPricingInfo(ticketId, userId); // #319 — validate price cap at confirm step too (guards against replays) @@ -533,26 +561,33 @@ export class TicketsService { const { txHash } = await this.stellar.submitSignedTransaction(signedXdr); - return this.prisma.$transaction(async (tx) => { - await tx.ticket.update({ - where: { id: ticketId }, - data: { status: TicketStatus.RESALE }, - }); - return tx.resaleListing.create({ - data: { - ticketId, - sellerId: userId, - price: BigInt(price), - txHash, - expiresAt: expiresAt ? new Date(expiresAt) : null, - priceHistory: { - create: { - price: BigInt(price), + try { + return await this.prisma.$transaction(async (tx) => { + await tx.ticket.update({ + where: { id: ticketId }, + data: { status: TicketStatus.RESALE }, + }); + return tx.resaleListing.create({ + data: { + ticketId, + sellerId: userId, + price: BigInt(price), + txHash, + expiresAt: expiresAt ? new Date(expiresAt) : null, + priceHistory: { + create: { + price: BigInt(price), + }, }, }, - }, + }); }); - }); + } catch (err) { + // #216 — a concurrent create raced us past the pre-check: the partial + // unique index rejected it, surface it as a 409. + this.throwOnResaleConflict(err); + throw err; + } } async updateResalePrice(userId: string, listingId: string, newPrice: string) { @@ -888,6 +923,40 @@ export class TicketsService { } } + /** + * #216 — application-level guard mirroring the partial unique index + * `ResaleListing_ticketId_active_key` (ticketId WHERE status = 'ACTIVE'). + * The DB remains the source of truth; this check only produces a nicer + * 409 before the chain round-trip. + */ + private async assertNoActiveResaleListing(ticketId: string) { + const existing = await this.prisma.resaleListing.findFirst({ + where: { ticketId, status: ResaleListingStatus.ACTIVE }, + select: { id: true }, + }); + if (existing) { + throw new ConflictException( + 'This ticket already has an active resale listing', + ); + } + } + + /** Translates the active-listing partial-index violation into a 409. */ + private throwOnResaleConflict(err: unknown): void { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + const target = (err.meta as { target?: unknown } | undefined)?.target; + const targets = Array.isArray(target) ? target.join(',') : String(target ?? ''); + if (targets.includes('ResaleListing_ticketId_active')) { + throw new ConflictException( + 'This ticket already has an active resale listing', + ); + } + } + } + private assertSaleWindow(startsAt: Date | null, endsAt: Date | null) { const now = new Date(); if (startsAt && now < startsAt) diff --git a/test/purchase-concurrency.e2e-spec.ts b/test/purchase-concurrency.e2e-spec.ts new file mode 100644 index 0000000..4638a45 --- /dev/null +++ b/test/purchase-concurrency.e2e-spec.ts @@ -0,0 +1,181 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { randomUUID } from 'node:crypto'; +import { AppModule } from './../src/app.module'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { StellarService } from '../src/stellar/stellar.service'; +import { NotificationService } from '../src/notifications/notifications.service'; +import { JwtAuthGuard } from '../src/auth/guards/jwt-auth.guard'; +import type { CurrentUserPayload } from '../src/auth/decorators/current-user.decorator'; +import type { User } from '@prisma/client'; +import { + createEvent, + createOrganization, + createTicketType, + createUser, +} from './factories'; + +// Requires a real DATABASE_URL — same constraint as app.e2e-spec.ts. +// +// #217 — proves concurrent confirmPurchase calls cannot oversell +// quantityTotal: the interactive transaction takes a row-level lock on the +// TicketType row and re-checks quantityIssued < quantityTotal under the +// lock, so losing transactions abort with TICKET_TYPE_SOLD_OUT (409) and +// quantityIssued never exceeds quantityTotal. +describe('Ticket purchase concurrency (e2e, #217)', () => { + let app: INestApplication; + let prisma: PrismaService; + let currentUser: CurrentUserPayload; + let userIndex: Map; + + const mockStellarService = { + buildPurchasePrimaryTx: jest.fn().mockResolvedValue('unsigned-purchase-xdr'), + submitSignedTransaction: jest.fn().mockImplementation(() => + Promise.resolve({ + result: BigInt(Math.floor(Math.random() * 1_000_000) + 1), + txHash: 'c'.repeat(64), + }), + ), + }; + + const mockNotificationService = { + sendTicketReceipt: jest.fn().mockResolvedValue(undefined), + }; + + async function seedSellableTier(overrides: { quantityTotal?: number } = {}) { + const organization = await prisma.organization.create({ + data: createOrganization({ + id: randomUUID(), + slug: `org-${randomUUID()}`, + }), + }); + const event = await prisma.event.create({ + data: createEvent({ + id: randomUUID(), + organizationId: organization.id, + status: 'PUBLISHED', + chainEventId: BigInt(Math.floor(Math.random() * 1_000_000) + 1), + }), + }); + const ticketType = await prisma.ticketType.create({ + data: createTicketType({ + id: randomUUID(), + eventId: event.id, + price: 1_000n, + quantityIssued: 0, + quantityTotal: overrides.quantityTotal ?? 2, + }), + }); + return { organization, event, ticketType }; + } + + beforeAll(async () => { + userIndex = new Map(); + currentUser = { + userId: '', + email: '', + role: 'ATTENDEE', + }; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(StellarService) + .useValue(mockStellarService) + .overrideProvider(NotificationService) + .useValue(mockNotificationService) + .overrideGuard(JwtAuthGuard) + .useValue({ + canActivate: (context: import('@nestjs/common').ExecutionContext) => { + const req = context.switchToHttp().getRequest(); + // Concurrency-safe test identity: each in-flight request carries + // its own buyer id via the x-test-user-id header. + const userId = req.headers['x-test-user-id'] as string | undefined; + const user = (userId && userIndex.get(userId)) || null; + req.user = user + ? { userId: user.id, email: user.email, role: user.role } + : currentUser; + return true; + }, + }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + + prisma = moduleFixture.get(PrismaService); + }); + + afterAll(async () => { + await app.close(); + }); + + it('never oversells quantityTotal under concurrent purchases', async () => { + const quantityTotal = 3; + const buyerCount = 8; + const { ticketType } = await seedSellableTier({ quantityTotal }); + + const purchasers: User[] = []; + for (let i = 0; i < buyerCount; i++) { + const buyer = await prisma.user.create({ + data: createUser({ + id: randomUUID(), + email: `buyer-${randomUUID()}@example.com`, + stellarPublicKey: `G${randomUUID() + .replace(/-/g, '') + .toUpperCase() + .padEnd(55, 'A')}`, + }), + }); + purchasers.push(buyer); + userIndex.set(buyer.id, buyer); + } + + // Build every purchase request first (each carrying its own buyer id), + // then execute them concurrently so the capacity race actually happens. + const purchases = purchasers.map((buyer, index) => + request(app.getHttpServer()) + .post('/v1/tickets/confirm-purchase') + .set('x-test-user-id', buyer.id) + .send({ + ticketTypeId: ticketType.id, + signedXdr: `signed-xdr-${index}`, + }), + ); + const responses = await Promise.all(purchases); + + const succeeded = responses.filter((res) => res.status === 201); + const soldOut = responses.filter((res) => res.status === 409); + + // Exactly quantityTotal purchases succeed; every other buyer gets the + // domain 409 (TICKET_TYPE_SOLD_OUT) instead of an oversold ticket. + expect(succeeded).toHaveLength(quantityTotal); + expect(soldOut).toHaveLength(buyerCount - quantityTotal); + for (const res of soldOut) { + expect(res.body.code).toBe('TICKET_TYPE_SOLD_OUT'); + } + + const tier = await prisma.ticketType.findUniqueOrThrow({ + where: { id: ticketType.id }, + }); + expect(Number(tier.quantityIssued)).toBe(quantityTotal); + + const ticketCount = await prisma.ticket.count({ + where: { ticketTypeId: ticketType.id }, + }); + expect(ticketCount).toBe(quantityTotal); + }); +}); diff --git a/test/resale.e2e-spec.ts b/test/resale.e2e-spec.ts index 09fb7d4..a03b536 100644 --- a/test/resale.e2e-spec.ts +++ b/test/resale.e2e-spec.ts @@ -221,4 +221,31 @@ describe('Resale flow (e2e)', () => { expect(ticketAfterBuy.ownerId).toBe(buyer.id); expect(ticketAfterBuy.status).toBe('VALID'); }); + + it('does not allow two ACTIVE resale listings for the same ticket (#216)', async () => { + const { seller, ticket } = await seedTicket(); + currentUser = { userId: seller.id, email: seller.email, role: seller.role }; + + // Two concurrent confirm-list-resale calls race the application-level + // pre-check; the DB partial unique index must let exactly one win. + const responses = await Promise.all( + Array.from({ length: 2 }, () => + request(app.getHttpServer()) + .post(`/v1/tickets/${ticket.id}/confirm-list-resale`) + .send({ price: '1100', signedXdr: 'signed-xdr' }), + ), + ); + + const succeeded = responses.filter((res) => res.status === 201); + const conflicted = responses.filter((res) => res.status === 409); + + expect(succeeded).toHaveLength(1); + expect(conflicted).toHaveLength(1); + expect(conflicted[0].body.message).toMatch(/active resale listing/i); + + const activeListings = await prisma.resaleListing.count({ + where: { ticketId: ticket.id, status: 'ACTIVE' }, + }); + expect(activeListings).toBe(1); + }); });