Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions apps/backend/src/__tests__/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,3 +438,106 @@ describe('PUT /api/cards/:id/default', () => {
expect(mockPrisma.card.update).toHaveBeenCalled();
});
});

// ─────────────────────────────────────────────────────────────────────────────
// POST /api/cards — default-card concurrency guard
// ─────────────────────────────────────────────────────────────────────────────

describe('POST /api/cards — default-card concurrency guard', () => {
beforeEach(() => {
vi.clearAllMocks();
wireTransaction();
});

it('first card is created as default and count+create run inside a transaction', async () => {
// Verifies that $transaction wraps both the count check and the create so
// that concurrent requests cannot both observe count=0 and both set isDefault=true.
mockPrisma.card.count.mockResolvedValue(0);
mockPrisma.card.create.mockResolvedValue({ ...mockCard, isDefault: true, cardLinks: [] });

const app = await buildApp();
const res = await app.inject({
method: 'POST',
url: '/api/cards',
payload: { title: 'First Card', linkIds: [] },
});

expect(res.statusCode).toBe(201);
expect(res.json().isDefault).toBe(true);
expect(mockPrisma.$transaction).toHaveBeenCalledOnce();
expect(mockPrisma.card.count).toHaveBeenCalledWith({ where: { userId: USER_ID } });
expect(mockPrisma.card.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ isDefault: true }) }),
);
});

it('subsequent card is not default when the user already has one', 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: 'Second Card', linkIds: [] },
});

expect(res.statusCode).toBe(201);
expect(res.json().isDefault).toBe(false);
expect(mockPrisma.card.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ isDefault: false }) }),
);
});

it('exactly one of two concurrent first-card requests becomes default', async () => {
// Request A: transaction-internal count=0 → creates as default.
// Request B: transaction-internal count=1 (after A committed) → creates as non-default.
const app = await buildApp();

mockPrisma.card.count
.mockResolvedValueOnce(0) // Tx A observes 0 existing cards
.mockResolvedValueOnce(1); // Tx B observes 1 (A committed first)
mockPrisma.card.create
.mockResolvedValueOnce({ ...mockCard, isDefault: true, cardLinks: [] })
.mockResolvedValueOnce({ ...mockCard, id: 'card-b', isDefault: false, cardLinks: [] });

const resA = await app.inject({
method: 'POST',
url: '/api/cards',
payload: { title: 'Card A', linkIds: [] },
});
const resB = await app.inject({
method: 'POST',
url: '/api/cards',
payload: { title: 'Card B', linkIds: [] },
});

expect(resA.statusCode).toBe(201);
expect(resB.statusCode).toBe(201);
expect(resA.json().isDefault).toBe(true);
expect(resB.json().isDefault).toBe(false);
});

it('invariant: exactly one default card exists after concurrent first-card requests', async () => {
const app = await buildApp();

mockPrisma.card.count
.mockResolvedValueOnce(0)
.mockResolvedValueOnce(1);
mockPrisma.card.create
.mockResolvedValueOnce({ ...mockCard, isDefault: true, cardLinks: [] })
.mockResolvedValueOnce({ ...mockCard, id: 'card-b', isDefault: false, cardLinks: [] });

const [resA, resB] = await Promise.all([
app.inject({ method: 'POST', url: '/api/cards', payload: { title: 'Card A', linkIds: [] } }),
app.inject({ method: 'POST', url: '/api/cards', payload: { title: 'Card B', linkIds: [] } }),
]);

const defaultCount = [resA, resB].filter(
(r) => r.statusCode === 201 && r.json().isDefault === true,
).length;
// Only one card may be default — the invariant must hold.
expect(defaultCount).toBe(1);
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(2);
});
});
52 changes: 0 additions & 52 deletions apps/backend/src/routes/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { createCardSchema, updateCardSchema } from '../utils/validators.js';
import * as cardService from '../services/cardService'

import type { Card } from '@devcard/shared';
import type { Prisma } from '@prisma/client';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';


Expand Down Expand Up @@ -79,57 +78,6 @@ export async function cardRoutes(app: FastifyInstance): Promise<void> {
}

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 card = await app.prisma.$transaction(async (tx) => {
const cardCount = await tx.card.count({
where: { userId },
});

return tx.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' },
},
},
});
}};
const response = {
id: card.id,
title: card.title,
isDefault: card.isDefault,
links: card.cardLinks.map((cl: CardLinkWithPlatform) => cl.platformLink),
}

return reply.status(201).send(response);
} catch (error) {
return handleDbError(error, request, reply);
const card = await cardService.createCard(app, userId, parsed.data)
return reply.status(201).send(card)
} catch (error: any) {
Expand Down
30 changes: 19 additions & 11 deletions apps/backend/src/services/cardService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,30 @@ export async function listCards(app: FastifyInstance, userId: string) {
}

export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) {
// Ownership check runs before any write so a foreign linkId is always
// caught before the transaction begins.
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 cardCount = await app.prisma.card.count({ where: { userId } })

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' } } },
})
// The count check and card creation run inside a single serializable
// transaction so that two concurrent first-card requests cannot both
// observe count = 0 and both set isDefault = true. Serializable
// isolation causes the database to roll back the second conflicting
// transaction rather than allowing both to commit with isDefault = true.
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' })

return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) }
}
Expand Down