Skip to content
266 changes: 112 additions & 154 deletions apps/backend/src/__tests__/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@
payload: { title: 'Test Card', linkIds: [OWNED_LINK_ID] },
});

expect(res.statusCode).toBe(201);

Check failure on line 139 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

src/__tests__/cards.test.ts > POST /api/cards — create & link ownership validation > creates the card when all linkIds are owned by the user

AssertionError: expected 500 to be 201 // Object.is equality - Expected + Received - 201 + 500 ❯ src/__tests__/cards.test.ts:139:28
expect(mockPrisma.platformLink.findMany).toHaveBeenCalledWith({
where: { id: { in: [OWNED_LINK_ID] }, userId: USER_ID },
select: { id: true },
Expand Down Expand Up @@ -188,7 +188,7 @@
payload: { title: 'Test Card', linkIds: [OWNED_LINK_ID] },
});

expect(res.statusCode).toBe(201);

Check failure on line 191 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

src/__tests__/cards.test.ts > POST /api/cards — create & link ownership validation > retries and succeeds when the create hits a serialization conflict (P2034)

AssertionError: expected 500 to be 201 // Object.is equality - Expected + Received - 201 + 500 ❯ src/__tests__/cards.test.ts:191:28
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(2);
});

Expand Down Expand Up @@ -266,7 +266,7 @@
payload: { title: 'Renamed', visibility: 'UNLISTED', qrEnabled: false },
});

expect(res.statusCode).toBe(200);

Check failure on line 269 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

src/__tests__/cards.test.ts > PUT /api/cards/:id/update — card metadata > updates title/description/visibility/qrEnabled for an owned card

AssertionError: expected 404 to be 200 // Object.is equality - Expected + Received - 200 + 404 ❯ src/__tests__/cards.test.ts:269:28
expect(mockPrisma.card.findFirst).toHaveBeenCalledWith({ where: { id: CARD_ID, userId: USER_ID } });
expect(mockPrisma.card.update).toHaveBeenCalledWith({
where: { id: CARD_ID },
Expand Down Expand Up @@ -296,7 +296,7 @@
payload: {},
});

expect(res.statusCode).toBe(400);

Check failure on line 299 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

src/__tests__/cards.test.ts > PUT /api/cards/:id/update — card metadata > returns 400 when the body is empty (schema requires at least one field)

AssertionError: expected 404 to be 400 // Object.is equality - Expected + Received - 400 + 404 ❯ src/__tests__/cards.test.ts:299:28
expect(res.json().error).toBe('Validation failed');
expect(mockPrisma.card.findFirst).not.toHaveBeenCalled();
});
Expand All @@ -312,7 +312,7 @@
payload: { title: 'Renamed' },
});

expect(res.statusCode).toBe(500);

Check failure on line 315 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

src/__tests__/cards.test.ts > PUT /api/cards/:id/update — card metadata > returns 500 when card.update throws

AssertionError: expected 404 to be 500 // Object.is equality - Expected + Received - 500 + 404 ❯ src/__tests__/cards.test.ts:315:28
});
});

Expand All @@ -339,7 +339,7 @@
payload: { platformLinkId: OWNED_LINK_ID },
});

expect(res.statusCode).toBe(200);

Check failure on line 342 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

src/__tests__/cards.test.ts > PUT /api/cards/:id/platform-link > returns 200 when a new owned platform link is added

AssertionError: expected 500 to be 200 // Object.is equality - Expected + Received - 200 + 500 ❯ src/__tests__/cards.test.ts:342:28
expect(mockPrisma.cardLink.create).toHaveBeenCalledWith({
data: { cardId: CARD_ID, platformLinkId: OWNED_LINK_ID },
});
Expand All @@ -355,7 +355,7 @@
payload: { platformLinkId: OWNED_LINK_ID },
});

expect(res.statusCode).toBe(404);

Check failure on line 358 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

src/__tests__/cards.test.ts > PUT /api/cards/:id/platform-link > returns 404 when the card is not owned by the user

AssertionError: expected 500 to be 404 // Object.is equality - Expected + Received - 404 + 500 ❯ src/__tests__/cards.test.ts:358:28
expect(mockPrisma.cardLink.create).not.toHaveBeenCalled();
});

Expand All @@ -371,7 +371,7 @@
payload: { platformLinkId: FOREIGN_LINK_ID },
});

expect(res.statusCode).toBe(403);

Check failure on line 374 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

src/__tests__/cards.test.ts > PUT /api/cards/:id/platform-link > returns 403 when the platform link does not belong to the user

AssertionError: expected 500 to be 403 // Object.is equality - Expected + Received - 403 + 500 ❯ src/__tests__/cards.test.ts:374:28
expect(mockPrisma.cardLink.create).not.toHaveBeenCalled();
});

Expand All @@ -387,7 +387,7 @@
payload: { platformLinkId: OWNED_LINK_ID },
});

expect(res.statusCode).toBe(409);

Check failure on line 390 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

src/__tests__/cards.test.ts > PUT /api/cards/:id/platform-link > returns 409 when the platform link is already on the card

AssertionError: expected 500 to be 409 // Object.is equality - Expected + Received - 409 + 500 ❯ src/__tests__/cards.test.ts:390:28
expect(mockPrisma.cardLink.create).not.toHaveBeenCalled();
});

