// @vitest-environment node /** * Die Heilprüfung der laufenden Lernstandsdatei. * * **Der Anlass.** Für *fremde* Sicherungsdateien lief seit jeher eine * neunstufige Prüfkette (`sicherung.ts`, Stufe 4 mit `integrity_check`). Die * eigene, laufende Datei bekam nie eine: `vorbereiten()` prüfte die * Schemaversion, schaltete WAL ein und wandte das Schema an. Ein Bitfehler, * ein defekter Datenträger oder ein abgebrochener Schreibvorgang auf einem * USB-Stick blieben unbemerkt, bis irgendeine einzelne Abfrage * `SQLITE_CORRUPT` warf – irgendwann mitten in einer Sitzung, mit „Ihr * Lernprofil konnte nicht geladen werden“ als einziger Auskunft und ohne * jedes Angebot. * * Hier wird an einer **echt beschädigten Datei** gemessen, nicht an einer * Attrappe: Eine gültige Datenbank wird angelegt, geschlossen und danach in * ihren Datenseiten überschrieben. */ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } 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 { istBeschaedigt, LERNSTAND_BESCHAEDIGT, Lernstand, lernstandBeiseitelegen, } from '../src/main/lernstand'; import type { Katalog } from '../src/shared/katalog'; const KATALOG: Katalog = { meta: { titel: 'Test', herausgeber: 'Test', stand: '2024-12-16', quelle: 'Test' }, kapitel: [{ id: 'I', titel: 'Recht', abschnitte: [{ id: 'I.1', titel: 'Grundlagen' }] }], bilder: {}, fragen: [ { id: 'I.1-1', kapitel: 'I', abschnitt: 'I.1', nummer: '1', frage: 'Eine Frage?', optionen: [{ label: 'a', text: 'Antwort', richtig: true }], }, ], } as unknown as Katalog; let verzeichnis = ''; let pfad = ''; /** Legt eine gültige, geschlossene Lernstandsdatei an. */ function gueltigeDatei(): void { const db = new Database(pfad); new Lernstand(db, KATALOG); db.close(); } /** * Überschreibt die Datenseiten – der Kopf bleibt stehen. * * Nur so entsteht der Fall, um den es geht: SQLite erkennt die Datei * weiterhin als Datenbank und öffnet sie klaglos. Erst eine Prüfung merkt, * dass der Inhalt hin ist. Wäre der Kopf mit zerstört, scheiterte schon das * Öffnen und die Prüfung wäre überflüssig. */ function beschaedigen(): void { const inhalt = readFileSync(pfad); inhalt.fill(0x7f, 1024, Math.min(inhalt.length, 16384)); writeFileSync(pfad, inhalt); } beforeEach(() => { verzeichnis = mkdtempSync(join(tmpdir(), 'wsk-heil-')); pfad = join(verzeichnis, 'lernstand.db'); }); afterEach(() => { rmSync(verzeichnis, { recursive: true, force: true }); }); describe('Heilprüfung beim Öffnen', () => { it('lässt eine gültige Datei ohne Weiteres durch', () => { gueltigeDatei(); const db = new Database(pfad); expect(() => new Lernstand(db, KATALOG)).not.toThrow(); db.close(); }); it('weist eine beschädigte Datei ab, statt sie in Betrieb zu nehmen', () => { gueltigeDatei(); beschaedigen(); const db = new Database(pfad); try { expect(() => new Lernstand(db, KATALOG)).toThrow(/beschädigt/u); } finally { db.close(); } }); it('macht den Befund für den Hauptprozess erkennbar', () => { /* Der Hauptprozess muss genau diesen Fall von allen anderen Öffnungsfehlern unterscheiden können – nur bei ihm gibt es etwas anzubieten. */ gueltigeDatei(); beschaedigen(); const db = new Database(pfad); try { new Lernstand(db, KATALOG); expect.unreachable('Die beschädigte Datei hätte abgewiesen werden müssen.'); } catch (fehler: unknown) { expect(istBeschaedigt(fehler)).toBe(true); expect((fehler as Error).message).toContain(LERNSTAND_BESCHAEDIGT); } finally { db.close(); } }); it('hält andere Öffnungsfehler auseinander', () => { expect(istBeschaedigt(new Error('Irgendetwas anderes'))).toBe(false); expect(istBeschaedigt('kein Fehlerobjekt')).toBe(false); expect(istBeschaedigt(null)).toBe(false); }); it('läuft auch gegen eine Datenbank im Arbeitsspeicher', () => { /* Die Tests der Anwendung arbeiten durchweg auf `:memory:`. Eine Heilprüfung, die dort anschlüge, legte die halbe Testfläche lahm. */ const db = new Database(':memory:'); expect(() => new Lernstand(db, KATALOG)).not.toThrow(); db.close(); }); }); describe('Beschädigte Datei beiseitelegen', () => { it('benennt um, statt zu löschen', () => { gueltigeDatei(); const vorher = readFileSync(pfad); const ziel = lernstandBeiseitelegen(pfad, new Date('2026-08-29T12:00:00.000Z')); expect(existsSync(pfad)).toBe(false); expect(existsSync(ziel)).toBe(true); /* Aus einer beschädigten Datei ist oft noch etwas zu holen – gelöscht ist sie endgültig weg. Der Inhalt muss deshalb unangetastet bleiben. */ expect(readFileSync(ziel)).toEqual(vorher); }); it('trägt einen Zeitstempel, damit ein zweiter Fall den ersten nicht überschreibt', () => { gueltigeDatei(); const erstes = lernstandBeiseitelegen(pfad, new Date('2026-08-29T12:00:00.000Z')); gueltigeDatei(); const zweites = lernstandBeiseitelegen(pfad, new Date('2026-08-30T09:30:00.000Z')); expect(erstes).not.toBe(zweites); expect(existsSync(erstes)).toBe(true); expect(existsSync(zweites)).toBe(true); }); it('nimmt Schreibprotokoll und Sperrdatei mit', () => { /* Bleiben `-wal` und `-shm` liegen, findet die frisch angelegte Datenbank ein Schreibprotokoll vor, das nicht zu ihr gehört – derselbe Fehler wie in docs/stand.md 7.8. */ gueltigeDatei(); writeFileSync(`${pfad}-wal`, 'protokoll'); writeFileSync(`${pfad}-shm`, 'sperre'); const ziel = lernstandBeiseitelegen(pfad, new Date('2026-08-29T12:00:00.000Z')); expect(existsSync(`${pfad}-wal`)).toBe(false); expect(existsSync(`${pfad}-shm`)).toBe(false); expect(existsSync(`${ziel}-wal`)).toBe(true); expect(existsSync(`${ziel}-shm`)).toBe(true); }); it('kommt ohne Nebendateien aus', () => { gueltigeDatei(); expect(() => lernstandBeiseitelegen(pfad, new Date('2026-08-29T12:00:00.000Z'))).not.toThrow(); }); it('macht den Weg für einen neuen Lernstand frei', () => { gueltigeDatei(); beschaedigen(); lernstandBeiseitelegen(pfad, new Date('2026-08-29T12:00:00.000Z')); const db = new Database(pfad); expect(() => new Lernstand(db, KATALOG)).not.toThrow(); db.close(); }); });