diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index f110c0da..dd707054 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -1,4 +1,4 @@ -import Fastify, { type FastifyInstance } from 'fastify'; +import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import { cardRoutes } from '../routes/cards.js'; @@ -48,10 +48,14 @@ const mockPrisma = { // against the same mock client, preserving existing per-operation mocks. function wireTransaction(): void { mockPrisma.$transaction.mockImplementation( - async (callback: (tx: typeof mockPrisma) => Promise) => callback(mockPrisma), + async (callback: (tx: typeof mockPrisma) => Promise, _options?: unknown) => callback(mockPrisma), ); } +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + app.decorate('prisma', mockPrisma); + app.decorate('authenticate', async (request: FastifyRequest & { user?: { id: string } }) => { async function buildApp():Promise { const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma as unknown as PrismaClient); @@ -182,6 +186,55 @@ describe('POST /api/cards — link ownership validation', () => { expect(res.statusCode).toBe(500); }); + + it('wraps creation in a Serializable transaction to prevent race conditions', async () => { + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.count.mockResolvedValue(0); + mockPrisma.card.create.mockResolvedValue({ ...mockCard, cardLinks: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(201); + expect(mockPrisma.$transaction).toHaveBeenCalledWith( + expect.any(Function), + { isolationLevel: 'Serializable' } + ); + }); + + it('retries the transaction on P2034 serialization failure', async () => { + mockPrisma.platformLink.findMany.mockResolvedValue([]); + + // First attempt fails with P2034 (serialization conflict) + // Second attempt succeeds + const error = new Error('Serialization failure') as Error & { code: string }; + error.code = 'P2034'; + + // We mock $transaction to fail once, then succeed + mockPrisma.$transaction + .mockRejectedValueOnce(error) + .mockImplementationOnce( + async (callback: (tx: typeof mockPrisma) => Promise) => callback(mockPrisma) + ); + + mockPrisma.card.count.mockResolvedValue(1); // second attempt sees count > 0 + mockPrisma.card.create.mockResolvedValue({ ...mockCard, isDefault: false, cardLinks: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [] }, + }); + + expect(res.statusCode).toBe(201); + expect(res.json().isDefault).toBe(false); + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(2); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index a9721783..1bc4ce03 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -1,93 +1,174 @@ -import type { FastifyInstance } from 'fastify' -import type { Prisma } from '@prisma/client' +import type { Prisma } from '@prisma/client'; +import type { FastifyInstance } from 'fastify'; + +type CardLinkResponse = { platformLink: unknown }; +type RawCard = { id: string; title: string; isDefault: boolean; cardLinks: CardLinkResponse[] }; +type CardResponse = { id: string; title: string; isDefault: boolean; links: unknown[] }; + +function mapCard(card: RawCard): CardResponse { + return { + id: card.id, + title: card.title, + isDefault: card.isDefault, + links: card.cardLinks.map((cardLink) => cardLink.platformLink), + }; +} -export async function listCards(app: FastifyInstance, userId: string) { - const cards = await app.prisma.card.findMany({ +export async function listCards(app: FastifyInstance, userId: string): Promise { + const cards = (await app.prisma.card.findMany({ where: { userId }, take: 50, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, orderBy: { createdAt: 'asc' }, - }) + })) as unknown as RawCard[]; - return cards.map((card: any) => ({ id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) })) + return cards.map(mapCard); } -export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) { +export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }): Promise { 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 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 maxRetries = 3; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + 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', + }, + )) as unknown as RawCard; + + return mapCard(card); + } catch (error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code: string }).code === 'P2034' && + attempt < maxRetries + ) { + continue; + } - 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' } } }, - }) + app.log.error(error); + throw error; + } + } - return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) } + throw new Error('Failed to create card after retrying serialization conflicts'); } -export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) { - const existing = await app.prisma.card.findFirst({ where: { id, userId } }) - if (!existing) return null +export async function updateCard( + app: FastifyInstance, + userId: string, + id: string, + body: { title?: string; linkIds?: string[] }, +): Promise { + const existing = await app.prisma.card.findFirst({ where: { id, userId } }); + if (!existing) { + return null; + } if (body.title) { - await app.prisma.card.update({ where: { id }, data: { title: body.title } }) + await app.prisma.card.update({ where: { id }, data: { title: body.title } }); } if (body.linkIds) { 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 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 linkIds = body.linkIds + const linkIds = body.linkIds; await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.cardLink.deleteMany({ where: { cardId: id } }) + await tx.cardLink.deleteMany({ where: { cardId: id } }); if (linkIds.length > 0) { - await tx.cardLink.createMany({ data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })) }) + await tx.cardLink.createMany({ + data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })), + }); } - }) + }); + } + + const updated = (await app.prisma.card.findUnique({ + where: { id }, + include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, + })) as unknown as RawCard | null; + + if (!updated) { + return null; } - const updated = await app.prisma.card.findUnique({ where: { id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) - return { id: updated!.id, title: updated!.title, isDefault: updated!.isDefault, links: updated!.cardLinks.map((cl: any) => cl.platformLink) } + return mapCard(updated); } -export async function deleteCard(app: FastifyInstance, userId: string, id: string) { +export async function deleteCard(app: FastifyInstance, userId: string, id: string): Promise { return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - const existing = await tx.card.findFirst({ where: { id, userId } }) - if (!existing) return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }) + const existing = await tx.card.findFirst({ where: { id, userId } }); + if (!existing) { + return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }); + } - const userCardCount = await tx.card.count({ where: { userId } }) - if (userCardCount <= 1) return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }) + const userCardCount = await tx.card.count({ where: { userId } }); + if (userCardCount <= 1) { + return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }); + } if (existing.isDefault) { - const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } }) + const oldestRemainingCard = await tx.card.findFirst({ + where: { userId, id: { not: id } }, + orderBy: { createdAt: 'asc' }, + }); + if (oldestRemainingCard) { - await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } }) + await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } }); } } - await tx.card.delete({ where: { id } }) - return null - }) + await tx.card.delete({ where: { id } }); + return null; + }); } -export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) { - const existing = await app.prisma.card.findFirst({ where: { id, userId } }) - if (!existing) return null +export async function setDefaultCard(app: FastifyInstance, userId: string, id: string): Promise<{ message: string } | null> { + const existing = await app.prisma.card.findFirst({ where: { id, userId } }); + if (!existing) { + return null; + } await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }) - await tx.card.update({ where: { id }, data: { isDefault: true } }) - }) + await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }); + await tx.card.update({ where: { id }, data: { isDefault: true } }); + }); - return { message: 'Default card updated' } + return { message: 'Default card updated' }; }