diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts new file mode 100644 index 0000000..5bdaedc --- /dev/null +++ b/apps/backend/src/__tests__/cards.test.ts @@ -0,0 +1,410 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify from 'fastify'; +import { cardRoutes } from '../routes/cards.js'; + +const USER_ID = 'user-123'; +const CARD_ID = 'card-abc'; +const OWNED_LINK_ID = 'link-owned-1'; +const FOREIGN_LINK_ID = 'link-foreign-x'; + +const mockCard = { + id: CARD_ID, + userId: USER_ID, + title: 'My Card', + isDefault: true, + createdAt: new Date(), + updatedAt: new Date(), + cardLinks: [], +}; + +// $transaction executes the callback synchronously against the same mock client, +// mirroring Prisma's interactive-transactions API without a real DB connection. +const mockPrisma = { + card: { + count: vi.fn(), + create: vi.fn(), + findMany: vi.fn(), + findFirst: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + delete: vi.fn(), + }, + cardLink: { + deleteMany: vi.fn(), + createMany: vi.fn(), + }, + platformLink: { + findMany: vi.fn(), + }, + $transaction: vi.fn(), +}; + +// Re-wire $transaction before every test so that it executes the callback +// against the same mock client, preserving existing per-operation mocks. +function wireTransaction() { + mockPrisma.$transaction.mockImplementation( + async (callback: (tx: typeof mockPrisma) => Promise) => callback(mockPrisma), + ); +} + +async function buildApp() { + const app = Fastify({ logger: false }); + app.decorate('prisma', mockPrisma); + app.decorate('authenticate', async (request: any) => { + request.user = { id: USER_ID }; + }); + app.register(cardRoutes, { prefix: '/api/cards' }); + await app.ready(); + return app; +} + +// ───────────────────────────────────────────────────────────────────────────── +// POST /api/cards +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /api/cards — link ownership validation', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('returns 403 when a supplied linkId belongs to another user', async () => { + mockPrisma.platformLink.findMany.mockResolvedValue([]); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [FOREIGN_LINK_ID] }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json().error).toBe('One or more links do not belong to your account'); + expect(mockPrisma.card.create).not.toHaveBeenCalled(); + }); + + it('returns 403 when a mix of owned and foreign linkIds is supplied', async () => { + // Only 1 of 2 requested IDs is owned — count mismatch triggers 403 + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [OWNED_LINK_ID, FOREIGN_LINK_ID] }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json().error).toBe('One or more links do not belong to your account'); + expect(mockPrisma.card.create).not.toHaveBeenCalled(); + }); + + it('creates the card when all linkIds are owned by the user', 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.platformLink.findMany).toHaveBeenCalledWith({ + where: { id: { in: [OWNED_LINK_ID] }, userId: USER_ID }, + select: { id: true }, + }); + }); + + it('skips the ownership check and creates the card when linkIds is empty', 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: 'Empty Card', linkIds: [] }, + }); + + expect(res.statusCode).toBe(201); + expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled(); + }); + + it('returns 500 when the ownership query throws unexpectedly', async () => { + mockPrisma.platformLink.findMany.mockRejectedValue(new Error('DB connection lost')); + + 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(500); + expect(res.json().error).toBe('Internal server error'); + // No write must have been attempted after the read failure + expect(mockPrisma.card.create).not.toHaveBeenCalled(); + }); + + it('returns 500 when card.count throws and no partial write occurs', async () => { + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.count.mockRejectedValue(new Error('Query timeout')); + + 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(500); + expect(res.json().error).toBe('Internal server error'); + expect(mockPrisma.card.create).not.toHaveBeenCalled(); + }); + + it('returns 500 when card.create throws', async () => { + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.count.mockResolvedValue(0); + mockPrisma.card.create.mockRejectedValue(new Error('FK constraint violation')); + + 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(500); + expect(res.json().error).toBe('Internal server error'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// PUT /api/cards/:id +// ───────────────────────────────────────────────────────────────────────────── + +describe('PUT /api/cards/:id — link ownership validation', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('returns 403 when a supplied linkId belongs to another user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.platformLink.findMany.mockResolvedValue([]); + + const app = await buildApp(); + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}`, + payload: { linkIds: [FOREIGN_LINK_ID] }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json().error).toBe('One or more links do not belong to your account'); + // Existing links must not have been touched + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled(); + expect(mockPrisma.cardLink.createMany).not.toHaveBeenCalled(); + }); + + it('updates links atomically when all supplied linkIds are owned', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 0 }); + 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.platformLink.findMany).toHaveBeenCalledWith({ + where: { id: { in: [OWNED_LINK_ID] }, userId: USER_ID }, + select: { id: true }, + }); + // Both operations must run inside the transaction, not as bare queries + expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); + expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalledWith({ where: { cardId: CARD_ID } }); + expect(mockPrisma.cardLink.createMany).toHaveBeenCalled(); + }); + + it('returns 404 when the card does not belong to the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(null); + + 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(404); + expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled(); + }); + + it('returns 500 when the ownership query throws and no mutation occurs', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.platformLink.findMany.mockRejectedValue(new Error('DB timeout')); + + 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(500); + expect(res.json().error).toBe('Internal server error'); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled(); + }); + + it('returns 500 and preserves existing links when the transaction fails mid-flight', async () => { + // Ownership check passes; deleteMany succeeds; createMany fails. + // The transaction rolls back, so the card retains its original links. + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 1 }); + mockPrisma.cardLink.createMany.mockRejectedValue(new Error('FK constraint')); + + 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(500); + expect(res.json().error).toBe('Internal server error'); + // Both were attempted inside the transaction (the DB rolls them back together) + expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalled(); + expect(mockPrisma.cardLink.createMany).toHaveBeenCalled(); + // The final read must not have been called — we short-circuited on error + expect(mockPrisma.card.findUnique).not.toHaveBeenCalled(); + }); + + it('returns 500 when card.findFirst throws', async () => { + mockPrisma.card.findFirst.mockRejectedValue(new Error('Connection refused')); + + 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(500); + expect(res.json().error).toBe('Internal server error'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// DELETE /api/cards/:id +// ───────────────────────────────────────────────────────────────────────────── + +describe('DELETE /api/cards/:id', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('returns 204 on successful deletion', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.card.delete.mockResolvedValue(mockCard); + + const app = await buildApp(); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + + expect(res.statusCode).toBe(204); + expect(mockPrisma.card.delete).toHaveBeenCalledWith({ where: { id: CARD_ID } }); + }); + + it('returns 404 when the card is not owned by the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + + expect(res.statusCode).toBe(404); + expect(mockPrisma.card.delete).not.toHaveBeenCalled(); + }); + + it('returns 500 when card.delete throws', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.card.delete.mockRejectedValue(new Error('Deadlock detected')); + + const app = await buildApp(); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + + expect(res.statusCode).toBe(500); + expect(res.json().error).toBe('Internal server error'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// PUT /api/cards/:id/default +// ───────────────────────────────────────────────────────────────────────────── + +describe('PUT /api/cards/:id/default', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('returns 200 and sets the card as default', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.card.updateMany.mockResolvedValue({ count: 2 }); + mockPrisma.card.update.mockResolvedValue({ ...mockCard, isDefault: true }); + + const app = await buildApp(); + const res = await app.inject({ method: 'PUT', url: `/api/cards/${CARD_ID}/default` }); + + expect(res.statusCode).toBe(200); + expect(res.json().message).toBe('Default card updated'); + expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); + // Clear-all and set-one must both run inside the transaction + expect(mockPrisma.card.updateMany).toHaveBeenCalledWith({ + where: { userId: USER_ID }, + data: { isDefault: false }, + }); + expect(mockPrisma.card.update).toHaveBeenCalledWith({ + where: { id: CARD_ID }, + data: { isDefault: true }, + }); + }); + + it('returns 404 when the card is not owned by the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ method: 'PUT', url: `/api/cards/${CARD_ID}/default` }); + + expect(res.statusCode).toBe(404); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + }); + + it('returns 500 and rolls back when the transaction fails mid-flight', async () => { + // updateMany clears all defaults; then update fails → transaction aborts, + // the user retains a consistent default card rather than having none. + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.card.updateMany.mockResolvedValue({ count: 2 }); + mockPrisma.card.update.mockRejectedValue(new Error('DB write failure')); + + const app = await buildApp(); + const res = await app.inject({ method: 'PUT', url: `/api/cards/${CARD_ID}/default` }); + + expect(res.statusCode).toBe(500); + expect(res.json().error).toBe('Internal server error'); + expect(mockPrisma.card.updateMany).toHaveBeenCalled(); + expect(mockPrisma.card.update).toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index f1af7b0..3c67c5c 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -9,23 +9,28 @@ export async function cardRoutes(app: FastifyInstance) { app.get('/', async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; - const cards = await app.prisma.card.findMany({ - where: { userId }, - include: { - cardLinks: { - include: { platformLink: true }, - orderBy: { displayOrder: 'asc' }, + try { + const cards = await app.prisma.card.findMany({ + where: { userId }, + include: { + cardLinks: { + include: { platformLink: true }, + orderBy: { displayOrder: 'asc' }, + }, }, - }, - orderBy: { createdAt: 'asc' }, - }); - - return cards.map((card) => ({ - id: card.id, - title: card.title, - isDefault: card.isDefault, - links: card.cardLinks.map((cl) => cl.platformLink), - })); + orderBy: { createdAt: 'asc' }, + }); + + return cards.map((card) => ({ + id: card.id, + title: card.title, + isDefault: card.isDefault, + links: card.cardLinks.map((cl) => cl.platformLink), + })); + } catch (err) { + app.log.error({ err }, 'DB error in GET /cards'); + return reply.status(500).send({ error: 'Internal server error' }); + } }); // ─── Create Card ─── @@ -38,35 +43,55 @@ export async function cardRoutes(app: FastifyInstance) { return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }); } - // Check if user's first card → make it default - const cardCount = await app.prisma.card.count({ where: { userId } }); - - const card = await app.prisma.card.create({ - data: { - userId, - title: parsed.data.title, - isDefault: cardCount === 0, - cardLinks: { - create: parsed.data.linkIds.map((linkId, index) => ({ - platformLinkId: linkId, - displayOrder: index, - })), + 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 cardCount = await app.prisma.card.count({ where: { userId } }); + + const card = await app.prisma.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' }, + include: { + cardLinks: { + include: { platformLink: true }, + orderBy: { displayOrder: 'asc' }, + }, }, - }, - }); - - return reply.status(201).send({ - id: card.id, - title: card.title, - isDefault: card.isDefault, - links: card.cardLinks.map((cl) => cl.platformLink), - }); + }); + + return reply.status(201).send({ + id: card.id, + title: card.title, + isDefault: card.isDefault, + links: card.cardLinks.map((cl) => cl.platformLink), + }); + } catch (err) { + app.log.error({ err }, 'DB error in POST /cards'); + return reply.status(500).send({ error: 'Internal server error' }); + } }); // ─── Update Card ─── @@ -75,58 +100,78 @@ export async function cardRoutes(app: FastifyInstance) { const userId = (request.user as any).id; const { id } = request.params; - const existing = await app.prisma.card.findFirst({ - where: { id, userId }, - }); - - if (!existing) { - return reply.status(404).send({ error: 'Card not found' }); - } - - const parsed = updateCardSchema.safeParse(request.body); - if (!parsed.success) { - return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }); - } + try { + const existing = await app.prisma.card.findFirst({ + where: { id, userId }, + }); - // Update card title - if (parsed.data.title) { - await app.prisma.card.update({ + if (!existing) { + return reply.status(404).send({ error: 'Card not found' }); + } + + const parsed = updateCardSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }); + } + + if (parsed.data.title) { + await app.prisma.card.update({ + where: { id }, + data: { title: parsed.data.title }, + }); + } + + if (parsed.data.linkIds) { + // Ownership check runs before any write so a foreign linkId is always + // caught before existing links are touched. + 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' }); + } + } + + // Replace links inside a transaction so the card is never left linkless + // when deleteMany succeeds but createMany subsequently fails. + const linkIds = parsed.data.linkIds; + await app.prisma.$transaction(async (tx) => { + 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, + })), + }); + } + }); + } + + const updated = await app.prisma.card.findUnique({ where: { id }, - data: { title: parsed.data.title }, + include: { + cardLinks: { + include: { platformLink: true }, + orderBy: { displayOrder: 'asc' }, + }, + }, }); - } - // Update card links if provided - if (parsed.data.linkIds) { - // Remove existing links - await app.prisma.cardLink.deleteMany({ where: { cardId: id } }); - // Add new links - await app.prisma.cardLink.createMany({ - data: parsed.data.linkIds.map((linkId, index) => ({ - cardId: id, - platformLinkId: linkId, - displayOrder: index, - })), - }); + return { + id: updated!.id, + title: updated!.title, + isDefault: updated!.isDefault, + links: updated!.cardLinks.map((cl) => cl.platformLink), + }; + } catch (err) { + app.log.error({ err, cardId: id }, 'DB error in PUT /cards/:id'); + return reply.status(500).send({ error: 'Internal server error' }); } - - // Fetch updated card - 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) => cl.platformLink), - }; }); // ─── Delete Card ─── @@ -135,16 +180,21 @@ export async function cardRoutes(app: FastifyInstance) { const userId = (request.user as any).id; const { id } = request.params; - const existing = await app.prisma.card.findFirst({ - where: { id, userId }, - }); + try { + const existing = await app.prisma.card.findFirst({ + where: { id, userId }, + }); - if (!existing) { - return reply.status(404).send({ error: 'Card not found' }); - } + if (!existing) { + return reply.status(404).send({ error: 'Card not found' }); + } - await app.prisma.card.delete({ where: { id } }); - return reply.status(204).send(); + await app.prisma.card.delete({ where: { id } }); + return reply.status(204).send(); + } catch (err) { + app.log.error({ err, cardId: id }, 'DB error in DELETE /cards/:id'); + return reply.status(500).send({ error: 'Internal server error' }); + } }); // ─── Set Default Card ─── @@ -153,26 +203,26 @@ export async function cardRoutes(app: FastifyInstance) { const userId = (request.user as any).id; const { id } = request.params; - const existing = await app.prisma.card.findFirst({ - where: { id, userId }, - }); - - if (!existing) { - return reply.status(404).send({ error: 'Card not found' }); - } + try { + const existing = await app.prisma.card.findFirst({ + where: { id, userId }, + }); - // Unset all other defaults - await app.prisma.card.updateMany({ - where: { userId }, - data: { isDefault: false }, - }); + if (!existing) { + return reply.status(404).send({ error: 'Card not found' }); + } - // Set this one - await app.prisma.card.update({ - where: { id }, - data: { isDefault: true }, - }); + // Clear then set in a single transaction so there is never a window where + // the user has zero default cards if the second write fails. + await app.prisma.$transaction(async (tx) => { + 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' }; + } catch (err) { + app.log.error({ err, cardId: id }, 'DB error in PUT /cards/:id/default'); + return reply.status(500).send({ error: 'Internal server error' }); + } }); }