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
111 changes: 111 additions & 0 deletions apps/backend/src/__tests__/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,117 @@ describe('PUT /api/cards/:id — link ownership validation', () => {
});
});

// ─────────────────────────────────────────────────────────────────────────────
// PUT /api/cards/:id/default — serialization & retry behaviour
// ─────────────────────────────────────────────────────────────────────────────

describe('PUT /api/cards/:id/default — serialization isolation & retry', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('passes isolationLevel Serializable to $transaction', async () => {
mockPrisma.card.findFirst.mockResolvedValue(mockCard);
mockPrisma.$transaction.mockImplementation(
async (callback: (tx: typeof mockPrisma) => Promise<unknown>, options?: unknown) => {
expect(options).toEqual({ isolationLevel: 'Serializable' });
mockPrisma.card.updateMany.mockResolvedValue({ count: 2 });
mockPrisma.card.update.mockResolvedValue({ ...mockCard, isDefault: true });
return callback(mockPrisma);
},
);

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

expect(res.statusCode).toBe(200);
expect(mockPrisma.$transaction).toHaveBeenCalledOnce();
});

it('retries on P2034 (serialization conflict) and succeeds on second attempt', async () => {
mockPrisma.card.findFirst.mockResolvedValue(mockCard);

const p2034 = Object.assign(new Error('Serialization failure'), { code: 'P2034' });
let callCount = 0;

mockPrisma.$transaction.mockImplementation(
async (callback: (tx: typeof mockPrisma) => Promise<unknown>) => {
callCount++;
if (callCount === 1) { throw p2034; }
mockPrisma.card.updateMany.mockResolvedValue({ count: 2 });
mockPrisma.card.update.mockResolvedValue({ ...mockCard, isDefault: true });
return callback(mockPrisma);
},
);

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

expect(res.statusCode).toBe(200);
expect(res.json().message).toBe('Default card updated');
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(2);
});

it('returns 500 after exhausting all 3 retry attempts on persistent P2034', async () => {
mockPrisma.card.findFirst.mockResolvedValue(mockCard);

const p2034 = Object.assign(new Error('Serialization failure'), { code: 'P2034' });
mockPrisma.$transaction.mockRejectedValue(p2034);

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

expect(res.statusCode).toBe(500);
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(3);
});

it('does not retry on non-P2034 errors and returns 500 immediately', async () => {
mockPrisma.card.findFirst.mockResolvedValue(mockCard);

const dbError = new Error('Connection lost');
mockPrisma.$transaction.mockRejectedValue(dbError);

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

expect(res.statusCode).toBe(500);
// Must not have retried — only one attempt for non-serialization errors
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1);
});

it('concurrent calls: the last committed transaction determines the sole default', async () => {
mockPrisma.card.findFirst.mockResolvedValue(mockCard);

let callCount = 0;
mockPrisma.$transaction.mockImplementation(
async (callback: (tx: typeof mockPrisma) => Promise<unknown>) => {
callCount++;
// First concurrent attempt fails (simulates DB-level serialization abort)
if (callCount === 1) {
throw Object.assign(new Error('Serialization failure'), { code: 'P2034' });
}
mockPrisma.card.updateMany.mockResolvedValue({ count: 2 });
mockPrisma.card.update.mockResolvedValue({ ...mockCard, isDefault: true });
return callback(mockPrisma);
},
);

const app = await buildApp();

// Fire both requests concurrently
const [res1, res2] = await Promise.all([
app.inject({ method: 'PUT', url: `/api/cards/${CARD_ID}/default` }),
app.inject({ method: 'PUT', url: `/api/cards/${CARD_ID}/default` }),
]);

// Both callers ultimately succeed (one retried)
expect([res1.statusCode, res2.statusCode]).toEqual([200, 200]);
// Combined, the transaction was attempted 3 times (1 fail + 1 succeed for
// the first caller, 1 succeed for the second — ordering may vary)
expect(mockPrisma.$transaction.mock.calls.length).toBeGreaterThanOrEqual(2);
});
});

// ─────────────────────────────────────────────────────────────────────────────
// DELETE /api/cards/:id
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
30 changes: 25 additions & 5 deletions apps/backend/src/services/cardService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,30 @@ export async function setDefaultCard(app: FastifyInstance, userId: string, id: s
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 } });
});
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
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 } });
},
{ isolationLevel: 'Serializable' },
);

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

app.log.error(error);
throw error;
}
}

return { message: 'Default card updated' };
throw new Error('Failed to set default card after retrying serialization conflicts');
}