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
56 changes: 53 additions & 3 deletions apps/backend/src/__tests__/cards.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify from 'fastify';
import Fastify, { FastifyRequest } from 'fastify';
import { Prisma } from '@prisma/client';
import { cardRoutes } from '../routes/cards.js';

const USER_ID = 'user-123';
Expand Down Expand Up @@ -45,14 +46,14 @@ const mockPrisma = {
// against the same mock client, preserving existing per-operation mocks.
function wireTransaction() {
mockPrisma.$transaction.mockImplementation(
async (callback: (tx: typeof mockPrisma) => Promise<unknown>) => callback(mockPrisma),
async (callback: (tx: typeof mockPrisma) => Promise<unknown>, options?: unknown) => callback(mockPrisma),
);
}

async function buildApp() {
const app = Fastify({ logger: false });
app.decorate('prisma', mockPrisma);
app.decorate('authenticate', async (request: any) => {
app.decorate('authenticate', async (request: FastifyRequest & { user?: { id: string } }) => {
request.user = { id: USER_ID };
});
app.register(cardRoutes, { prefix: '/api/cards' });
Expand Down Expand Up @@ -179,6 +180,55 @@ describe('POST /api/cards — link ownership validation', () => {

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

it('wraps creation in a Serializable transaction to prevent race conditions', 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.$transaction).toHaveBeenCalledWith(
expect.any(Function),
{ isolationLevel: 'Serializable' }
);
});

it('retries the transaction on P2034 serialization failure', async () => {
mockPrisma.platformLink.findMany.mockResolvedValue([]);

// First attempt fails with P2034 (serialization conflict)
// Second attempt succeeds
const error = new Error('Serialization failure') as Error & { code: string };
error.code = 'P2034';

// We mock $transaction to fail once, then succeed
mockPrisma.$transaction
.mockRejectedValueOnce(error)
.mockImplementationOnce(
async (callback: (tx: typeof mockPrisma) => Promise<unknown>) => callback(mockPrisma)
);

mockPrisma.card.count.mockResolvedValue(1); // second attempt sees count > 0
mockPrisma.card.create.mockResolvedValue({ ...mockCard, isDefault: false, cardLinks: [] });

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

expect(res.statusCode).toBe(201);
expect(res.json().isDefault).toBe(false);
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(2);
});
});

// ─────────────────────────────────────────────────────────────────────────────
Expand Down
60 changes: 42 additions & 18 deletions apps/backend/src/services/cardService.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import type { FastifyInstance } from 'fastify'
import type { Prisma } from '@prisma/client'


type RawCard = { id: string, title: string, isDefault: boolean, cardLinks: { platformLink: unknown }[] };

export async function listCards(app: FastifyInstance, userId: string) {
const cards = await app.prisma.card.findMany({
where: { userId },
take: 50,
include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },
orderBy: { createdAt: 'asc' },
})
}) as unknown as RawCard[];

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

export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) {
Expand All @@ -18,19 +20,40 @@ export async function createCard(app: FastifyInstance, userId: string, body: { t
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' } } },
})

return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) }
const MAX_RETRIES = 3;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const card = await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const cardCount = await tx.card.count({ where: { userId } })

return await 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'
}) as unknown as RawCard;

return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl) => cl.platformLink) }
} catch (error: unknown) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: string }).code === 'P2034' &&
attempt < MAX_RETRIES
) {
continue;
}
app.log.error(error);
throw error;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add app.log.error here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the latest update.

Added app.log.error(error) before rethrowing unexpected failures while preserving the existing retry flow for P2034 serialization conflicts.

}
}
}

export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) {
Expand All @@ -56,8 +79,9 @@ export async function updateCard(app: FastifyInstance, userId: string, id: strin
})
}

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) }
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;
return { id: updated.id, title: updated.title, isDefault: updated.isDefault, links: updated.cardLinks.map((cl) => cl.platformLink) }
}

export async function deleteCard(app: FastifyInstance, userId: string, id: string) {
Expand Down