CardTypeSchema öffnet 'cloze' als drittes MVP-Set-Mitglied. Domain-Modul
@cards/domain/src/cloze.ts kapselt die Cluster-Logik (extractClusterIds,
subIndexCountForCloze, clusterIdForSubIndex, renderClozePrompt/Answer)
— Hint-Markup wird MVP-stumm gedroppt.
subIndexCount('cloze') wirft jetzt explizit, statt still auf 1 zu fallen,
weil die Cluster-Anzahl text-abhängig ist und ein silent-default falsch
dimensionierte Review-Tabellen produzieren würde. Card-POST-Handler holt
für Cloze die Anzahl aus subIndexCountForCloze und lehnt 422 ab, wenn
kein {{cN::…}}-Markup vorhanden ist.
12 neue Cloze-Tests, alle Domain- und API-Tests grün (41 + 46).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
144 lines
4.2 KiB
TypeScript
144 lines
4.2 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
|
|
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: 'image-occlusion',
|
|
fields: { image_ref: 'x', mask_regions: 'y' },
|
|
}),
|
|
});
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|