Skip to content
Merged
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
57 changes: 55 additions & 2 deletions apps/backend/src/__tests__/cards.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Fastify, { type FastifyInstance } from 'fastify';
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify';
import { describe, it, expect, beforeEach, vi } from 'vitest';

import { cardRoutes } from '../routes/cards.js';
Expand Down Expand Up @@ -48,10 +48,14 @@ const mockPrisma = {
// against the same mock client, preserving existing per-operation mocks.
function wireTransaction(): void {
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(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
app.decorate('prisma', mockPrisma);
app.decorate('authenticate', async (request: FastifyRequest & { user?: { id: string } }) => {
async function buildApp():Promise<FastifyInstance> {
const app = Fastify({ logger: false });
app.decorate('prisma', mockPrisma as unknown as PrismaClient);
Expand Down Expand Up @@ -182,6 +186,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
179 changes: 130 additions & 49 deletions apps/backend/src/services/cardService.ts
Original file line number Diff line number Diff line change
@@ -1,93 +1,174 @@
import type { FastifyInstance } from 'fastify'
import type { Prisma } from '@prisma/client'
import type { Prisma } from '@prisma/client';
import type { FastifyInstance } from 'fastify';

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

function mapCard(card: RawCard): CardResponse {
return {
id: card.id,
title: card.title,
isDefault: card.isDefault,
links: card.cardLinks.map((cardLink) => cardLink.platformLink),
};
}

export async function listCards(app: FastifyInstance, userId: string) {
const cards = await app.prisma.card.findMany({
export async function listCards(app: FastifyInstance, userId: string): Promise<CardResponse[]> {
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(mapCard);
}

export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) {
export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }): Promise<CardResponse> {
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 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 maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
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',
},
)) as unknown as RawCard;

return mapCard(card);
} catch (error: unknown) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: string }).code === 'P2034' &&
attempt < maxRetries
) {
continue;
}

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' } } },
})
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
Contributor 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.

}
}

return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) }
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[] }) {
const existing = await app.prisma.card.findFirst({ where: { id, userId } })
if (!existing) return null
export async function updateCard(
app: FastifyInstance,
userId: string,
id: string,
body: { title?: string; linkIds?: string[] },
): Promise<CardResponse | null> {
const existing = await app.prisma.card.findFirst({ where: { id, userId } });
if (!existing) {
return null;
}

if (body.title) {
await app.prisma.card.update({ where: { id }, data: { title: body.title } })
await app.prisma.card.update({ where: { id }, data: { title: body.title } });
}

if (body.linkIds) {
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 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 linkIds = body.linkIds
const linkIds = body.linkIds;
await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.cardLink.deleteMany({ where: { cardId: id } })
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 })) })
await tx.cardLink.createMany({
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;
}

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) }
return mapCard(updated);
}

export async function deleteCard(app: FastifyInstance, userId: string, id: string) {
export async function deleteCard(app: FastifyInstance, userId: string, id: string): Promise<null> {
return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const existing = await tx.card.findFirst({ where: { id, userId } })
if (!existing) return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' })
const existing = await tx.card.findFirst({ where: { id, userId } });
if (!existing) {
throw Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' });
}

const userCardCount = await tx.card.count({ where: { userId } })
if (userCardCount <= 1) return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' })
const userCardCount = await tx.card.count({ where: { userId } });
if (userCardCount <= 1) {
throw Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' });
}

if (existing.isDefault) {
const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } })
const oldestRemainingCard = await tx.card.findFirst({
where: { userId, id: { not: id } },
orderBy: { createdAt: 'asc' },
});

if (oldestRemainingCard) {
await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } })
await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } });
}
}

await tx.card.delete({ where: { id } })
return null
})
await tx.card.delete({ where: { id } });
return null;
});
}

export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) {
const existing = await app.prisma.card.findFirst({ where: { id, userId } })
if (!existing) return null
export async function setDefaultCard(app: FastifyInstance, userId: string, id: string): Promise<{ message: string } | null> {
const existing = await app.prisma.card.findFirst({ where: { id, userId } });
if (!existing) {
return null;
}

await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
await tx.card.updateMany({ where: { userId }, data: { isDefault: false } })
await tx.card.update({ where: { id }, data: { isDefault: true } })
})
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' };
}
Loading