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

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

View workflow job for this annotation

GitHub Actions / backend-ci

`fastify` import should occur before import of `vitest`

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

View workflow job for this annotation

GitHub Actions / backend-ci

There should be at least one empty line between import groups
import Fastify from 'fastify';

import { cardRoutes } from '../routes/cards.js';

const USER_ID = 'user-123';
Expand Down Expand Up @@ -42,17 +43,17 @@
};

// Re-wire $transaction before every test so that it executes the callback
// against the same mock client, preserving existing per-operation mocks.

Check warning on line 46 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
function wireTransaction() {
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),
);
}

Check warning on line 52 in apps/backend/src/__tests__/cards.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
async function buildApp() {
async function buildApp(): Promise<FastifyInstance> {
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 @@

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';

Check failure on line 2 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

`@prisma/client` type import should occur before type import of `fastify`

type CardLinkResponse = { platformLink: unknown };

Check warning on line 4 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
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),
};
}

Check warning on line 15 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function

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({

Check failure on line 18 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Expected { after 'if' condition
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' });

Check warning on line 36 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
}
}

Check failure on line 38 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Expected { after 'if' condition

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({

Check failure on line 47 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Expected { after 'if' condition
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;

Check warning on line 63 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
return mapCard(card);
} catch (error: unknown) {
if (

Check failure on line 66 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Expected { after 'if' condition
typeof error === 'object' &&
error !== null &&
'code' in error &&

Check failure on line 69 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Expected { after 'if' condition
(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
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');
}

Check warning on line 83 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
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,

Check failure on line 85 in apps/backend/src/services/cardService.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Expected { after 'if' condition
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) {
return 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) {
return 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