import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; import { cardsRouter } from '../src/routes/cards.ts'; import type { CardsDb } from '../src/db/connection.ts'; /** * Routen-Tests ohne echte DB. Drizzle-Aufrufe werden durch eine * minimale Stub-DB ersetzt, die nur die Validations-Pfade abdeckt. */ function buildApp() { const app = new Hono(); const stub = { select: () => ({ from: () => ({ where: () => ({ limit: () => [] }), }), }), }; app.route('/api/v1/cards', cardsRouter({ db: stub as unknown as CardsDb })); return { app }; } describe('cardsRouter — auth-gate', () => { it('GET ohne X-User-Id ist 401', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards'); expect(res.status).toBe(401); }); it('GET /hashes ohne X-User-Id ist 401', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards/hashes'); expect(res.status).toBe(401); }); }); describe('cardsRouter — Input-Validation', () => { it('POST mit leerem Body ist 422', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards', { method: 'POST', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: '{}', }); expect(res.status).toBe(422); }); it('POST mit basic-Card ohne back-Feld ist 422', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards', { method: 'POST', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ deck_id: 'd-1', type: 'basic', fields: { front: 'Q' }, }), }); expect(res.status).toBe(422); }); it('POST mit unknown CardType ist 422', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards', { method: 'POST', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ deck_id: 'd-1', type: 'audio', fields: { audio_ref: 'x' }, }), }); expect(res.status).toBe(422); }); it('POST mit image-occlusion ohne mask_regions ist 422', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards', { method: 'POST', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ deck_id: 'd-1', type: 'image-occlusion', fields: { image_ref: 'm1' }, }), }); expect(res.status).toBe(422); }); it('POST mit image-occlusion mit kaputtem mask_regions ist 422', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards', { method: 'POST', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ deck_id: 'd-1', type: 'image-occlusion', fields: { image_ref: 'm1', mask_regions: 'not json' }, }), }); expect(res.status).toBe(422); const body = (await res.json()) as { issues: string[] }; expect(body.issues[0]).toMatch(/mask_regions/); }); it('POST mit gültiger image-occlusion erreicht Deck-Lookup (404 bei stub)', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards', { method: 'POST', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ deck_id: 'd-1', type: 'image-occlusion', fields: { image_ref: 'm1', mask_regions: JSON.stringify([ { id: 'r1', x: 0.1, y: 0.1, w: 0.1, h: 0.1 }, ]), }, }), }); expect(res.status).toBe(404); const body = (await res.json()) as { error: string }; expect(body.error).toBe('deck_not_found'); }); it('POST mit cloze-Card ohne text-Feld ist 422', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards', { method: 'POST', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ deck_id: 'd-1', type: 'cloze', fields: {}, }), }); expect(res.status).toBe(422); }); it('POST mit cloze-Card aber Text ohne Cluster ist 422', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards', { method: 'POST', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ deck_id: 'd-1', type: 'cloze', fields: { text: 'plain text without any {{cN::…}} markup' }, }), }); expect(res.status).toBe(422); const body = (await res.json()) as { error: string; issues: string[] }; expect(body.issues[0]).toMatch(/cloze\.text/); }); it('POST mit gültiger cloze-Card erreicht Deck-Lookup (404 bei stub)', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards', { method: 'POST', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ deck_id: 'd-1', type: 'cloze', fields: { text: 'Capital of {{c1::France}} is {{c2::Paris}}.' }, }), }); expect(res.status).toBe(404); const body = (await res.json()) as { error: string }; expect(body.error).toBe('deck_not_found'); }); it('PATCH mit extra prop ist 422', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards/c-1', { method: 'PATCH', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ fields: { front: 'X' }, leak: 'bad' }), }); expect(res.status).toBe(422); }); it('POST mit gültigem basic-Card erreicht Deck-Lookup (404 bei stub)', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards', { method: 'POST', headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ deck_id: 'd-1', type: 'basic', fields: { front: 'Q', back: 'A' }, }), }); // Stub-DB gibt empty array → Deck-Not-Found-Pfad expect(res.status).toBe(404); const body = (await res.json()) as { error: string }; expect(body.error).toBe('deck_not_found'); }); });