cards/apps/web/tests/anki-parse.test.ts
Till JS c9eb0a6f80 Phase 9k: Media-Upload via MinIO-Container
Eigener cards-minio-Container im docker-compose (9100/9101 — Plattform
auf 9000/9001 bleibt isoliert). cardsadmin/cardsadmin als Dev-Default,
prod via env-Vars (CARDS_S3_*).

apps/api/src/services/storage.ts — schmaler StorageService um den
minio-Client. ensureBucket() ist idempotent (auto-create beim ersten
Upload). removeObjectsByPrefix() implementiert den DSGVO-Bucket-Sweep,
weil die S3-API kein Cascade kennt.

Neue Tabelle media_files in pgSchema('cards'):
  id, user_id, object_key, mime_type, original_filename, size_bytes,
  kind, created_at — kein FK auf cards (ein File kann mehreren Karten
  gehören). objectKey-Format <userId>/<ulid>.<ext> für Bucket-Prefix-
  Sweep beim DSGVO-Delete. Legacy mediaRefs bleibt als Slot.

Neuer Router /api/v1/media:
  POST /upload   — multipart, 25 MiB Default-Limit, image/audio/video
                   only (415 sonst), schreibt media_files-Row + speichert
                   in MinIO unter <userId>/<ulid>.<ext>
  GET  /:id      — streamt aus MinIO mit Cache-Control: private,
                   immutable. Cross-User → 404 (nicht 403, anti-enumeration).
  GET  /         — listet alle eigenen Files

DSGVO-Pfade (Service-Key + /me/delete) räumen jetzt auch media_files
+ MinIO-Bucket-Prefix mit ab. Storage-Sweep ist non-fatal — DB ist erst
konsistent gelöscht, dead bytes wären die schlimmstmögliche Folge.

Anki-Import: parse.ts sanitizeAnkiHtml akzeptiert wieder eine
Filename→URL-Map (war in Phase 8c gedroppt). import.ts lädt vor den
Karten alle referenzierten Media-Files via uploadMedia() in MinIO,
sammelt URLs, ersetzt Anki-Filenames durch /api/v1/media/<id>-Pfade
in `<img>` (Markdown) und `[sound:…]` (HTML <audio>). 4-fache Worker-
Concurrency.

apps/web/src/lib/markdown.ts: DOMPurify lässt jetzt <audio>/<video>/
<source> mit src/controls/preload-Attributen durch — sonst würden die
Audio-Tags aus dem Anki-Import gestrippt.

i18n-Strings (DE/EN) auf Media-Stage erweitert: stage_media,
done_media, what_works_media, dropzone_hint, preview_media.
import.what_skipped_media wird zur Bestätigung dass Media seit
Sprint 9k mit übernommen wird.

Manueller E2E-Smoke gegen lokale MinIO (cards-minio :9100):
- 1×1-PNG hochgeladen → 201 mit ID + URL
- /api/v1/media/<id> streamt 200 image/png 69 bytes (file-Identifikation
  bestätigt)
- Cross-User → 404, ohne X-User-Id → 401, text/plain → 415

53 API-Tests grün (+4 neue media-Auth-Gate-Tests), 7 Web-Tests,
51 Domain-Tests, type-check + svelte-check 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:42:56 +02:00

181 lines
6.4 KiB
TypeScript

