/** * Sicherung des Lernstands. * * Alles hier läuft gegen echte SQLite-Dateien auf der Platte, nicht gegen * Attrappen: Die Fallen dieses Merkmals sind Fallen von SQLite selbst, und * eine Attrappe hätte keine davon. */ import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, 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 { SCHEMA_VERSION } from '../src/main/schema'; import { ANWENDUNGSKENNUNG, aufraeumen, dateiPruefen, kennzahlenLesen, nebendateienAufraeumen, sicherungsDateiname, sicherungSchreiben, } from '../src/main/sicherung'; let ordner: string; let offene: Database.Database[]; /** Ein Lernstand, wie ihn die Anwendung anlegt – im WAL-Modus. */ function lernstandAnlegen(pfad: string, antworten = 5): Database.Database { const db = new Database(pfad); offene.push(db); db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE schema_version (version INTEGER PRIMARY KEY); CREATE TABLE profil (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL); CREATE TABLE frage_stand ( profil_id INTEGER NOT NULL REFERENCES profil(id), frage_id TEXT NOT NULL, gemerkt INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (profil_id, frage_id) ); CREATE TABLE antwort_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, profil_id INTEGER NOT NULL REFERENCES profil(id), zeitpunkt TEXT NOT NULL ); `); for (let version = 1; version <= SCHEMA_VERSION; version++) { db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(version); } db.prepare('INSERT INTO profil (name) VALUES (?)').run('Standard'); const einfuegen = db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt) VALUES (1, ?)'); const alle = db.transaction(() => { for (let i = 0; i < antworten; i++) { einfuegen.run(`2026-08-${String(10 + (i % 10)).padStart(2, '0')}T09:00:00.000Z`); } }); alle(); db.prepare('INSERT INTO frage_stand (profil_id, frage_id, gemerkt) VALUES (1, ?, 1)').run( 'I.1-01', ); return db; } beforeEach(() => { ordner = mkdtempSync(join(tmpdir(), 'wsk-sicherung-')); offene = []; }); afterEach(() => { for (const db of offene) { try { db.close(); } catch { /* Schon zu. */ } } rmSync(ordner, { recursive: true, force: true }); }); describe('Warum eine bloße Dateikopie nicht genügt', () => { it('verliert bei offener Verbindung im WAL-Modus alles – und meldet trotzdem „ok“', () => { /* Der Messwert, der die ganze Umsetzung begründet. Er steht hier als Test, damit niemand später auf die naheliegende Abkürzung verfällt: Die Kopie ist nicht bloß älter, sie ist LEER – bei tadellosem integrity_check. */ const quelle = join(ordner, 'lernstand.db'); lernstandAnlegen(quelle, 200); const kopie = join(ordner, 'nur-hauptdatei.db'); copyFileSync(quelle, kopie); const db = new Database(kopie, { readonly: true }); offene.push(db); const zeilen = db.pragma('integrity_check') as { integrity_check: string }[]; expect(zeilen[0]?.integrity_check).toBe('ok'); const tabellen = db .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'") .all(); expect(tabellen).toHaveLength(0); }); }); describe('Eine Sicherung schreiben', () => { it('nimmt alles mit, obwohl die Verbindung offen bleibt', () => { const quelle = join(ordner, 'lernstand.db'); const db = lernstandAnlegen(quelle, 200); const ziel = join(ordner, 'sicherung.wsklernstand'); const bytes = sicherungSchreiben(db, ziel, Database); expect(bytes).toBeGreaterThan(0); const gelesen = new Database(ziel, { readonly: true }); offene.push(gelesen); expect( gelesen.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM antwort_log').get() ?.anzahl, ).toBe(200); }); it('hinterlässt genau eine Datei, auch nachdem sie gelesen wurde', () => { /* Der Grund gegen `db.backup()`: Dessen Ergebnis steht im WAL-Modus, und wer es auch nur ansieht, erzeugt daneben `-wal` und `-shm`, die nach dem Schließen liegen bleiben. Eine Sicherung, von der man zwei Dateien vergisst, ist genau der Fehler, den sie verhüten soll. */ const db = lernstandAnlegen(join(ordner, 'lernstand.db')); const ziel = join(ordner, 'sicherung.wsklernstand'); sicherungSchreiben(db, ziel, Database); const gelesen = new Database(ziel, { readonly: true }); expect((gelesen.pragma('journal_mode') as { journal_mode: string }[])[0]?.journal_mode).toBe( 'delete', ); gelesen.close(); expect(existsSync(`${ziel}-wal`)).toBe(false); expect(existsSync(`${ziel}-shm`)).toBe(false); }); it('trägt die Kennung dieser Anwendung und die Schemafassung im Kopf', () => { const db = lernstandAnlegen(join(ordner, 'lernstand.db')); const ziel = join(ordner, 'sicherung.wsklernstand'); sicherungSchreiben(db, ziel, Database); const gelesen = new Database(ziel, { readonly: true }); offene.push(gelesen); expect( (gelesen.pragma('application_id') as { application_id: number }[])[0]?.application_id, ).toBe(ANWENDUNGSKENNUNG); expect((gelesen.pragma('user_version') as { user_version: number }[])[0]?.user_version).toBe( SCHEMA_VERSION, ); }); it('lässt keine Teildatei zurück', () => { /* Erst nach `.teil`, dann umbenennen: Ein Abbruch mitten im Schreiben hinterlässt damit nie eine halbe Datei unter dem richtigen Namen. */ const db = lernstandAnlegen(join(ordner, 'lernstand.db')); const ziel = join(ordner, 'sicherung.wsklernstand'); sicherungSchreiben(db, ziel, Database); expect(existsSync(`${ziel}.teil`)).toBe(false); }); it('nimmt einen Pfad mit Anführungszeichen im Namen', () => { /* Der Zielpfad wird gebunden und nicht in die Anweisung eingesetzt. Eingesetzt zerlegte ein Anführungszeichen die Anweisung. */ const db = lernstandAnlegen(join(ordner, 'lernstand.db')); const ziel = join(ordner, "Olafs 'Sicherung'.wsklernstand"); expect(() => sicherungSchreiben(db, ziel, Database)).not.toThrow(); expect(existsSync(ziel)).toBe(true); }); }); describe('Die Prüfkette', () => { it('nimmt eine echte Sicherung an und beziffert sie', () => { const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 42); const ziel = join(ordner, 'sicherung.wsklernstand'); sicherungSchreiben(db, ziel, Database); const befund = dateiPruefen(ziel, Database); expect(befund.art).toBe('brauchbar'); if (befund.art !== 'brauchbar') { return; } expect(befund.kennzahlen.profilnamen).toEqual(['Standard']); expect(befund.kennzahlen.antworten).toBe(42); expect(befund.kennzahlen.gemerkt).toBe(1); expect(befund.kennzahlen.schemafassung).toBe(SCHEMA_VERSION); expect(befund.kennzahlen.letzteAntwort).not.toBeNull(); }); it('weist eine leere Datei ab – der gefährlichste Fall überhaupt', () => { /* Eine Datei von null Byte besteht `integrity_check` mit „ok“ und hat null Tabellen. Ohne diese Abweisung durchliefe sie die vollständige Migrationskette und stünde als tadelloser, LEERER Lernstand da. Das Einspielen meldete Erfolg, und die Arbeit von Wochen wäre fort. */ const leer = join(ordner, 'leer.wsklernstand'); writeFileSync(leer, ''); const befund = dateiPruefen(leer, Database); expect(befund.art).toBe('abgelehnt'); expect(befund.art === 'abgelehnt' && befund.grund).toContain('Es wurde nichts verändert.'); }); it('weist eine gültige, aber fremde SQLite-Datenbank ab', () => { /* Auch eine Datenbank mit null Tabellen ist syntaktisch tadellos. Was fehlt, sind die Tabellen dieser Anwendung. */ const fremd = join(ordner, 'fremd.db'); const db = new Database(fremd); db.exec('CREATE TABLE adressen (id INTEGER PRIMARY KEY, name TEXT)'); db.close(); const befund = dateiPruefen(fremd, Database); expect(befund.art).toBe('abgelehnt'); expect(befund.art === 'abgelehnt' && befund.grund).toContain('kein Lernstand dieser Anwendung'); }); it('weist ein umbenanntes Bild ab, ohne eine englische Datenbankmeldung', () => { /* Eine Textdatei lässt sich readonly ÖFFNEN – erst die erste Abfrage wirft SQLITE_NOTADB. Deshalb wird der Dateikopf vorher gelesen. */ const foto = join(ordner, 'urlaub.wsklernstand'); writeFileSync(foto, 'x'.repeat(2048)); const befund = dateiPruefen(foto, Database); expect(befund.art).toBe('abgelehnt'); expect(befund.art === 'abgelehnt' && befund.grund).toContain('keine Datenbank'); expect(befund.art === 'abgelehnt' && befund.grund).not.toMatch(/SQLITE_|not a database/u); }); it('weist eine abgeschnittene Datei ab', () => { const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 500); const ziel = join(ordner, 'sicherung.wsklernstand'); sicherungSchreiben(db, ziel, Database); const halb = join(ordner, 'halb.wsklernstand'); const { size } = statSync(ziel); const inhalt = readFileSync(ziel); writeFileSync(halb, inhalt.subarray(0, Math.floor(size * 0.4))); const befund = dateiPruefen(halb, Database); expect(befund.art).toBe('abgelehnt'); }); it('weist eine Sicherung aus einer neueren Programmfassung ab', () => { /* Vorwärts migrieren kann die Anwendung, rückwärts nicht. Eine neuere Sicherung stillschweigend anzunehmen hiesse, Spalten zu verlieren. */ const db = lernstandAnlegen(join(ordner, 'lernstand.db')); const ziel = join(ordner, 'sicherung.wsklernstand'); sicherungSchreiben(db, ziel, Database); /* Die Fassung wird erst NACH dem Schreiben hochgesetzt: Diese Anwendung kann eine solche Datei gar nicht erzeugen – sie entsteht auf einem Rechner mit neuerem Programm. */ const zukunft = join(ordner, 'zukunft.wsklernstand'); copyFileSync(ziel, zukunft); const neuer = new Database(zukunft); neuer.prepare('INSERT INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION + 1); neuer.close(); const befund = dateiPruefen(zukunft, Database); expect(befund.art).toBe('abgelehnt'); expect(befund.art === 'abgelehnt' && befund.grund).toContain('neueren Fassung'); }); it('nimmt eine ältere Sicherung an – die Migrationskette zieht sie hoch', () => { /* `docs/stand.md` sagte „bei gleicher Schemafassung". Das ist zu streng: Eine alte Sicherung einzuspielen ist byteweise dieselbe Handlung wie ein alter Lernstand nach einem Programmupdate, und diesen Weg geht jeder Nutzer nach jeder Aktualisierung. */ const db = lernstandAnlegen(join(ordner, 'lernstand.db')); db.prepare('DELETE FROM schema_version WHERE version > 3').run(); const ziel = join(ordner, 'alt.wsklernstand'); sicherungSchreiben(db, ziel, Database); const befund = dateiPruefen(ziel, Database); expect(befund.art).toBe('brauchbar'); expect(befund.art === 'brauchbar' && befund.kennzahlen.schemafassung).toBe(3); }); it('lässt nach jeder Abweisung die Datei wieder los', () => { /* Bleibt die Verbindung offen, ist die Datei unter Windows gesperrt und liesse sich weder löschen noch umbenennen. */ const fremd = join(ordner, 'fremd.db'); const db = new Database(fremd); db.exec('CREATE TABLE adressen (id INTEGER PRIMARY KEY)'); db.close(); expect(dateiPruefen(fremd, Database).art).toBe('abgelehnt'); expect(() => { rmSync(fremd); }).not.toThrow(); }); }); describe('Der Dateiname', () => { it('trägt Datum und Uhrzeit auf die Sekunde genau', () => { /* Auf die Sekunde, damit zwei Sicherungen in derselben Minute einander nicht überschreiben – die selbsttätige Sicherheitskopie vor dem Einspielen fragt niemanden. */ const name = sicherungsDateiname(new Date(2026, 7, 22, 14, 32, 7)); expect(name).toBe('Waffensachkunde-Lernstand-2026-08-22-143207.wsklernstand'); }); it('nennt keinen Profilnamen', () => { /* Gesichert wird die ganze Datenbank mit allen Profilen. Ein Name darin wäre gelogen und verleitete dazu, die Datei später für die Sicherung eines einzelnen Profils zu halten. */ expect(sicherungsDateiname(new Date())).not.toMatch(/Standard|Olaf/u); }); }); describe('Aufräumen und Kennzahlen', () => { /* Nachgemessen: Wird eine Datenbankdatei nur LESEND geöffnet, bleiben danach ein 0 Byte grosses `-wal` und ein 32 KiB grosses `-shm` liegen. Im heutigen Ablauf ist das harmlos – der nächste Durchgang liest die richtige Datei. Läge dort aber je ein GEFÜLLTES `-wal`, bekäme SQLite den Inhalt der vorigen Datenbank untergeschoben, bei grünem `integrity_check`; nachgestellt: erwartet 900 Zeilen, gelesen 5000 aus der anderen Datei. Deshalb wird es entfernt, bevor der erste schreibende Zugriff auf eine Arbeitskopie dazukommt. */ it('nimmt beim Aufräumen die Nebendateien mit', () => { const pfad = join(ordner, 'stand.db'); /* Die schreibende Verbindung muss zu sein, sonst hält Windows die Nebendateien und der Test misst das Betriebssystem statt den Code. */ lernstandAnlegen(pfad).close(); const lesend = new Database(pfad, { readonly: true }); offene.push(lesend); lesend.prepare('SELECT COUNT(*) AS n FROM antwort_log').get(); lesend.close(); expect(existsSync(`${pfad}-shm`)).toBe(true); aufraeumen(pfad); expect(existsSync(pfad)).toBe(false); expect(existsSync(`${pfad}-wal`)).toBe(false); expect(existsSync(`${pfad}-shm`)).toBe(false); }); it('stört sich nicht daran, wenn nichts da ist', () => { expect(() => { aufraeumen(join(ordner, 'gibt-es-nicht.db')); }).not.toThrow(); }); /* Beim Anlegen einer Sicherung über eine vorhandene darf nur das Schreibprotokoll fallen, nie die Datei selbst: Bis 0.24.1 wurde sie vorher gelöscht, und ein Fehlschlag danach nahm beide Fassungen mit. */ it('räumt nur die Nebendateien weg und lässt die Sicherung stehen', () => { const pfad = join(ordner, 'sicherung.wsklernstand'); writeFileSync(pfad, 'ALTE SICHERUNG', 'utf8'); writeFileSync(`${pfad}-wal`, 'protokoll', 'utf8'); writeFileSync(`${pfad}-shm`, 'gemeinsam', 'utf8'); nebendateienAufraeumen(pfad); expect(readFileSync(pfad, 'utf8')).toBe('ALTE SICHERUNG'); expect(existsSync(`${pfad}-wal`)).toBe(false); expect(existsSync(`${pfad}-shm`)).toBe(false); }); /* Der Grund, warum das Vorab-Löschen entbehrlich ist: `sicherungSchreiben` schreibt nach `.teil` und schiebt die fertige Datei mit `renameSync` an ihren Platz. Das ersetzt ein vorhandenes Ziel unteilbar. */ it('ersetzt eine vorhandene Zieldatei, ohne dass sie vorher gelöscht wird', () => { const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 30); const ziel = join(ordner, 'sicherung.wsklernstand'); writeFileSync(ziel, 'ALTE SICHERUNG', 'utf8'); const bytes = sicherungSchreiben(db, ziel, Database); expect(bytes).toBeGreaterThan(0); expect(dateiPruefen(ziel, Database).art).toBe('brauchbar'); }); it('lässt die vorhandene Zieldatei unangetastet, wenn das Schreiben scheitert', () => { /* Eine Quelle ohne die Pflichttabellen: Die Gegenprobe in `sicherungSchreiben` lehnt das Ergebnis ab und wirft. */ const untauglich = new Database(join(ordner, 'untauglich.db')); offene.push(untauglich); untauglich.exec('CREATE TABLE irgendwas (a INTEGER)'); const ziel = join(ordner, 'sicherung.wsklernstand'); writeFileSync(ziel, 'ALTE SICHERUNG', 'utf8'); expect(() => sicherungSchreiben(untauglich, ziel, Database)).toThrow(); expect(readFileSync(ziel, 'utf8')).toBe('ALTE SICHERUNG'); expect(existsSync(`${ziel}.teil`)).toBe(false); }); /* `antwort_log` enthält seit Schemafassung 8 auch Zeilen mit `nur_historie = 1`: Fragen eines abgelaufenen Prüfungsbogens, die nie aufgeschlagen wurden. Sie gehören in die Historie, aber nicht in eine Zahl, über die jemand einen nicht rücknehmbaren Schritt freigibt. */ it('zählt nie aufgeschlagene Prüfungsfragen nicht als Antworten', () => { const pfad = join(ordner, 'mit-historie.db'); const db = lernstandAnlegen(pfad, 5); db.exec('ALTER TABLE antwort_log ADD COLUMN nur_historie INTEGER NOT NULL DEFAULT 0'); db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt, nur_historie) VALUES (1, ?, 1)').run( '2026-08-20T09:00:00.000Z', ); db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt, nur_historie) VALUES (1, ?, 1)').run( '2026-08-21T09:00:00.000Z', ); const kennzahlen = kennzahlenLesen(db, SCHEMA_VERSION, new Set(['profil', 'antwort_log'])); expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 7 }); expect(kennzahlen.antworten).toBe(5); }); it('zählt bei älteren Sicherungen ohne die Spalte weiterhin alles', () => { /* Rückwirkend liesse sich nicht ermitteln, welche Zeile nie gestellt wurde. Zu raten wäre schlechter, als es stehen zu lassen. */ const pfad = join(ordner, 'alt.db'); const db = lernstandAnlegen(pfad, 4); const kennzahlen = kennzahlenLesen(db, 7, new Set(['profil', 'antwort_log'])); expect(kennzahlen.antworten).toBe(4); }); });