// @vitest-environment node /** * Die selbsttätige Sicherheitskopie beim Beenden. * * **Der Anlass.** Bis 0.22.0 entstand eine Sicherung ausschließlich, wenn * jemand die Karte „Lernstand sichern und übertragen“ aufschlug und bediente. * Die einzige selbsttätige Kopie entstand vor dem *Einspielen* einer fremden * Sicherung – also genau dann, wenn ohnehin jemand mit Sicherungen hantiert. * Wer die Karte nie öffnete, hatte nichts: Ein versehentliches Löschen oder * eine beschädigte Datei nahmen alles mit, was über Wochen gelernt worden war. * * Geprüft wird an echten Dateien, nicht an Attrappen – einschließlich der * Frage, ob die Kopie hinterher wirklich eine brauchbare Datenbank ist. */ import { existsSync, mkdirSync, readdirSync, rmSync, mkdtempSync, utimesSync } 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 } from '../src/main/lernstand'; import { ABSTAND_MS, istFaellig, KOPIEN_BEHALTEN, selbstsicherungAnlegen, selbstsicherungsordner, SELBSTSICHERUNG_VORSATZ, } from '../src/main/selbstsicherung'; 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 userData = ''; let db: Database.Database; /** Alle Kopien im Sicherungsordner. */ function kopien(): string[] { const ordner = selbstsicherungsordner(userData); return existsSync(ordner) ? readdirSync(ordner).filter((name) => name.startsWith(SELBSTSICHERUNG_VORSATZ)) : []; } beforeEach(() => { userData = mkdtempSync(join(tmpdir(), 'wsk-selbstsicherung-')); db = new Database(join(userData, 'lernstand.db')); new Lernstand(db, KATALOG); }); afterEach(() => { db.close(); rmSync(userData, { recursive: true, force: true }); }); describe('Fälligkeit', () => { it('ist fällig, wenn noch nie gesichert wurde', () => { expect(istFaellig(null, Date.now())).toBe(true); }); it('ist nicht fällig, solange die letzte Kopie frisch ist', () => { const jetzt = Date.UTC(2026, 7, 29); expect(istFaellig(jetzt - ABSTAND_MS + 1000, jetzt)).toBe(false); }); it('ist fällig, sobald der Abstand erreicht ist', () => { const jetzt = Date.UTC(2026, 7, 29); expect(istFaellig(jetzt - ABSTAND_MS, jetzt)).toBe(true); }); it('gilt eine Kopie aus der Zukunft als überfällig', () => { /* Ein Uhrsprung oder ein kopierter Ordner kann einen Zeitstempel aus der Zukunft hinterlassen. Gälte er als frisch, unterbliebe die Sicherung bis zu dem Tag, den er behauptet – unter Umständen jahrelang. */ const jetzt = Date.UTC(2026, 7, 29); expect(istFaellig(jetzt + ABSTAND_MS, jetzt)).toBe(true); }); }); describe('Kopie anlegen', () => { it('legt beim ersten Mal eine Kopie an', () => { const pfad = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); expect(pfad).not.toBeNull(); expect(existsSync(pfad ?? '')).toBe(true); expect(kopien()).toHaveLength(1); }); it('schreibt eine Kopie, die sich wirklich öffnen lässt', () => { /* Eine Sicherungsdatei, die niemand einlesen kann, ist schlimmer als keine: Sie wiegt in Sicherheit, bis es darauf ankommt. */ const pfad = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); const kopie = new Database(pfad ?? '', { readonly: true }); try { const zeilen = kopie.pragma('integrity_check') as { integrity_check: string }[]; expect(zeilen[0]?.integrity_check).toBe('ok'); const profile = kopie .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil') .get(); expect(profile?.anzahl).toBeGreaterThan(0); } finally { kopie.close(); } }); it('legt keine zweite Kopie an, solange die erste frisch ist', () => { const erste = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); /* Über die Frische entscheidet die Änderungszeit der Datei, nicht ihr Name. Ohne dieses Zurückstellen redete die Systemuhr mit: Die Kopie entsteht im Augenblick des Laufs, und sobald dieser Augenblick nach dem zweiten Aufruf liegt, gilt sie nach `istFaellig` als aus der Zukunft und damit als überfällig. Der Fall war keine Theorie – ab dem 30.08.2026, 20:00 Uhr war dieser Test rot, ohne dass sich am geprüften Verhalten etwas geändert hätte. */ const angelegt = new Date('2026-08-29T20:00:00'); utimesSync(erste ?? '', angelegt, angelegt); const zweite = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-30T20:00:00')); expect(zweite).toBeNull(); expect(kopien()).toHaveLength(1); }); it('legt wieder eine an, sobald der Abstand überschritten ist', () => { const erste = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); /* Entscheidend ist die Änderungszeit der Datei, nicht der Name – sie wird hier zurückgestellt, statt eine Woche zu warten. */ const alt = new Date(Date.now() - ABSTAND_MS - 60_000); utimesSync(erste ?? '', alt, alt); const zweite = selbstsicherungAnlegen(db, userData, Database, new Date()); expect(zweite).not.toBeNull(); expect(kopien()).toHaveLength(2); }); it('behält nur die jüngsten Kopien', () => { for (let lauf = 0; lauf < KOPIEN_BEHALTEN + 2; lauf++) { const pfad = selbstsicherungAnlegen(db, userData, Database, new Date(2026, 7, 1 + lauf, 20)); if (pfad !== null) { const alt = new Date(Date.now() - ABSTAND_MS * (10 - lauf)); utimesSync(pfad, alt, alt); } } expect(kopien().length).toBeLessThanOrEqual(KOPIEN_BEHALTEN); }); it('rührt fremde Dateien im Ordner nicht an', () => { /* Aufgeräumt wird ausschließlich das eigene Muster. Ein Ordner, in dem ein Programm nach Gutdünken löscht, ist kein Ort für Sicherungen. */ const ordner = selbstsicherungsordner(userData); mkdirSync(ordner, { recursive: true }); const fremd = join(ordner, 'Wichtige-eigene-Datei.wsklernstand'); new Database(fremd).close(); for (let lauf = 0; lauf < KOPIEN_BEHALTEN + 2; lauf++) { const pfad = selbstsicherungAnlegen(db, userData, Database, new Date(2026, 7, 1 + lauf, 20)); if (pfad !== null) { const alt = new Date(Date.now() - ABSTAND_MS * (10 - lauf)); utimesSync(pfad, alt, alt); } } expect(existsSync(fremd)).toBe(true); }); it('legt den Ordner selbst an', () => { expect(existsSync(selbstsicherungsordner(userData))).toBe(false); selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); expect(existsSync(selbstsicherungsordner(userData))).toBe(true); }); });