// @vitest-environment node // Schema-Schutzgitter. Geprüft wird gegen echte SQLite-Dateien, weil genau // die Eigenschaften auf dem Spiel stehen, die eine In-Memory-Datenbank nicht // zeigt: WAL-Journal, Fremdschlüssel über Neustarts hinweg, Dauerhaftigkeit. import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Lernstand, lernstandInstanz, lernstandSchliessen } from '../src/main/lernstand'; import { SCHEMA_VERSION } from '../src/main/schema'; import type { Frage, Katalog } from '../src/shared/katalog'; const FRAGEN: readonly Frage[] = [ { id: 'I.1-01', amtliche_nummer: '1.01', kapitel: 'I', abschnitt: 'I.1', typ: 'mc', seite: 1, frage: { text: 'Frage', segmente: [{ t: 'Frage' }] }, bilder: [], optionen: [ { label: 'a', inhalt: { text: 'a', segmente: [] }, korrekt: true, bilder: [] }, { label: 'b', inhalt: { text: 'b', segmente: [] }, korrekt: false, bilder: [] }, ], }, { id: 'II-01', amtliche_nummer: '2.01', kapitel: 'II', abschnitt: null, typ: 'freitext', seite: 2, frage: { text: 'Frage', segmente: [{ t: 'Frage' }] }, bilder: [], musterantwort: { text: 'Antwort', segmente: [{ t: 'Antwort', h: true }] }, }, ]; const KATALOG: Katalog = { meta: { titel: 'Prüfkatalog', herausgeber: 'Bundesverwaltungsamt', stand: '2024-12-16', quellenangabe: 'Amtlicher Fragenkatalog.', quelle_url: 'https://www.bva.bund.de/', quelldatei_sha256: 'a'.repeat(64), fragen_gesamt: FRAGEN.length, }, kapitel: [ { id: 'I', titel: 'Waffenrecht', abschnitte: [{ id: 'I.1', titel: 'Begriffe' }] }, { id: 'II', titel: 'Waffentechnik', abschnitte: [] }, ], bilder: [], fragen: FRAGEN, }; let verzeichnis: string; let pfad: string; /** Alle geöffneten Verbindungen – auch die, deren Aufbau scheiterte. */ let verbindungen: Database.Database[]; function oeffnen(): { lernstand: Lernstand; db: Database.Database } { const db = new Database(pfad); // Vor der Konstruktion vormerken: schlägt sie fehl, muss die Verbindung // trotzdem geschlossen werden, sonst bleibt die Datei unter Windows gesperrt. verbindungen.push(db); return { lernstand: new Lernstand(db, KATALOG), db }; } beforeEach(() => { verzeichnis = mkdtempSync(join(tmpdir(), 'waffensachkunde-')); pfad = join(verzeichnis, 'lernstand.db'); verbindungen = []; }); afterEach(() => { // Die Modul-weite Instanz aus lernstandInstanz mit aufraeumen. lernstandSchliessen(); for (const db of verbindungen) { if (db.open) { db.close(); } } rmSync(verzeichnis, { recursive: true, force: true }); }); // ─── Aufbau ───────────────────────────────────────────────────────────────── describe('Schema', () => { it('legt alle geforderten Tabellen an', () => { const { db } = oeffnen(); const tabellen = ( db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as { name: string; }[] ).map((z) => z.name); expect(tabellen).toEqual( expect.arrayContaining([ 'schema_version', 'profil', 'frage_stand', 'antwort_log', 'katalog_stand', ]), ); }); it.each([ ['profil', ['id', 'name', 'pruefungstermin', 'erstellt_am']], [ 'frage_stand', [ 'profil_id', 'frage_id', 'versuche', 'richtige', 'zuletzt_beantwortet', 'faellig_ab', 'gemerkt', 'letzte_bewertung', ], ], [ 'antwort_log', [ 'id', 'profil_id', 'frage_id', 'zeitpunkt', 'richtig', 'bewertung', 'dauer_ms', 'auswahl', 'freitext', ], ], ])('gibt der Tabelle %s die vereinbarten Spalten', (tabelle, spalten) => { const { db } = oeffnen(); const vorhanden = (db.pragma(`table_info(${tabelle})`) as { name: string }[]).map( (s) => s.name, ); expect(vorhanden).toEqual(expect.arrayContaining(spalten)); }); it('nutzt (profil_id, frage_id) als Primärschlüssel von frage_stand', () => { const { db } = oeffnen(); const schluessel = (db.pragma('table_info(frage_stand)') as { name: string; pk: number }[]) .filter((s) => s.pk > 0) .sort((a, b) => a.pk - b.pk) .map((s) => s.name); expect(schluessel).toEqual(['profil_id', 'frage_id']); }); it('schaltet WAL und Fremdschlüssel ein', () => { const { db } = oeffnen(); expect(db.pragma('journal_mode', { simple: true })).toBe('wal'); expect(db.pragma('foreign_keys', { simple: true })).toBe(1); }); it('hält den Schemastand fest', () => { const { db } = oeffnen(); expect(db.prepare('SELECT MAX(version) AS version FROM schema_version').get()).toEqual({ version: SCHEMA_VERSION, }); }); }); // ─── Beständigkeit ────────────────────────────────────────────────────────── describe('Beständigkeit', () => { it('behält Profile und Antworten über einen Neustart hinweg', () => { const erste = oeffnen(); const profil = erste.lernstand.profilAnlegen('Olaf'); erste.lernstand.antworten(profil.id, { frageId: 'I.1-01', auswahl: ['a'], richtig: true, bewertung: 'gut', dauerMs: 1200, }); erste.lernstand.schliessen(); const zweite = oeffnen(); expect(zweite.lernstand.profile().map((p) => p.name)).toEqual(['Standard', 'Olaf']); expect(zweite.lernstand.frageStand(profil.id, 'I.1-01')).toMatchObject({ versuche: 1, richtige: 1, letzteBewertung: 'gut', }); }); it('legt beim erneuten Öffnen weder ein zweites Standardprofil noch einen zweiten Schemaeintrag an', () => { oeffnen().lernstand.schliessen(); const { lernstand, db } = oeffnen(); expect(lernstand.profile()).toHaveLength(1); expect(db.prepare('SELECT COUNT(*) AS n FROM schema_version').get()).toEqual({ n: SCHEMA_VERSION, }); /* Auch der Katalogstand wird nur beim ersten Öffnen vermerkt – bei Gleichstand entsteht keine zweite Zeile, sonst wüchse die Tabelle mit jedem Start und der Verlauf sagte nichts mehr. */ expect(db.prepare('SELECT COUNT(*) AS n FROM katalog_stand').get()).toEqual({ n: 1 }); }); it('verweigert den Start, wenn der Lernstand aus einer neueren Version stammt', () => { const { lernstand, db } = oeffnen(); db.prepare('INSERT INTO schema_version (version, angewendet_am) VALUES (?, ?)').run( SCHEMA_VERSION + 5, new Date().toISOString(), ); lernstand.schliessen(); expect(() => oeffnen()).toThrow(/neueren Programmversion/u); }); it('lässt nach einer Abweisung keine offene Verbindung zurück', () => { /* Scheitert der Konstruktor, bliebe die eben geöffnete Verbindung sonst liegen – und die Oberfläche versucht es nach jeder Sitzung erneut. Unter Windows ist eine Datei mit offenem Handle nicht löschbar; genau das ist hier der belastbare Nachweis. */ const { lernstand, db } = oeffnen(); db.prepare('INSERT INTO schema_version (version, angewendet_am) VALUES (?, ?)').run( SCHEMA_VERSION + 5, new Date().toISOString(), ); lernstand.schliessen(); for (let versuch = 0; versuch < 3; versuch += 1) { expect(() => lernstandInstanz(pfad, KATALOG)).toThrow(/neueren Programmversion/u); } expect(() => { rmSync(pfad, { force: true }); }).not.toThrow(); }); }); // ─── Datenintegrität ──────────────────────────────────────────────────────── describe('Integritätsregeln', () => { it('lehnt Protokollzeilen zu einem unbekannten Profil ab', () => { const { db } = oeffnen(); expect(() => db .prepare( `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms) VALUES (?, ?, ?, ?, ?, ?)`, ) .run(4711, 'I.1-01', new Date().toISOString(), 1, 'gut', 100), ).toThrow(/FOREIGN KEY/u); }); it('räumt beim Löschen eines Profils dessen Daten mit ab', () => { const { lernstand, db } = oeffnen(); const profil = lernstand.profilAnlegen('Olaf'); lernstand.antworten(profil.id, { frageId: 'I.1-01', auswahl: ['a'], richtig: true, bewertung: 'gut', dauerMs: 10, }); db.prepare('DELETE FROM profil WHERE id = ?').run(profil.id); expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 0 }); expect(db.prepare('SELECT COUNT(*) AS n FROM frage_stand').get()).toEqual({ n: 0 }); }); it('lässt je Profil und Frage nur eine Standzeile zu', () => { const { lernstand, db } = oeffnen(); const profilId = lernstand.profile()[0]!.id; lernstand.merken(profilId, 'I.1-01', true); expect(() => db .prepare('INSERT INTO frage_stand (profil_id, frage_id) VALUES (?, ?)') .run(profilId, 'I.1-01'), ).toThrow(/UNIQUE|PRIMARY KEY/u); }); it.each([ ['bewertung', 'auswendig'], ['richtig', 7], ])('lehnt einen unzulässigen Wert in antwort_log.%s ab', (spalte, wert) => { const { lernstand, db } = oeffnen(); const profilId = lernstand.profile()[0]!.id; const werte: Record = { profil_id: profilId, frage_id: 'I.1-01', zeitpunkt: new Date().toISOString(), richtig: 1, bewertung: 'gut', dauer_ms: 10, }; werte[spalte] = wert; expect(() => db .prepare( `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms) VALUES (@profil_id, @frage_id, @zeitpunkt, @richtig, @bewertung, @dauer_ms)`, ) .run(werte), ).toThrow(/CHECK/u); }); it('lehnt einen doppelten Profilnamen auch auf Datenbankebene ab', () => { const { db } = oeffnen(); expect(() => db .prepare('INSERT INTO profil (name, erstellt_am) VALUES (?, ?)') .run('Standard', new Date().toISOString()), ).toThrow(/UNIQUE/u); }); }); describe('Migration auf einen bestehenden Lernstand', () => { /* Der Fall, den kein bisheriger Test abdeckte – und der genau deshalb gefährlich ist: Alle Tests legen frische Datenbanken an, und auf einer frischen tut `CREATE TABLE IF NOT EXISTS` alles Nötige. Auf einer bestehenden ist es ein reiner Leerlauf. Wer eine neue Spalte dort statt in MIGRATIONEN einträgt, baut einen Fehler, den die ganze Suite nicht sieht: Die Spalte entsteht nie, und das erste SELECT darauf scheitert mit „no such column“ – beim Nutzer, nicht im Test. */ it('ergänzt kapitel_ausschluss auf einer Datenbank ohne diese Spalte', () => { const { lernstand } = oeffnen(); lernstand.schliessen(); /* Den Zustand vor der Migration nachstellen: Spalte weg, Versionsstand zurück. SQLite kann DROP COLUMN seit 3.35. */ const roh = new Database(pfad); verbindungen.push(roh); roh.exec('ALTER TABLE profil DROP COLUMN kapitel_ausschluss'); roh.prepare('DELETE FROM schema_version WHERE version >= 6').run(); expect( roh .prepare<[], { name: string }>('PRAGMA table_info(profil)') .all() .some((z) => z.name === 'kapitel_ausschluss'), ).toBe(false); roh.close(); const zweite = oeffnen(); /* Die Spalte ist da, gefüllt, und das bestehende Profil ist unverändert lesbar – ohne Vorgabewert scheiterte das ADD COLUMN an NOT NULL. */ expect(zweite.lernstand.profile()[0]?.kapitelAusschluss).toEqual([]); expect(zweite.db.prepare('SELECT MAX(version) AS v FROM schema_version').get()).toEqual({ v: SCHEMA_VERSION, }); }); it('bildet den belegten Abruf aus dem Antwortprotokoll exakt nach', () => { /* Der Punkt, an dem eine Migration entweder ehrlich ist oder rät. Der Beleg ist neu – aber er lässt sich nicht schätzen, sondern nachspielen: `antwort_log` ist die vollständige Historie und wird nie überschrieben; der Kommentar über `frage_stand` im Schema sagt selbst, dort stehe nur die Zusammenfassung, „die Wahrheit steht in antwort_log“. Dieser Test schreibt eine Historie von Hand, nimmt die Spalte weg, öffnet neu – und verlangt, dass genau die Fragen belegt sind, die es nach der Regel sein müssen. Ein Vorgabewert (etwa „alles, was zuletzt richtig war“) käme durch die anderen Tests durch und wäre trotzdem falsch. */ const { lernstand, db } = oeffnen(); const profil = lernstand.profile()[0]!; lernstand.schliessen(); const roh = new Database(pfad); verbindungen.push(roh); /* Vier Fragen, vier Verläufe. Die erwarteten Ergebnisse stehen daneben. */ const historie: readonly (readonly [string, number, string])[] = [ // I.1-01: einmal richtig – kein Beleg, das erste Mal zählt nie. ['I.1-01', 1, '2026-01-01T09:00:00.000Z'], // I.1-02: richtig, dann zwei Tage später wieder richtig – belegt. ['I.1-02', 1, '2026-01-01T09:00:00.000Z'], ['I.1-02', 1, '2026-01-03T09:00:00.000Z'], // I.2-01: wie oben belegt, danach aber falsch – Beleg wieder weg. ['I.2-01', 1, '2026-01-01T09:00:00.000Z'], ['I.2-01', 1, '2026-01-03T09:00:00.000Z'], ['I.2-01', 0, '2026-01-10T09:00:00.000Z'], // II-01: zweimal richtig am selben Tag – kein Beleg, kein Schaden. ['II-01', 1, '2026-01-01T09:00:00.000Z'], ['II-01', 1, '2026-01-01T21:00:00.000Z'], ]; const eintragen = roh.prepare( `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms) VALUES (?, ?, ?, ?, ?, 1500)`, ); const stand = roh.prepare( `INSERT INTO frage_stand (profil_id, frage_id, versuche, richtige, zuletzt_beantwortet) VALUES (?, ?, 1, 1, ?) ON CONFLICT (profil_id, frage_id) DO UPDATE SET versuche = versuche + 1`, ); for (const [frageId, richtig, zeitpunkt] of historie) { eintragen.run(profil.id, frageId, zeitpunkt, richtig, richtig === 1 ? 'gut' : 'nochmal'); stand.run(profil.id, frageId, zeitpunkt); } roh.exec('ALTER TABLE frage_stand DROP COLUMN bestaetigt'); roh.prepare('DELETE FROM schema_version WHERE version >= 7').run(); roh.close(); const zweite = oeffnen(); const belegt = zweite.db .prepare<[], { frage_id: string }>( 'SELECT frage_id FROM frage_stand WHERE bestaetigt = 1 ORDER BY frage_id', ) .all() .map((z) => z.frage_id); expect(belegt).toEqual(['I.1-02']); expect(zweite.db.prepare('SELECT MAX(version) AS v FROM schema_version').get()).toEqual({ v: SCHEMA_VERSION, }); void db; }); it('ergänzt nur_historie und lässt bestehende Zeilen mitzählen', () => { /* Schemafassung 8. Rückwirkend lässt sich nicht ermitteln, welche Protokollzeilen zu nie gestellten Prüfungsfragen gehören – sie bekommen deshalb 0 und zählen weiter mit. Das ist die ehrlichere Wahl: Zu raten hiesse, Zahlen zu ändern, von denen niemand weiss, ob sie falsch waren. */ const { lernstand } = oeffnen(); const profil = lernstand.profile()[0]!; lernstand.antworten(profil.id, { frageId: 'I.1-01', auswahl: ['a'], richtig: true, bewertung: 'gut', dauerMs: 1000, }); lernstand.schliessen(); const roh = new Database(pfad); verbindungen.push(roh); roh.exec('ALTER TABLE antwort_log DROP COLUMN nur_historie'); roh.prepare('DELETE FROM schema_version WHERE version >= 8').run(); roh.close(); const zweite = oeffnen(); expect( zweite.db .prepare<[], { anzahl: number }>( 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE nur_historie = 0', ) .get()?.anzahl, ).toBe(1); /* Und die bestehende Zeile zählt weiterhin in die Tagesbilanz. */ expect(zweite.lernstand.uebersicht(profil.id).heuteRichtig).toBe(1); }); it('vermerkt beim Aufstieg auf Fassung 9 den geladenen Katalogstand als Ausgangswert', () => { /* Schemafassung 9. Gegen welchen Katalogstand die vorhandenen Zeilen wirklich entstanden, wurde nie festgehalten und lässt sich nicht rekonstruieren – der geladene Stand ist der ehrlichste Ausgangswert. Und er darf keine Meldung auslösen: Für den Nutzer hat sich an diesem Tag nichts geändert, ein Alarm wäre erfunden. */ const { lernstand } = oeffnen(); lernstand.schliessen(); const roh = new Database(pfad); verbindungen.push(roh); roh.exec('DROP TABLE katalog_stand'); roh.prepare('DELETE FROM schema_version WHERE version >= 9').run(); roh.close(); const zweite = oeffnen(); expect(zweite.lernstand.katalogwechsel).toBeNull(); expect( zweite.db .prepare<[], { stand: string }>('SELECT stand FROM katalog_stand ORDER BY id DESC LIMIT 1') .get(), ).toEqual({ stand: '2024-12-16' }); expect(zweite.db.prepare('SELECT MAX(version) AS v FROM schema_version').get()).toEqual({ v: SCHEMA_VERSION, }); }); it('läuft ein zweites Mal durch, ohne zu scheitern', () => { /* Ein nacktes ALTER TABLE würfe beim zweiten Lauf „duplicate column name“, und eine einmal aus dem Tritt geratene Datenbank wäre dauerhaft nicht mehr zu öffnen. `spalteErgaenzen` sieht deshalb erst nach. */ const { lernstand } = oeffnen(); lernstand.schliessen(); const roh = new Database(pfad); verbindungen.push(roh); roh.prepare('DELETE FROM schema_version WHERE version >= 6').run(); roh.close(); expect(() => oeffnen()).not.toThrow(); }); });