Skip to content

Commit fe788d7

Browse files
authored
Fix: Platform-specific username validation & Unhandled backend exceptions (#252) (#289)
* fix(backend): automatically handle default card reassignment on card deletion * feat(shared): add platform-specific regex validation for card handles * fix(backend): catch unhandled errors in card endpoints * refactor(backend): add typed responses and unhandled exception handling per review * feat(backend): implement centralized DB error handling for Prisma exceptions
1 parent 04f0973 commit fe788d7

6 files changed

Lines changed: 332 additions & 136 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import Fastify from 'fastify';
3+
import { cardRoutes } from '../routes/cards.js';
4+
import type { PrismaClient } from '@prisma/client';
5+
6+
const mockPrisma = {
7+
card: {
8+
findFirst: vi.fn(),
9+
count: vi.fn(),
10+
update: vi.fn(),
11+
delete: vi.fn(),
12+
create: vi.fn(),
13+
findMany: vi.fn(),
14+
updateMany: vi.fn(),
15+
},
16+
cardLink: {
17+
deleteMany: vi.fn(),
18+
createMany: vi.fn(),
19+
}
20+
} as unknown as PrismaClient;
21+
22+
async function buildApp() {
23+
const app = Fastify();
24+
app.decorate('prisma', mockPrisma);
25+
app.decorate('authenticate', async (request: any) => {
26+
request.user = { id: 'user-123' };
27+
});
28+
app.register(cardRoutes, { prefix: '/api/cards' });
29+
await app.ready();
30+
return app;
31+
}
32+
33+
describe('DELETE /api/cards/:id', () => {
34+
beforeEach(() => vi.clearAllMocks());
35+
36+
it('should return 404 if card is not found', async () => {
37+
(mockPrisma.card.findFirst as any).mockResolvedValue(null);
38+
const app = await buildApp();
39+
const res = await app.inject({ method: 'DELETE', url: '/api/cards/card-1' });
40+
expect(res.statusCode).toBe(404);
41+
expect(res.json().error).toBe('Card not found');
42+
});
43+
44+
it('should return 400 if trying to delete the last remaining card', async () => {
45+
(mockPrisma.card.findFirst as any).mockResolvedValue({ id: 'card-1', isDefault: true, userId: 'user-123' });
46+
(mockPrisma.card.count as any).mockResolvedValue(1);
47+
const app = await buildApp();
48+
const res = await app.inject({ method: 'DELETE', url: '/api/cards/card-1' });
49+
expect(res.statusCode).toBe(400);
50+
expect(res.json().error).toBe('Cannot delete the last remaining card. A user must have at least one card.');
51+
});
52+
53+
it('should successfully delete a non-default card without reassigning', async () => {
54+
(mockPrisma.card.findFirst as any).mockResolvedValue({ id: 'card-1', isDefault: false, userId: 'user-123' });
55+
(mockPrisma.card.count as any).mockResolvedValue(2);
56+
(mockPrisma.card.delete as any).mockResolvedValue({ id: 'card-1' });
57+
const app = await buildApp();
58+
const res = await app.inject({ method: 'DELETE', url: '/api/cards/card-1' });
59+
expect(res.statusCode).toBe(204);
60+
expect(mockPrisma.card.update).not.toHaveBeenCalled();
61+
expect(mockPrisma.card.delete).toHaveBeenCalledWith({ where: { id: 'card-1' } });
62+
});
63+
64+
it('should reassign default to oldest remaining card if deleting the default card', async () => {
65+
(mockPrisma.card.findFirst as any)
66+
.mockResolvedValueOnce({ id: 'card-1', isDefault: true, userId: 'user-123' }) // first findFirst for existing
67+
.mockResolvedValueOnce({ id: 'card-2', isDefault: false, userId: 'user-123' }); // second findFirst for oldest remaining
68+
69+
(mockPrisma.card.count as any).mockResolvedValue(2);
70+
(mockPrisma.card.update as any).mockResolvedValue({ id: 'card-2', isDefault: true });
71+
(mockPrisma.card.delete as any).mockResolvedValue({ id: 'card-1' });
72+
73+
const app = await buildApp();
74+
const res = await app.inject({ method: 'DELETE', url: '/api/cards/card-1' });
75+
76+
expect(res.statusCode).toBe(204);
77+
expect(mockPrisma.card.update).toHaveBeenCalledWith({
78+
where: { id: 'card-2' },
79+
data: { isDefault: true },
80+
});
81+
expect(mockPrisma.card.delete).toHaveBeenCalledWith({ where: { id: 'card-1' } });
82+
});
83+
});

0 commit comments

Comments
 (0)