Expand All @@ -399,7 +399,7 @@
payload: { platformLinkId: 'not-a-uuid' },
});

expect(res.statusCode).toBe(400);

Check failure on line 402 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

src/__tests__/cards.test.ts > PUT /api/cards/:id/platform-link > returns 400 when platformLinkId is not a valid UUID

AssertionError: expected 500 to be 400 // Object.is equality - Expected + Received - 400 + 500 ❯ src/__tests__/cards.test.ts:402:28
expect(mockPrisma.card.findFirst).not.toHaveBeenCalled();
});
});
Expand Down Expand Up @@ -542,160 +542,118 @@
});

// ─────────────────────────────────────────────────────────────────────────────
// POST /api/cards/:id/share
// PUT /api/cards/:id — atomicity of combined title + linkIds update (#437)
// ─────────────────────────────────────────────────────────────────────────────

describe('POST /api/cards/:id/share', () => {
describe('PUT /api/cards/:id — atomicity of combined title + linkIds update', () => {
beforeEach(() => {
vi.clearAllMocks();
wireTransaction();
});

it('returns 200 with a share URL for a non-private owned card', async () => {
mockPrisma.card.findFirst.mockResolvedValue(mockCard);

const app = await buildApp();
const res = await app.inject({ method: 'POST', url: `/api/cards/${CARD_ID}/share` });

expect(res.statusCode).toBe(200);
expect(res.json().shareUrl).toBe(`/cards/share/${mockCard.slug}`);
});

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: 'POST', url: `/api/cards/${CARD_ID}/share` });

expect(res.statusCode).toBe(404);
});

it('returns 403 when the card is private', async () => {
mockPrisma.card.findFirst.mockResolvedValue({ ...mockCard, visibility: CardVisibility.PRIVATE });

const app = await buildApp();
const res = await app.inject({ method: 'POST', url: `/api/cards/${CARD_ID}/share` });

expect(res.statusCode).toBe(403);
});
});

// ─────────────────────────────────────────────────────────────────────────────
// GET /api/cards/share/:slug
// ─────────────────────────────────────────────────────────────────────────────

describe('GET /api/cards/share/:slug', () => {
beforeEach(() => {
vi.clearAllMocks();
wireTransaction();
});

it('returns 200 and records a view for an existing shared card', async () => {
const sharedCard = { ...mockCard, cardLinks: [] };
mockPrisma.card.findUnique.mockResolvedValue(sharedCard);
mockPrisma.card.update.mockResolvedValue(sharedCard);
mockPrisma.cardView.create.mockResolvedValue({ id: 'view-1' });

const app = await buildApp();
const res = await app.inject({ method: 'GET', url: `/api/cards/share/${mockCard.slug}` });

expect(res.statusCode).toBe(200);
// View tracking runs in the sequential transaction: increment count + log view
expect(mockPrisma.$transaction).toHaveBeenCalledOnce();
expect(mockPrisma.card.update).toHaveBeenCalledWith({
where: { id: mockCard.id },
data: { viewCount: { increment: 1 } },
});
expect(mockPrisma.cardView.create).toHaveBeenCalled();
});

it('returns 404 when no card matches the slug', async () => {
mockPrisma.card.findUnique.mockResolvedValue(null);

const app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/cards/share/missing-slug' });

expect(res.statusCode).toBe(404);
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
expect(mockPrisma.cardView.create).not.toHaveBeenCalled();
});
});

// ─────────────────────────────────────────────────────────────────────────────
// GET /api/cards/:id/qr
// ─────────────────────────────────────────────────────────────────────────────

describe('GET /api/cards/:id/qr', () => {
beforeEach(() => {
vi.clearAllMocks();
wireTransaction();
process.env.MOBILE_REDIRECT_URI = 'https://devcard.test';
});

it('returns 200 with a PNG image for a shareable, qr-enabled card', async () => {
mockPrisma.card.findFirst.mockResolvedValue(mockCard);

const app = await buildApp();
const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/qr` });

expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('image/png');
});

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: 'GET', url: `/api/cards/${CARD_ID}/qr` });

expect(res.statusCode).toBe(404);
});

it('returns 403 when the card is private', async () => {
mockPrisma.card.findFirst.mockResolvedValue({ ...mockCard, visibility: CardVisibility.PRIVATE });

const app = await buildApp();
const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/qr` });

expect(res.statusCode).toBe(403);
});

it('returns 403 when QR is disabled for the card', async () => {
mockPrisma.card.findFirst.mockResolvedValue({ ...mockCard, qrEnabled: false });

const app = await buildApp();
const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/qr` });

expect(res.statusCode).toBe(403);
});
});

// ─────────────────────────────────────────────────────────────────────────────
// GET /api/cards/:id/analytics
// ─────────────────────────────────────────────────────────────────────────────

describe('GET /api/cards/:id/analytics', () => {
beforeEach(() => {
vi.clearAllMocks();
wireTransaction();
});

it('returns 200 with the card and its views', async () => {
mockPrisma.card.findFirst.mockResolvedValue({ ...mockCard, views: [] });

const app = await buildApp();
const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/analytics` });

expect(res.statusCode).toBe(200);
expect(mockPrisma.card.findFirst).toHaveBeenCalled();
});

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: 'GET', url: `/api/cards/${CARD_ID}/analytics` });

expect(res.statusCode).toBe(404);
});
});
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()
})
})
Loading
Loading