/**
* Hermetic Anki-Parser-Test. Wir bauen zur Laufzeit eine minimale
* .apkg (sql.js + JSZip), füttern sie in `parseApkg` und prüfen das
* ParsedAnki-Output. Keine Binär-Fixture im Repo — die Tests sind
* vollständig reproducible aus reiner JS-Logik.
*
* Anki-Mindest-Schema: `col` (collection), `notes`, `cards` plus
* JSON-encoded `models` und `decks` im einzigen `col`-Row.
*/
import { describe, it, expect } from 'vitest';
import JSZip from 'jszip';
import initSqlJs from 'sql.js';
import { readFileSync } from 'node:fs';
import { parseApkg, sanitizeAnkiHtml } from '../src/lib/anki/parse.ts';
// In Node-Tests muss locateFile auf den lokalen WASM-Pfad zeigen.
// Wir patchen das einmal pro Test-Run — die Library cached SQL global.
const WASM_BUFFER = readFileSync(
new URL('../node_modules/sql.js/dist/sql-wasm.wasm', import.meta.url)
);
async function buildSampleApkg(): Promise<Blob> {
const SQL = await initSqlJs({ wasmBinary: WASM_BUFFER as unknown as ArrayBuffer });
const db = new SQL.Database();
// Anki erwartet diese Tabellen auch wenn nur ein Subset benutzt wird.
db.run(`
CREATE TABLE col (
id INTEGER PRIMARY KEY, crt INTEGER, mod INTEGER, scm INTEGER, ver INTEGER,
dty INTEGER, usn INTEGER, ls INTEGER,
conf TEXT, models TEXT, decks TEXT, dconf TEXT, tags TEXT
);
CREATE TABLE notes (
id INTEGER PRIMARY KEY, guid TEXT, mid INTEGER, mod INTEGER, usn INTEGER,
tags TEXT, flds TEXT, sfld TEXT, csum INTEGER, flags INTEGER, data TEXT
);
CREATE TABLE cards (
id INTEGER PRIMARY KEY, nid INTEGER, did INTEGER, ord INTEGER, mod INTEGER,
usn INTEGER, type INTEGER, queue INTEGER, due INTEGER, ivl INTEGER,
factor INTEGER, reps INTEGER, lapses INTEGER, left INTEGER, odue INTEGER,
odid INTEGER, flags INTEGER, data TEXT
);
`);
const models = {
// model-id "100" = Standard (1 template = basic)
'100': {
id: 100,
name: 'Basic',
type: 0,
flds: [{ name: 'Front' }, { name: 'Back' }],
tmpls: [{ name: 'Card 1' }],
},
// model-id "101" = Standard mit 2 templates = basic-reverse
'101': {
id: 101,
name: 'Basic + Reverse',
type: 0,
flds: [{ name: 'Front' }, { name: 'Back' }],
tmpls: [{ name: 'Card 1' }, { name: 'Card 2' }],
},
// model-id "102" = Cloze
'102': {
id: 102,
name: 'Cloze',
type: 1,
flds: [{ name: 'Text' }, { name: 'Extra' }],
tmpls: [{ name: 'Cloze' }],
},
};
const decks = {
'1': { id: 1, name: 'Default' },
'200': { id: 200, name: 'Geographie::Europa' },
'201': { id: 201, name: 'Vokabeln' },
};
db.run(
`INSERT INTO col VALUES (1,0,0,0,0,0,0,0,'{}',?,?,'{}','{}')`,
[JSON.stringify(models), JSON.stringify(decks)]
);
// Drei Notes (basic, basic-reverse, cloze).
const FS = '\x1f'; // Anki-Field-Separator
db.run(
`INSERT INTO notes VALUES (?,?,?,0,0,'',?,'',0,0,'')`,
[1001, 'g1', 100, `Was ist 2+2?${FS}4`]
);
db.run(
`INSERT INTO notes VALUES (?,?,?,0,0,'',?,'',0,0,'')`,
[1002, 'g2', 101, `Hauptstadt Frankreich${FS}Paris`]
);
db.run(
`INSERT INTO notes VALUES (?,?,?,0,0,'',?,'',0,0,'')`,
[1003, 'g3', 102, `Die {{c1::Hauptstadt}} von {{c2::Frankreich}}.${FS}Geo-Quiz`]
);
// Cards: 1 für basic (ord=0), 2 für basic-reverse (ord=0,1),
// 2 für cloze (ord=0,1 — Anki erzeugt eine Card pro Cluster).
db.run(`INSERT INTO cards VALUES (?,?,?,0,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9001, 1001, 200]);
db.run(`INSERT INTO cards VALUES (?,?,?,0,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9002, 1002, 201]);
db.run(`INSERT INTO cards VALUES (?,?,?,1,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9003, 1002, 201]);
db.run(`INSERT INTO cards VALUES (?,?,?,0,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9004, 1003, 200]);
db.run(`INSERT INTO cards VALUES (?,?,?,1,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9005, 1003, 200]);
const sqliteBytes = db.export();
db.close();
const zip = new JSZip();
zip.file('collection.anki21', sqliteBytes);
// Empty media manifest — kein extra File.
zip.file('media', '{}');
const blob = await zip.generateAsync({ type: 'arraybuffer' });
return new Blob([blob]);
}
describe('parseApkg', () => {
it('extrahiert Decks, Cards (basic, basic-reverse, cloze) und de-dupes Note-Rows', async () => {
const apkg = await buildSampleApkg();
const result = await parseApkg(apkg);
// Default-Deck (id=1) wird gefiltert.
expect(result.decks).toHaveLength(2);
expect(result.decks.map((d) => d.name).sort()).toEqual([
'Geographie::Europa',
'Vokabeln',
]);
// 3 Notes → 3 Karten (NICHT 5, weil basic-reverse + cloze auf Note-Ebene dedupliziert werden).
expect(result.cards).toHaveLength(3);
const types = result.cards.map((c) => c.type).sort();
expect(types).toEqual(['basic', 'basic-reverse', 'cloze']);
const cloze = result.cards.find((c) => c.type === 'cloze');
expect(cloze?.fields.text).toBe('Die {{c1::Hauptstadt}} von {{c2::Frankreich}}.');
expect(cloze?.fields.extra).toBe('Geo-Quiz');
const basic = result.cards.find((c) => c.type === 'basic');
expect(basic?.fields).toEqual({ front: 'Was ist 2+2?', back: '4' });
const reverse = result.cards.find((c) => c.type === 'basic-reverse');
expect(reverse?.fields).toEqual({ front: 'Hauptstadt Frankreich', back: 'Paris' });
expect(result.skipped).toBe(0);
});
});
describe('sanitizeAnkiHtml', () => {
it('droppt Bilder und Audio ohne URL-Map (lossy fallback)', () => {
const out = sanitizeAnkiHtml('Vorne <img src="paris.jpg"> Hinten [sound:audio.mp3] fertig.');
expect(out).toBe('Vorne Hinten fertig.');
});
it('ersetzt Bilder durch Markdown wenn URL-Map gesetzt', () => {
const map = new Map([['paris.jpg', '/api/v1/media/abc']]);
const out = sanitizeAnkiHtml('Vorne <img src="paris.jpg"> hinten.', map);
expect(out).toBe('Vorne ![paris.jpg](/api/v1/media/abc) hinten.');
});
it('ersetzt [sound:…] durch <audio> wenn URL-Map gesetzt', () => {
const map = new Map([['x.mp3', '/api/v1/media/xyz']]);
const out = sanitizeAnkiHtml('Vorne [sound:x.mp3] hinten.', map);
expect(out).toContain('<audio controls preload="metadata" src="/api/v1/media/xyz">');
});
it('konvertiert Bold/Italic zu Markdown', () => {
expect(sanitizeAnkiHtml('Das <b>ist</b> <i>wichtig</i>')).toBe('Das **ist** *wichtig*');
});
it('decodiert HTML-Entities', () => {
expect(sanitizeAnkiHtml('Q&amp;A &lt;tag&gt;')).toBe('Q&A <tag>');
});
it('lässt Cloze-Markup intakt', () => {
const out = sanitizeAnkiHtml('Die {{c1::Hauptstadt}} ist <b>Paris</b>.');
expect(out).toBe('Die {{c1::Hauptstadt}} ist **Paris**.');
});
});