// @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, readFileSync, mkdirSync, readdirSync, rmSync, mkdtempSync, utimesSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; 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, kopieVorDemVerwerfen, VOR_DEM_VERWERFEN, 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); }); }); describe('Kopie vor dem Verwerfen', () => { /* Die Wache, die gefehlt hat. Beide Einspielwege legen vorher eine Sicherheitskopie an – das Einspielen einer fremden Sicherung und das Übernehmen eines Profils. Die beiden Wege, die tatsächlich etwas vernichten, taten es nicht: Wer „Neu anfangen“ drückte oder ein Profil löschte, war den Lernstand los, und die selbsttätige Wochenkopie war je nach Tag bis zu sieben Tage alt. */ function kopien(vorsatz: string): string[] { const ordner = selbstsicherungsordner(userData); return existsSync(ordner) ? readdirSync(ordner).filter((name) => name.startsWith(vorsatz)) : []; } it('legt eine Kopie an, auch wenn eine Wochenkopie eben erst entstand', () => { /* Der Unterschied zur Wochenkopie in einem Fall: Sie ist ungedrosselt. Wäre sie es nicht, fiele sie genau dann aus, wenn sie gebraucht wird. */ selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); const pfad = kopieVorDemVerwerfen(db, userData, Database, new Date('2026-08-29T20:00:05')); expect(existsSync(pfad)).toBe(true); expect(kopien(VOR_DEM_VERWERFEN)).toHaveLength(1); }); it('schreibt eine Kopie, die sich wirklich öffnen lässt', () => { const pfad = kopieVorDemVerwerfen(db, userData, Database, new Date('2026-08-29T20:00:00')); const kopie = new Database(pfad, { readonly: true }); try { expect( (kopie.pragma('integrity_check') as { integrity_check: string }[])[0]?.integrity_check, ).toBe('ok'); expect( kopie.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil').get() ?.anzahl, ).toBeGreaterThan(0); } finally { kopie.close(); } }); it('räumt nur die eigenen Kopien ab, nie die der Wochensicherung', () => { selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); for (let lauf = 0; lauf < KOPIEN_BEHALTEN + 2; lauf += 1) { const pfad = kopieVorDemVerwerfen( db, userData, Database, new Date(2026, 7, 1 + lauf, 20, 0, 0), ); const alt = new Date(Date.now() - 60_000 * (10 - lauf)); utimesSync(pfad, alt, alt); } expect(kopien(VOR_DEM_VERWERFEN).length).toBeLessThanOrEqual(KOPIEN_BEHALTEN); /* Die Wochenkopie ist unangetastet – zwei Kontingente, zwei Wege. */ expect(kopien(SELBSTSICHERUNG_VORSATZ)).toHaveLength(1); }); it('weicht auf einen zweiten Namen aus, statt zu überschreiben', () => { const jetzt = new Date('2026-08-29T20:00:00'); const erst = kopieVorDemVerwerfen(db, userData, Database, jetzt); const zweit = kopieVorDemVerwerfen(db, userData, Database, jetzt); expect(zweit).not.toBe(erst); expect(existsSync(erst)).toBe(true); expect(existsSync(zweit)).toBe(true); }); it('wirft, wenn sich nichts schreiben lässt – dann wird auch nichts verworfen', () => { /* Der Unterschied zur Wochenkopie im zweiten Fall: Sie schweigt bei einem Fehlschlag, diese hier nicht. Vor einem Schritt, der Daten vernichtet, ist Schweigen die falsche Antwort. */ db.close(); expect(() => kopieVorDemVerwerfen(db, userData, Database, new Date())).toThrow(); }); }); describe('Die Verdrahtung der Kopie vor dem Verwerfen', () => { /* Die Funktion allein nützt nichts – sie muss an den beiden zerstörenden Wegen auch gerufen werden, und zwar **vor** ihnen. Für die IPC-Schicht gibt es in diesem Projekt keinen Prüfstand; gelesen wird deshalb der Quelltext, so wie es `sicherheit.test.ts` mit dem Sicherheitsmodell tut. Eine Zusicherung an der Quelle ist schwächer als eine am laufenden Programm – aber ungleich stärker als gar keine. */ const ipcQuelle = readFileSync( join(fileURLToPath(new URL('..', import.meta.url)), 'src', 'main', 'ipc.ts'), 'utf8', ); it('ruft die Kopie im Kanal „profil:loeschen“, bevor gelöscht wird', () => { const block = /'profil:loeschen'[\s\S]*?\n {2}\}\);/u.exec(ipcQuelle)?.[0] ?? ''; expect(block).toContain('kopieVorVerwerfen()'); expect(block.indexOf('kopieVorVerwerfen()')).toBeLessThan(block.indexOf('profilLoeschen(')); }); it('ruft die Kopie im Kanal „lernen:zuruecksetzen“, bevor zurückgesetzt wird', () => { const block = /'lernen:zuruecksetzen'[\s\S]*?\n {2}\}\);/u.exec(ipcQuelle)?.[0] ?? ''; expect(block).toContain('kopieVorVerwerfen()'); expect(block.indexOf('kopieVorVerwerfen()')).toBeLessThan(block.indexOf('zuruecksetzen(')); }); it('fängt den Fehlschlag nicht ab – sonst wäre die Kopie eine Zugabe', () => { /* Beim Beenden ist die Kopie eine Zugabe und ihr Fehlschlag wird geschluckt. Hier darf er das nicht: `behandeln` reicht den Fehler an die Oberfläche durch, und der zerstörende Schritt unterbleibt. */ const funktion = /function kopieVorVerwerfen\(\)[\s\S]*?\n\}/u.exec(ipcQuelle)?.[0] ?? ''; expect(funktion.length).toBeGreaterThan(0); expect(funktion).not.toContain('catch'); }); }); describe('Die Kopie beim Öffnen', () => { /* Die Wache, die gefehlt hat. Die Wochenkopie entstand ausschließlich beim geordneten Beenden. Stürzt die Anwendung ab, fällt der Strom aus oder beendet jemand den Prozess über den Taskmanager, entstand sie nie – und genau dann ist eine Kopie am ehesten etwas wert. Wer eine Woche lang jeden Tag abstürzte, hatte am Ende keine einzige. Für die IPC-Schicht gibt es keinen Prüfstand; gelesen wird deshalb der Quelltext, so wie bei der Kopie vor dem Verwerfen. */ const quelle = readFileSync( join(fileURLToPath(new URL('..', import.meta.url)), 'src', 'main', 'ipc.ts'), 'utf8', ); it('ruft die selbsttätige Kopie auch beim Öffnen des Lernstands', () => { const block = /function lernstand\(\): Lernstand \{[\s\S]*?\n\}/u.exec(quelle)?.[0] ?? ''; expect(block).toContain('selbstsicherungBeimOeffnen('); }); it('erwägt sie höchstens einmal je Programmlauf', () => { /* `lernstand()` läuft bei jedem IPC-Kanal. Ohne Merker liefe die Fälligkeitsprüfung hundertfach – sie schreibt zwar nichts, liest aber jedes Mal das Verzeichnis. */ const block = /function selbstsicherungBeimOeffnen\([\s\S]*?\n\}/u.exec(quelle)?.[0] ?? ''; expect(block).toContain('kopieBeimOeffnenErwogen'); expect(block).toContain('return;'); }); it('schluckt ihren Fehlschlag – sie ist eine Zugabe, keine Bedingung', () => { /* Anders als die Kopie vor dem Verwerfen: Dort steht ein Schritt dahinter, der Daten vernichtet, hier nicht. */ const block = /function selbstsicherungBeimOeffnen\([\s\S]*?\n\}/u.exec(quelle)?.[0] ?? ''; expect(block).toContain('catch'); }); });