Some checks are pending
CI / validate (push) Waiting to run
- apps/landing/ entfernt (Cardecky-Marketing-Astro-Site, war nie deployed — der nginx-Block in landings.conf zeigte auf einen Pfad ohne Inhalt) - Stale Doc-Kommentare in api+web: Cardecky → Wordeck wo passend - fsrs.ts subIndexCount: image-occlusion + audio-front Cases raus (CardType-Enum hat sie nicht mehr — der throw wäre toter Code) - fork.ts: image-occlusion-Sonderfall in subIndexCountFor weg - cards-domain tests: schemas-Test prüft jetzt Wordeck-Typen + dass image-occlusion/audio-front ABGELEHNT werden - cards-domain tests: image-occlusion-throw-Test entfernt - api tests/cards.test.ts: 3 image-occlusion-Cases zu 1 negative Tested (422 expected, weil Schema sie verbietet) 51 Tests grün (cards-domain). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
164 lines
4.8 KiB
TypeScript
164 lines
4.8 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);
|
|
});
|
|
|
|
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 ist 422 (CardType nicht mehr akzeptiert)', 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: '[]' },
|
|
}),
|
|
});
|
|
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');
|
|
});
|
|
});
|