diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index f110c0da..8a295e15 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -441,3 +441,120 @@ describe('PUT /api/cards/:id/default', () => { expect(mockPrisma.card.update).toHaveBeenCalled(); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// PUT /api/cards/:id — atomicity of combined title + linkIds update (#437) +// ───────────────────────────────────────────────────────────────────────────── + +describe('PUT /api/cards/:id — atomicity of combined title + linkIds update', () => { + beforeEach(() => { + vi.clearAllMocks() + wireTransaction() + }) + + it('commits both title and links in a single transaction on success', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard) + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]) + mockPrisma.card.update.mockResolvedValue({ ...mockCard, title: 'New Title' }) + mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 0 }) + mockPrisma.cardLink.createMany.mockResolvedValue({ count: 1 }) + mockPrisma.card.findUnique.mockResolvedValue({ ...mockCard, title: 'New Title', cardLinks: [] }) + + const app = await buildApp() + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { title: 'New Title', linkIds: [OWNED_LINK_ID] }, + }) + + expect(res.statusCode).toBe(200) + // Both mutations must be inside one transaction, not two separate calls + expect(mockPrisma.$transaction).toHaveBeenCalledOnce() + expect(mockPrisma.card.update).toHaveBeenCalledWith({ where: { id: CARD_ID }, data: { title: 'New Title' } }) + expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalledWith({ where: { cardId: CARD_ID } }) + expect(mockPrisma.cardLink.createMany).toHaveBeenCalled() + }) + + it('does not commit the title when the linkIds createMany fails (full rollback)', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard) + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]) + // card.update (title) succeeds inside tx, but createMany blows up + mockPrisma.card.update.mockResolvedValue({ ...mockCard, title: 'New Title' }) + mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 1 }) + mockPrisma.cardLink.createMany.mockRejectedValue(new Error('FK constraint violation')) + + const app = await buildApp() + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { title: 'New Title', linkIds: [OWNED_LINK_ID] }, + }) + + expect(res.statusCode).toBe(500) + // The transaction rolled back — the final read must not have been attempted + expect(mockPrisma.card.findUnique).not.toHaveBeenCalled() + // Confirm both operations ran inside the same tx (the DB undoes them together) + expect(mockPrisma.card.update).toHaveBeenCalled() + expect(mockPrisma.cardLink.createMany).toHaveBeenCalled() + }) + + it('returns 403 and opens no transaction when a linkId fails ownership validation', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard) + // Ownership check returns empty — foreign linkId + mockPrisma.platformLink.findMany.mockResolvedValue([]) + + const app = await buildApp() + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { title: 'New Title', linkIds: [FOREIGN_LINK_ID] }, + }) + + expect(res.statusCode).toBe(403) + expect(res.json().error).toBe('One or more links do not belong to your account') + // No transaction must have been opened — no writes of any kind + expect(mockPrisma.$transaction).not.toHaveBeenCalled() + expect(mockPrisma.card.update).not.toHaveBeenCalled() + expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled() + }) + + it('applies only the title update when linkIds is absent', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard) + mockPrisma.card.update.mockResolvedValue({ ...mockCard, title: 'Title Only' }) + mockPrisma.card.findUnique.mockResolvedValue({ ...mockCard, title: 'Title Only', cardLinks: [] }) + + const app = await buildApp() + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { title: 'Title Only' }, + }) + + expect(res.statusCode).toBe(200) + expect(mockPrisma.$transaction).toHaveBeenCalledOnce() + expect(mockPrisma.card.update).toHaveBeenCalledWith({ where: { id: CARD_ID }, data: { title: 'Title Only' } }) + expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled() + expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled() + }) + + it('applies only link replacement when title is absent', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard) + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]) + mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 1 }) + mockPrisma.cardLink.createMany.mockResolvedValue({ count: 1 }) + mockPrisma.card.findUnique.mockResolvedValue({ ...mockCard, cardLinks: [] }) + + const app = await buildApp() + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { linkIds: [OWNED_LINK_ID] }, + }) + + expect(res.statusCode).toBe(200) + expect(mockPrisma.$transaction).toHaveBeenCalledOnce() + expect(mockPrisma.card.update).not.toHaveBeenCalled() + expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalledWith({ where: { cardId: CARD_ID } }) + expect(mockPrisma.cardLink.createMany).toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index a9721783..4447496a 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -1,5 +1,5 @@ -import type { FastifyInstance } from 'fastify' import type { Prisma } from '@prisma/client' +import type { FastifyInstance } from 'fastify' export async function listCards(app: FastifyInstance, userId: string) { const cards = await app.prisma.card.findMany({ @@ -15,7 +15,9 @@ export async function listCards(app: FastifyInstance, userId: string) { export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) { 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' }) + 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 } }) @@ -35,38 +37,59 @@ export async function createCard(app: FastifyInstance, userId: string, body: { t 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 + if (!existing) { + return null + } - if (body.title) { - await app.prisma.card.update({ where: { id }, data: { title: body.title } }) + if (body.linkIds && 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' }) + } } - 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 linkIds = body.linkIds + + await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + if (body.title) { + await tx.card.update({ where: { id }, data: { title: body.title } }) } - const linkIds = body.linkIds - await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + if (linkIds !== undefined) { 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' } } } }) + 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) } } export async function deleteCard(app: FastifyInstance, userId: string, id: string) { 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' }) + 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' }) + 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' } }) @@ -82,7 +105,9 @@ export async function deleteCard(app: FastifyInstance, userId: string, id: strin export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) { const existing = await app.prisma.card.findFirst({ where: { id, userId } }) - if (!existing) return null + if (!existing) { + return null + } await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }) @@ -90,4 +115,4 @@ export async function setDefaultCard(app: FastifyInstance, userId: string, id: s }) return { message: 'Default card updated' } -} +} \ No newline at end of file