diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index 813883e8..a478d516 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -438,3 +438,106 @@ describe('PUT /api/cards/:id/default', () => { expect(mockPrisma.card.update).toHaveBeenCalled(); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// POST /api/cards — default-card concurrency guard +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /api/cards — default-card concurrency guard', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('first card is created as default and count+create run inside a transaction', async () => { + // Verifies that $transaction wraps both the count check and the create so + // that concurrent requests cannot both observe count=0 and both set isDefault=true. + mockPrisma.card.count.mockResolvedValue(0); + mockPrisma.card.create.mockResolvedValue({ ...mockCard, isDefault: true, cardLinks: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'First Card', linkIds: [] }, + }); + + expect(res.statusCode).toBe(201); + expect(res.json().isDefault).toBe(true); + expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); + expect(mockPrisma.card.count).toHaveBeenCalledWith({ where: { userId: USER_ID } }); + expect(mockPrisma.card.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ isDefault: true }) }), + ); + }); + + it('subsequent card is not default when the user already has one', async () => { + mockPrisma.card.count.mockResolvedValue(1); + mockPrisma.card.create.mockResolvedValue({ ...mockCard, isDefault: false, cardLinks: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Second Card', linkIds: [] }, + }); + + expect(res.statusCode).toBe(201); + expect(res.json().isDefault).toBe(false); + expect(mockPrisma.card.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ isDefault: false }) }), + ); + }); + + it('exactly one of two concurrent first-card requests becomes default', async () => { + // Request A: transaction-internal count=0 → creates as default. + // Request B: transaction-internal count=1 (after A committed) → creates as non-default. + const app = await buildApp(); + + mockPrisma.card.count + .mockResolvedValueOnce(0) // Tx A observes 0 existing cards + .mockResolvedValueOnce(1); // Tx B observes 1 (A committed first) + mockPrisma.card.create + .mockResolvedValueOnce({ ...mockCard, isDefault: true, cardLinks: [] }) + .mockResolvedValueOnce({ ...mockCard, id: 'card-b', isDefault: false, cardLinks: [] }); + + const resA = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Card A', linkIds: [] }, + }); + const resB = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Card B', linkIds: [] }, + }); + + expect(resA.statusCode).toBe(201); + expect(resB.statusCode).toBe(201); + expect(resA.json().isDefault).toBe(true); + expect(resB.json().isDefault).toBe(false); + }); + + it('invariant: exactly one default card exists after concurrent first-card requests', async () => { + const app = await buildApp(); + + mockPrisma.card.count + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(1); + mockPrisma.card.create + .mockResolvedValueOnce({ ...mockCard, isDefault: true, cardLinks: [] }) + .mockResolvedValueOnce({ ...mockCard, id: 'card-b', isDefault: false, cardLinks: [] }); + + const [resA, resB] = await Promise.all([ + app.inject({ method: 'POST', url: '/api/cards', payload: { title: 'Card A', linkIds: [] } }), + app.inject({ method: 'POST', url: '/api/cards', payload: { title: 'Card B', linkIds: [] } }), + ]); + + const defaultCount = [resA, resB].filter( + (r) => r.statusCode === 201 && r.json().isDefault === true, + ).length; + // Only one card may be default — the invariant must hold. + expect(defaultCount).toBe(1); + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index 1c2640a5..88cc05ba 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -3,7 +3,6 @@ import { createCardSchema, updateCardSchema } from '../utils/validators.js'; import * as cardService from '../services/cardService' import type { Card } from '@devcard/shared'; -import type { Prisma } from '@prisma/client'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; @@ -79,57 +78,6 @@ export async function cardRoutes(app: FastifyInstance): Promise { } try { - // Verify every supplied link belongs to the authenticated user before any write. - // A count mismatch means at least one ID is foreign — reject before touching the DB. - if (parsed.data.linkIds.length > 0) { - const ownedLinks = await app.prisma.platformLink.findMany({ - where: { id: { in: parsed.data.linkIds }, userId }, - select: { id: true }, - }); - - if (ownedLinks.length !== parsed.data.linkIds.length) { - return reply.status(403).send({ error: 'One or more links do not belong to your account' }); - } - } - - // Check if user's first card -> make it default. - // Prisma wraps the nested cardLinks.create inside card.create in a single - // implicit transaction, so either both the card and its links are written or neither is. - const card = await app.prisma.$transaction(async (tx) => { - const cardCount = await tx.card.count({ - where: { userId }, - }); - - return tx.card.create({ - data: { - userId, - title: parsed.data.title, - isDefault: cardCount === 0, - cardLinks: { - create: parsed.data.linkIds.map((linkId, index) => ({ - platformLinkId: linkId, - displayOrder: index, - })), - }, - }, - include: { - cardLinks: { - include: { platformLink: true }, - orderBy: { displayOrder: 'asc' }, - }, - }, - }); - }}; - const response = { - id: card.id, - title: card.title, - isDefault: card.isDefault, - links: card.cardLinks.map((cl: CardLinkWithPlatform) => cl.platformLink), - } - - return reply.status(201).send(response); - } catch (error) { - return handleDbError(error, request, reply); const card = await cardService.createCard(app, userId, parsed.data) return reply.status(201).send(card) } catch (error: any) { diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index a9721783..12ce7aba 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -13,22 +13,30 @@ export async function listCards(app: FastifyInstance, userId: string) { } export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) { + // Ownership check runs before any write so a foreign linkId is always + // caught before the transaction begins. if (body.linkIds.length > 0) { const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) } - const cardCount = await app.prisma.card.count({ where: { userId } }) - - const card = await app.prisma.card.create({ - data: { - userId, - title: body.title, - isDefault: cardCount === 0, - cardLinks: { create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })) }, - }, - include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, - }) + // The count check and card creation run inside a single serializable + // transaction so that two concurrent first-card requests cannot both + // observe count = 0 and both set isDefault = true. Serializable + // isolation causes the database to roll back the second conflicting + // transaction rather than allowing both to commit with isDefault = true. + const card = await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + const cardCount = await tx.card.count({ where: { userId } }) + return tx.card.create({ + data: { + userId, + title: body.title, + isDefault: cardCount === 0, + cardLinks: { create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })) }, + }, + include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, + }) + }, { isolationLevel: 'Serializable' }) return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) } }