// @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'); }); });