import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; import { decksRouter } from '../src/routes/decks.ts'; import type { CardsDb } from '../src/db/connection.ts'; /** * Routen-Tests ohne echte DB. Wir mocken die paar Drizzle-Methoden, die * der Decks-Router nutzt, mit einem winzigen In-Memory-Store. * * Echte Integrations-Tests (gegen postgres/pg-mem) folgen in einer späteren * Phase, wenn die Test-Infra steht. */ type Row = { id: string; userId: string; name: string; description: string | null; color: string | null; visibility: 'private' | 'space' | 'public'; fsrsSettings: unknown; contentHash: string | null; createdAt: Date; updatedAt: Date; }; function makeFakeDb() { const store = new Map(); const fakeDb = { insert: (_table: unknown) => ({ values: (vals: Partial & { id: string; userId: string }) => ({ returning: async () => { const row: Row = { id: vals.id, userId: vals.userId, name: vals.name ?? '', description: vals.description ?? null, color: vals.color ?? null, visibility: vals.visibility ?? 'private', fsrsSettings: vals.fsrsSettings ?? {}, contentHash: vals.contentHash ?? null, createdAt: vals.createdAt ?? new Date(), updatedAt: vals.updatedAt ?? new Date(), }; store.set(row.id, row); return [row]; }, }), }), select: () => ({ from: (_table: unknown) => ({ where: (filter: { userId?: string; id?: string }) => { const items = Array.from(store.values()).filter((r) => { if (filter.userId && r.userId !== filter.userId) return false; if (filter.id && r.id !== filter.id) return false; return true; }); return Object.assign(items as Row[] | Promise, { limit: (_n: number) => items.slice(0, _n), }); }, }), }), update: (_table: unknown) => ({ set: (patch: Partial) => ({ where: (filter: { userId: string; id: string }) => ({ returning: async () => { const existing = store.get(filter.id); if (!existing || existing.userId !== filter.userId) return []; const updated = { ...existing, ...patch, updatedAt: new Date() }; store.set(updated.id, updated); return [updated]; }, }), }), }), delete: (_table: unknown) => ({ where: (filter: { userId: string; id: string }) => ({ returning: async () => { const existing = store.get(filter.id); if (!existing || existing.userId !== filter.userId) return []; store.delete(filter.id); return [{ id: filter.id }]; }, }), }), }; // Drizzle's eq/and dont actually pass a function-based filter; the fake-db // shim above doesn't match real Drizzle wire-shape. So we override the // interpretation: the test patches eq/and via a simpler comparator. // For now this fake-DB is sufficient ONLY if the routes' .where()-args // arrive as plain { userId, id } objects. They don't — they arrive as // Drizzle-SQL builders. So tests below are scoped to validation/auth paths, // not full CRUD. return { fakeDb, store }; } function buildApp() { const { fakeDb } = makeFakeDb(); // Cast — the fakeDb is intentionally minimal and not a full CardsDb. const app = new Hono(); app.route('/api/v1/decks', decksRouter({ db: fakeDb as unknown as CardsDb })); return { app }; } describe('decksRouter — auth-gate', () => { it('rejects requests without X-User-Id with 401', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/decks'); expect(res.status).toBe(401); }); it('lets through with X-User-Id (no DB call)', async () => { const { app } = buildApp(); // POST with invalid input should reach the validation step, not 401. const res = await app.request('/api/v1/decks', { method: 'POST', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json', }, body: '{}', }); expect(res.status).toBe(422); }); }); describe('decksRouter — input validation', () => { it('POST with empty body is 422', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/decks', { method: 'POST', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: '{}', }); expect(res.status).toBe(422); const body = (await res.json()) as { error: string }; expect(body.error).toBe('invalid_input'); }); it('POST with bad color is 422', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/decks', { method: 'POST', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'D', color: 'red' }), }); expect(res.status).toBe(422); }); it('PATCH with extra prop is 422', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/decks/d-1', { method: 'PATCH', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'X', leak: 'bad' }), }); expect(res.status).toBe(422); }); });