diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index 3542a539..8a295e15 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -440,4 +440,121 @@ describe('PUT /api/cards/:id/default', () => { expect(mockPrisma.card.updateMany).toHaveBeenCalled(); expect(mockPrisma.card.update).toHaveBeenCalled(); }); -}); \ No newline at end of file +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 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 fd3b9903..3dcd87ef 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -27,13 +27,9 @@ export async function listCards(app: FastifyInstance, userId: string): Promise { if (body.linkIds.length > 0) { - const ownedLinks = await app.prisma.platformLink.findMany({ - where: { id: { in: body.linkIds }, userId }, - select: { id: true }, - }); - + 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' }); + throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) } } @@ -81,66 +77,62 @@ export async function createCard(app: FastifyInstance, userId: string, body: { t 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[] }, -): Promise { - const existing = await app.prisma.card.findFirst({ where: { id, userId } }); +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; + 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 }, - }); + const linkIds = body.linkIds - if (ownedLinks.length !== body.linkIds.length) { - throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }); - } + 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) => { - await tx.cardLink.deleteMany({ where: { cardId: id } }); + 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 })), - }); + 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; - } - + if (!updated) {return null;} return mapCard(updated); } 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 } }); + const existing = await tx.card.findFirst({ where: { id, userId } }) if (!existing) { - throw Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }); + throw Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }) } - const userCardCount = await tx.card.count({ where: { userId } }); + const userCardCount = await tx.card.count({ where: { userId } }) if (userCardCount <= 1) { - throw Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }); + throw Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }) } if (existing.isDefault) { @@ -160,9 +152,9 @@ export async function deleteCard(app: FastifyInstance, userId: string, id: strin } export async function setDefaultCard(app: FastifyInstance, userId: string, id: string): Promise<{ message: string } | null> { - const existing = await app.prisma.card.findFirst({ where: { id, userId } }); + const existing = await app.prisma.card.findFirst({ where: { id, userId } }) if (!existing) { - return null; + return null } await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { @@ -170,5 +162,5 @@ export async function setDefaultCard(app: FastifyInstance, userId: string, id: s await tx.card.update({ where: { id }, data: { isDefault: true } }); }); - return { message: 'Default card updated' }; + return { message: 'Default card updated' } }