waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests selbstsicherung.test.ts
| 1 | // @vitest-environment node |
| 2 | /** |
| 3 | * Die selbsttätige Sicherheitskopie beim Beenden. |
| 4 | * |
| 5 | * **Der Anlass.** Bis 0.22.0 entstand eine Sicherung ausschließlich, wenn |
| 6 | * jemand die Karte „Lernstand sichern und übertragen“ aufschlug und bediente. |
| 7 | * Die einzige selbsttätige Kopie entstand vor dem *Einspielen* einer fremden |
| 8 | * Sicherung – also genau dann, wenn ohnehin jemand mit Sicherungen hantiert. |
| 9 | * Wer die Karte nie öffnete, hatte nichts: Ein versehentliches Löschen oder |
| 10 | * eine beschädigte Datei nahmen alles mit, was über Wochen gelernt worden war. |
| 11 | * |
| 12 | * Geprüft wird an echten Dateien, nicht an Attrappen – einschließlich der |
| 13 | * Frage, ob die Kopie hinterher wirklich eine brauchbare Datenbank ist. |
| 14 | */ |
| 15 | |
| 16 | import { existsSync, mkdirSync, readdirSync, rmSync, mkdtempSync, utimesSync } from 'node:fs'; |
| 17 | import { tmpdir } from 'node:os'; |
| 18 | import { join } from 'node:path'; |
| 19 | |
| 20 | import Database from 'better-sqlite3'; |
| 21 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 22 | |
| 23 | import { Lernstand } from '../src/main/lernstand'; |
| 24 | import { |
| 25 | ABSTAND_MS, |
| 26 | istFaellig, |
| 27 | KOPIEN_BEHALTEN, |
| 28 | selbstsicherungAnlegen, |
| 29 | selbstsicherungsordner, |
| 30 | SELBSTSICHERUNG_VORSATZ, |
| 31 | } from '../src/main/selbstsicherung'; |
| 32 | import type { Katalog } from '../src/shared/katalog'; |
| 33 | |
| 34 | const KATALOG: Katalog = { |
| 35 | meta: { titel: 'Test', herausgeber: 'Test', stand: '2024-12-16', quelle: 'Test' }, |
| 36 | kapitel: [{ id: 'I', titel: 'Recht', abschnitte: [{ id: 'I.1', titel: 'Grundlagen' }] }], |
| 37 | bilder: {}, |
| 38 | fragen: [ |
| 39 | { |
| 40 | id: 'I.1-1', |
| 41 | kapitel: 'I', |
| 42 | abschnitt: 'I.1', |
| 43 | nummer: '1', |
| 44 | frage: 'Eine Frage?', |
| 45 | optionen: [{ label: 'a', text: 'Antwort', richtig: true }], |
| 46 | }, |
| 47 | ], |
| 48 | } as unknown as Katalog; |
| 49 | |
| 50 | let userData = ''; |
| 51 | let db: Database.Database; |
| 52 | |
| 53 | /** Alle Kopien im Sicherungsordner. */ |
| 54 | function kopien(): string[] { |
| 55 | const ordner = selbstsicherungsordner(userData); |
| 56 | return existsSync(ordner) |
| 57 | ? readdirSync(ordner).filter((name) => name.startsWith(SELBSTSICHERUNG_VORSATZ)) |
| 58 | : []; |
| 59 | } |
| 60 | |
| 61 | beforeEach(() => { |
| 62 | userData = mkdtempSync(join(tmpdir(), 'wsk-selbstsicherung-')); |
| 63 | db = new Database(join(userData, 'lernstand.db')); |
| 64 | new Lernstand(db, KATALOG); |
| 65 | }); |
| 66 | |
| 67 | afterEach(() => { |
| 68 | db.close(); |
| 69 | rmSync(userData, { recursive: true, force: true }); |
| 70 | }); |
| 71 | |
| 72 | describe('Fälligkeit', () => { |
| 73 | it('ist fällig, wenn noch nie gesichert wurde', () => { |
| 74 | expect(istFaellig(null, Date.now())).toBe(true); |
| 75 | }); |
| 76 | |
| 77 | it('ist nicht fällig, solange die letzte Kopie frisch ist', () => { |
| 78 | const jetzt = Date.UTC(2026, 7, 29); |
| 79 | expect(istFaellig(jetzt - ABSTAND_MS + 1000, jetzt)).toBe(false); |
| 80 | }); |
| 81 | |
| 82 | it('ist fällig, sobald der Abstand erreicht ist', () => { |
| 83 | const jetzt = Date.UTC(2026, 7, 29); |
| 84 | expect(istFaellig(jetzt - ABSTAND_MS, jetzt)).toBe(true); |
| 85 | }); |
| 86 | |
| 87 | it('gilt eine Kopie aus der Zukunft als überfällig', () => { |
| 88 | /* Ein Uhrsprung oder ein kopierter Ordner kann einen Zeitstempel aus der |
| 89 | Zukunft hinterlassen. Gälte er als frisch, unterbliebe die Sicherung |
| 90 | bis zu dem Tag, den er behauptet – unter Umständen jahrelang. */ |
| 91 | const jetzt = Date.UTC(2026, 7, 29); |
| 92 | expect(istFaellig(jetzt + ABSTAND_MS, jetzt)).toBe(true); |
| 93 | }); |
| 94 | }); |
| 95 | |
| 96 | describe('Kopie anlegen', () => { |
| 97 | it('legt beim ersten Mal eine Kopie an', () => { |
| 98 | const pfad = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); |
| 99 | |
| 100 | expect(pfad).not.toBeNull(); |
| 101 | expect(existsSync(pfad ?? '')).toBe(true); |
| 102 | expect(kopien()).toHaveLength(1); |
| 103 | }); |
| 104 | |
| 105 | it('schreibt eine Kopie, die sich wirklich öffnen lässt', () => { |
| 106 | /* Eine Sicherungsdatei, die niemand einlesen kann, ist schlimmer als |
| 107 | keine: Sie wiegt in Sicherheit, bis es darauf ankommt. */ |
| 108 | const pfad = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); |
| 109 | |
| 110 | const kopie = new Database(pfad ?? '', { readonly: true }); |
| 111 | try { |
| 112 | const zeilen = kopie.pragma('integrity_check') as { integrity_check: string }[]; |
| 113 | expect(zeilen[0]?.integrity_check).toBe('ok'); |
| 114 | const profile = kopie |
| 115 | .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil') |
| 116 | .get(); |
| 117 | expect(profile?.anzahl).toBeGreaterThan(0); |
| 118 | } finally { |
| 119 | kopie.close(); |
| 120 | } |
| 121 | }); |
| 122 | |
| 123 | it('legt keine zweite Kopie an, solange die erste frisch ist', () => { |
| 124 | const erste = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); |
| 125 | /* Über die Frische entscheidet die Änderungszeit der Datei, nicht ihr |
| 126 | Name. Ohne dieses Zurückstellen redete die Systemuhr mit: Die Kopie |
| 127 | entsteht im Augenblick des Laufs, und sobald dieser Augenblick nach |
| 128 | dem zweiten Aufruf liegt, gilt sie nach `istFaellig` als aus der |
| 129 | Zukunft und damit als überfällig. Der Fall war keine Theorie – ab dem |
| 130 | 30.08.2026, 20:00 Uhr war dieser Test rot, ohne dass sich am geprüften |
| 131 | Verhalten etwas geändert hätte. */ |
| 132 | const angelegt = new Date('2026-08-29T20:00:00'); |
| 133 | utimesSync(erste ?? '', angelegt, angelegt); |
| 134 | |
| 135 | const zweite = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-30T20:00:00')); |
| 136 | |
| 137 | expect(zweite).toBeNull(); |
| 138 | expect(kopien()).toHaveLength(1); |
| 139 | }); |
| 140 | |
| 141 | it('legt wieder eine an, sobald der Abstand überschritten ist', () => { |
| 142 | const erste = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); |
| 143 | /* Entscheidend ist die Änderungszeit der Datei, nicht der Name – sie |
| 144 | wird hier zurückgestellt, statt eine Woche zu warten. */ |
| 145 | const alt = new Date(Date.now() - ABSTAND_MS - 60_000); |
| 146 | utimesSync(erste ?? '', alt, alt); |
| 147 | |
| 148 | const zweite = selbstsicherungAnlegen(db, userData, Database, new Date()); |
| 149 | |
| 150 | expect(zweite).not.toBeNull(); |
| 151 | expect(kopien()).toHaveLength(2); |
| 152 | }); |
| 153 | |
| 154 | it('behält nur die jüngsten Kopien', () => { |
| 155 | for (let lauf = 0; lauf < KOPIEN_BEHALTEN + 2; lauf++) { |
| 156 | const pfad = selbstsicherungAnlegen(db, userData, Database, new Date(2026, 7, 1 + lauf, 20)); |
| 157 | if (pfad !== null) { |
| 158 | const alt = new Date(Date.now() - ABSTAND_MS * (10 - lauf)); |
| 159 | utimesSync(pfad, alt, alt); |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | expect(kopien().length).toBeLessThanOrEqual(KOPIEN_BEHALTEN); |
| 164 | }); |
| 165 | |
| 166 | it('rührt fremde Dateien im Ordner nicht an', () => { |
| 167 | /* Aufgeräumt wird ausschließlich das eigene Muster. Ein Ordner, in dem |
| 168 | ein Programm nach Gutdünken löscht, ist kein Ort für Sicherungen. */ |
| 169 | const ordner = selbstsicherungsordner(userData); |
| 170 | mkdirSync(ordner, { recursive: true }); |
| 171 | const fremd = join(ordner, 'Wichtige-eigene-Datei.wsklernstand'); |
| 172 | new Database(fremd).close(); |
| 173 | |
| 174 | for (let lauf = 0; lauf < KOPIEN_BEHALTEN + 2; lauf++) { |
| 175 | const pfad = selbstsicherungAnlegen(db, userData, Database, new Date(2026, 7, 1 + lauf, 20)); |
| 176 | if (pfad !== null) { |
| 177 | const alt = new Date(Date.now() - ABSTAND_MS * (10 - lauf)); |
| 178 | utimesSync(pfad, alt, alt); |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | expect(existsSync(fremd)).toBe(true); |
| 183 | }); |
| 184 | |
| 185 | it('legt den Ordner selbst an', () => { |
| 186 | expect(existsSync(selbstsicherungsordner(userData))).toBe(false); |
| 187 | |
| 188 | selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); |
| 189 | |
| 190 | expect(existsSync(selbstsicherungsordner(userData))).toBe(true); |
| 191 | }); |
| 192 | }); |