wordeck/apps/api/tests/cards.test.ts
Till JS c77100e85a
Some checks are pending
CI / validate (push) Waiting to run
chore: stale Cardecky-Refs entfernt + apps/landing/ gelöscht
- 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>
2026-05-17 21:58:52 +02:00

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');
});
});