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 { |
| 17 | existsSync, |
| 18 | readFileSync, |
| 19 | mkdirSync, |
| 20 | readdirSync, |
| 21 | rmSync, |
| 22 | mkdtempSync, |
| 23 | utimesSync, |
| 24 | } from 'node:fs'; |
| 25 | import { tmpdir } from 'node:os'; |
| 26 | import { fileURLToPath } from 'node:url'; |
| 27 | import { join } from 'node:path'; |
| 28 | |
| 29 | import Database from 'better-sqlite3'; |
| 30 | import { afterEach, beforeEach, describe, expect, it } from 'vitest'; |
| 31 | |
| 32 | import { Lernstand } from '../src/main/lernstand'; |
| 33 | import { |
| 34 | ABSTAND_MS, |
| 35 | kopieVorDemVerwerfen, |
| 36 | VOR_DEM_VERWERFEN, |
| 37 | istFaellig, |
| 38 | KOPIEN_BEHALTEN, |
| 39 | selbstsicherungAnlegen, |
| 40 | selbstsicherungsordner, |
| 41 | SELBSTSICHERUNG_VORSATZ, |
| 42 | } from '../src/main/selbstsicherung'; |
| 43 | import type { Katalog } from '../src/shared/katalog'; |
| 44 | |
| 45 | const KATALOG: Katalog = { |
| 46 | meta: { titel: 'Test', herausgeber: 'Test', stand: '2024-12-16', quelle: 'Test' }, |
| 47 | kapitel: [{ id: 'I', titel: 'Recht', abschnitte: [{ id: 'I.1', titel: 'Grundlagen' }] }], |
| 48 | bilder: {}, |
| 49 | fragen: [ |
| 50 | { |
| 51 | id: 'I.1-1', |
| 52 | kapitel: 'I', |
| 53 | abschnitt: 'I.1', |
| 54 | nummer: '1', |
| 55 | frage: 'Eine Frage?', |
| 56 | optionen: [{ label: 'a', text: 'Antwort', richtig: true }], |
| 57 | }, |
| 58 | ], |
| 59 | } as unknown as Katalog; |
| 60 | |
| 61 | let userData = ''; |
| 62 | let db: Database.Database; |
| 63 | |
| 64 | /** Alle Kopien im Sicherungsordner. */ |
| 65 | function kopien(): string[] { |
| 66 | const ordner = selbstsicherungsordner(userData); |
| 67 | return existsSync(ordner) |
| 68 | ? readdirSync(ordner).filter((name) => name.startsWith(SELBSTSICHERUNG_VORSATZ)) |
| 69 | : []; |
| 70 | } |
| 71 | |
| 72 | beforeEach(() => { |
| 73 | userData = mkdtempSync(join(tmpdir(), 'wsk-selbstsicherung-')); |
| 74 | db = new Database(join(userData, 'lernstand.db')); |
| 75 | new Lernstand(db, KATALOG); |
| 76 | }); |
| 77 | |
| 78 | afterEach(() => { |
| 79 | db.close(); |
| 80 | rmSync(userData, { recursive: true, force: true }); |
| 81 | }); |
| 82 | |
| 83 | describe('Fälligkeit', () => { |
| 84 | it('ist fällig, wenn noch nie gesichert wurde', () => { |
| 85 | expect(istFaellig(null, Date.now())).toBe(true); |
| 86 | }); |
| 87 | |
| 88 | it('ist nicht fällig, solange die letzte Kopie frisch ist', () => { |
| 89 | const jetzt = Date.UTC(2026, 7, 29); |
| 90 | expect(istFaellig(jetzt - ABSTAND_MS + 1000, jetzt)).toBe(false); |
| 91 | }); |
| 92 | |
| 93 | it('ist fällig, sobald der Abstand erreicht ist', () => { |
| 94 | const jetzt = Date.UTC(2026, 7, 29); |
| 95 | expect(istFaellig(jetzt - ABSTAND_MS, jetzt)).toBe(true); |
| 96 | }); |
| 97 | |
| 98 | it('gilt eine Kopie aus der Zukunft als überfällig', () => { |
| 99 | /* Ein Uhrsprung oder ein kopierter Ordner kann einen Zeitstempel aus der |
| 100 | Zukunft hinterlassen. Gälte er als frisch, unterbliebe die Sicherung |
| 101 | bis zu dem Tag, den er behauptet – unter Umständen jahrelang. */ |
| 102 | const jetzt = Date.UTC(2026, 7, 29); |
| 103 | expect(istFaellig(jetzt + ABSTAND_MS, jetzt)).toBe(true); |
| 104 | }); |
| 105 | }); |
| 106 | |
| 107 | describe('Kopie anlegen', () => { |
| 108 | it('legt beim ersten Mal eine Kopie an', () => { |
| 109 | const pfad = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); |
| 110 | |
| 111 | expect(pfad).not.toBeNull(); |
| 112 | expect(existsSync(pfad ?? '')).toBe(true); |
| 113 | expect(kopien()).toHaveLength(1); |
| 114 | }); |
| 115 | |
| 116 | it('schreibt eine Kopie, die sich wirklich öffnen lässt', () => { |
| 117 | /* Eine Sicherungsdatei, die niemand einlesen kann, ist schlimmer als |
| 118 | keine: Sie wiegt in Sicherheit, bis es darauf ankommt. */ |
| 119 | const pfad = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); |
| 120 | |
| 121 | const kopie = new Database(pfad ?? '', { readonly: true }); |
| 122 | try { |
| 123 | const zeilen = kopie.pragma('integrity_check') as { integrity_check: string }[]; |
| 124 | expect(zeilen[0]?.integrity_check).toBe('ok'); |
| 125 | const profile = kopie |
| 126 | .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil') |
| 127 | .get(); |
| 128 | expect(profile?.anzahl).toBeGreaterThan(0); |
| 129 | } finally { |
| 130 | kopie.close(); |
| 131 | } |
| 132 | }); |
| 133 | |
| 134 | it('legt keine zweite Kopie an, solange die erste frisch ist', () => { |
| 135 | const erste = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); |
| 136 | /* Über die Frische entscheidet die Änderungszeit der Datei, nicht ihr |
| 137 | Name. Ohne dieses Zurückstellen redete die Systemuhr mit: Die Kopie |
| 138 | entsteht im Augenblick des Laufs, und sobald dieser Augenblick nach |
| 139 | dem zweiten Aufruf liegt, gilt sie nach `istFaellig` als aus der |
| 140 | Zukunft und damit als überfällig. Der Fall war keine Theorie – ab dem |
| 141 | 30.08.2026, 20:00 Uhr war dieser Test rot, ohne dass sich am geprüften |
| 142 | Verhalten etwas geändert hätte. */ |
| 143 | const angelegt = new Date('2026-08-29T20:00:00'); |
| 144 | utimesSync(erste ?? '', angelegt, angelegt); |
| 145 | |
| 146 | const zweite = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-30T20:00:00')); |
| 147 | |
| 148 | expect(zweite).toBeNull(); |
| 149 | expect(kopien()).toHaveLength(1); |
| 150 | }); |
| 151 | |
| 152 | it('legt wieder eine an, sobald der Abstand überschritten ist', () => { |
| 153 | const erste = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); |
| 154 | /* Entscheidend ist die Änderungszeit der Datei, nicht der Name – sie |
| 155 | wird hier zurückgestellt, statt eine Woche zu warten. */ |
| 156 | const alt = new Date(Date.now() - ABSTAND_MS - 60_000); |
| 157 | utimesSync(erste ?? '', alt, alt); |
| 158 | |
| 159 | const zweite = selbstsicherungAnlegen(db, userData, Database, new Date()); |
| 160 | |
| 161 | expect(zweite).not.toBeNull(); |
| 162 | expect(kopien()).toHaveLength(2); |
| 163 | }); |
| 164 | |
| 165 | it('behält nur die jüngsten Kopien', () => { |
| 166 | for (let lauf = 0; lauf < KOPIEN_BEHALTEN + 2; lauf++) { |
| 167 | const pfad = selbstsicherungAnlegen(db, userData, Database, new Date(2026, 7, 1 + lauf, 20)); |
| 168 | if (pfad !== null) { |
| 169 | const alt = new Date(Date.now() - ABSTAND_MS * (10 - lauf)); |
| 170 | utimesSync(pfad, alt, alt); |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | expect(kopien().length).toBeLessThanOrEqual(KOPIEN_BEHALTEN); |
| 175 | }); |
| 176 | |
| 177 | it('rührt fremde Dateien im Ordner nicht an', () => { |
| 178 | /* Aufgeräumt wird ausschließlich das eigene Muster. Ein Ordner, in dem |
| 179 | ein Programm nach Gutdünken löscht, ist kein Ort für Sicherungen. */ |
| 180 | const ordner = selbstsicherungsordner(userData); |
| 181 | mkdirSync(ordner, { recursive: true }); |
| 182 | const fremd = join(ordner, 'Wichtige-eigene-Datei.wsklernstand'); |
| 183 | new Database(fremd).close(); |
| 184 | |
| 185 | for (let lauf = 0; lauf < KOPIEN_BEHALTEN + 2; lauf++) { |
| 186 | const pfad = selbstsicherungAnlegen(db, userData, Database, new Date(2026, 7, 1 + lauf, 20)); |
| 187 | if (pfad !== null) { |
| 188 | const alt = new Date(Date.now() - ABSTAND_MS * (10 - lauf)); |
| 189 | utimesSync(pfad, alt, alt); |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | expect(existsSync(fremd)).toBe(true); |
| 194 | }); |
| 195 | |
| 196 | it('legt den Ordner selbst an', () => { |
| 197 | expect(existsSync(selbstsicherungsordner(userData))).toBe(false); |
| 198 | |
| 199 | selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); |
| 200 | |
| 201 | expect(existsSync(selbstsicherungsordner(userData))).toBe(true); |
| 202 | }); |
| 203 | }); |
| 204 | |
| 205 | describe('Kopie vor dem Verwerfen', () => { |
| 206 | /* |
| 207 | Die Wache, die gefehlt hat. |
| 208 | |
| 209 | Beide Einspielwege legen vorher eine Sicherheitskopie an – das Einspielen |
| 210 | einer fremden Sicherung und das Übernehmen eines Profils. Die beiden |
| 211 | Wege, die tatsächlich etwas vernichten, taten es nicht: Wer „Neu |
| 212 | anfangen“ drückte oder ein Profil löschte, war den Lernstand los, und |
| 213 | die selbsttätige Wochenkopie war je nach Tag bis zu sieben Tage alt. |
| 214 | */ |
| 215 | function kopien(vorsatz: string): string[] { |
| 216 | const ordner = selbstsicherungsordner(userData); |
| 217 | return existsSync(ordner) ? readdirSync(ordner).filter((name) => name.startsWith(vorsatz)) : []; |
| 218 | } |
| 219 | |
| 220 | it('legt eine Kopie an, auch wenn eine Wochenkopie eben erst entstand', () => { |
| 221 | /* Der Unterschied zur Wochenkopie in einem Fall: Sie ist ungedrosselt. |
| 222 | Wäre sie es nicht, fiele sie genau dann aus, wenn sie gebraucht wird. */ |
| 223 | selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); |
| 224 | const pfad = kopieVorDemVerwerfen(db, userData, Database, new Date('2026-08-29T20:00:05')); |
| 225 | |
| 226 | expect(existsSync(pfad)).toBe(true); |
| 227 | expect(kopien(VOR_DEM_VERWERFEN)).toHaveLength(1); |
| 228 | }); |
| 229 | |
| 230 | it('schreibt eine Kopie, die sich wirklich öffnen lässt', () => { |
| 231 | const pfad = kopieVorDemVerwerfen(db, userData, Database, new Date('2026-08-29T20:00:00')); |
| 232 | |
| 233 | const kopie = new Database(pfad, { readonly: true }); |
| 234 | try { |
| 235 | expect( |
| 236 | (kopie.pragma('integrity_check') as { integrity_check: string }[])[0]?.integrity_check, |
| 237 | ).toBe('ok'); |
| 238 | expect( |
| 239 | kopie.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil').get() |
| 240 | ?.anzahl, |
| 241 | ).toBeGreaterThan(0); |
| 242 | } finally { |
| 243 | kopie.close(); |
| 244 | } |
| 245 | }); |
| 246 | |
| 247 | it('räumt nur die eigenen Kopien ab, nie die der Wochensicherung', () => { |
| 248 | selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00')); |
| 249 | |
| 250 | for (let lauf = 0; lauf < KOPIEN_BEHALTEN + 2; lauf += 1) { |
| 251 | const pfad = kopieVorDemVerwerfen( |
| 252 | db, |
| 253 | userData, |
| 254 | Database, |
| 255 | new Date(2026, 7, 1 + lauf, 20, 0, 0), |
| 256 | ); |
| 257 | const alt = new Date(Date.now() - 60_000 * (10 - lauf)); |
| 258 | utimesSync(pfad, alt, alt); |
| 259 | } |
| 260 | |
| 261 | expect(kopien(VOR_DEM_VERWERFEN).length).toBeLessThanOrEqual(KOPIEN_BEHALTEN); |
| 262 | /* Die Wochenkopie ist unangetastet – zwei Kontingente, zwei Wege. */ |
| 263 | expect(kopien(SELBSTSICHERUNG_VORSATZ)).toHaveLength(1); |
| 264 | }); |
| 265 | |
| 266 | it('weicht auf einen zweiten Namen aus, statt zu überschreiben', () => { |
| 267 | const jetzt = new Date('2026-08-29T20:00:00'); |
| 268 | const erst = kopieVorDemVerwerfen(db, userData, Database, jetzt); |
| 269 | const zweit = kopieVorDemVerwerfen(db, userData, Database, jetzt); |
| 270 | |
| 271 | expect(zweit).not.toBe(erst); |
| 272 | expect(existsSync(erst)).toBe(true); |
| 273 | expect(existsSync(zweit)).toBe(true); |
| 274 | }); |
| 275 | |
| 276 | it('wirft, wenn sich nichts schreiben lässt – dann wird auch nichts verworfen', () => { |
| 277 | /* Der Unterschied zur Wochenkopie im zweiten Fall: Sie schweigt bei |
| 278 | einem Fehlschlag, diese hier nicht. Vor einem Schritt, der Daten |
| 279 | vernichtet, ist Schweigen die falsche Antwort. */ |
| 280 | db.close(); |
| 281 | |
| 282 | expect(() => kopieVorDemVerwerfen(db, userData, Database, new Date())).toThrow(); |
| 283 | }); |
| 284 | }); |
| 285 | |
| 286 | describe('Die Verdrahtung der Kopie vor dem Verwerfen', () => { |
| 287 | /* |
| 288 | Die Funktion allein nützt nichts – sie muss an den beiden zerstörenden |
| 289 | Wegen auch gerufen werden, und zwar **vor** ihnen. Für die IPC-Schicht |
| 290 | gibt es in diesem Projekt keinen Prüfstand; gelesen wird deshalb der |
| 291 | Quelltext, so wie es `sicherheit.test.ts` mit dem Sicherheitsmodell tut. |
| 292 | Eine Zusicherung an der Quelle ist schwächer als eine am laufenden |
| 293 | Programm – aber ungleich stärker als gar keine. |
| 294 | */ |
| 295 | const ipcQuelle = readFileSync( |
| 296 | join(fileURLToPath(new URL('..', import.meta.url)), 'src', 'main', 'ipc.ts'), |
| 297 | 'utf8', |
| 298 | ); |
| 299 | |
| 300 | it('ruft die Kopie im Kanal „profil:loeschen“, bevor gelöscht wird', () => { |
| 301 | const block = /'profil:loeschen'[\s\S]*?\n {2}\}\);/u.exec(ipcQuelle)?.[0] ?? ''; |
| 302 | |
| 303 | expect(block).toContain('kopieVorVerwerfen()'); |
| 304 | expect(block.indexOf('kopieVorVerwerfen()')).toBeLessThan(block.indexOf('profilLoeschen(')); |
| 305 | }); |
| 306 | |
| 307 | it('ruft die Kopie im Kanal „lernen:zuruecksetzen“, bevor zurückgesetzt wird', () => { |
| 308 | const block = /'lernen:zuruecksetzen'[\s\S]*?\n {2}\}\);/u.exec(ipcQuelle)?.[0] ?? ''; |
| 309 | |
| 310 | expect(block).toContain('kopieVorVerwerfen()'); |
| 311 | expect(block.indexOf('kopieVorVerwerfen()')).toBeLessThan(block.indexOf('zuruecksetzen(')); |
| 312 | }); |
| 313 | |
| 314 | it('fängt den Fehlschlag nicht ab – sonst wäre die Kopie eine Zugabe', () => { |
| 315 | /* Beim Beenden ist die Kopie eine Zugabe und ihr Fehlschlag wird |
| 316 | geschluckt. Hier darf er das nicht: `behandeln` reicht den Fehler an |
| 317 | die Oberfläche durch, und der zerstörende Schritt unterbleibt. */ |
| 318 | const funktion = /function kopieVorVerwerfen\(\)[\s\S]*?\n\}/u.exec(ipcQuelle)?.[0] ?? ''; |
| 319 | |
| 320 | expect(funktion.length).toBeGreaterThan(0); |
| 321 | expect(funktion).not.toContain('catch'); |
| 322 | }); |
| 323 | }); |
| 324 | |
| 325 | describe('Die Kopie beim Öffnen', () => { |
| 326 | /* |
| 327 | Die Wache, die gefehlt hat. |
| 328 | |
| 329 | Die Wochenkopie entstand ausschließlich beim geordneten Beenden. Stürzt |
| 330 | die Anwendung ab, fällt der Strom aus oder beendet jemand den Prozess |
| 331 | über den Taskmanager, entstand sie nie – und genau dann ist eine Kopie am |
| 332 | ehesten etwas wert. Wer eine Woche lang jeden Tag abstürzte, hatte am |
| 333 | Ende keine einzige. |
| 334 | |
| 335 | Für die IPC-Schicht gibt es keinen Prüfstand; gelesen wird deshalb der |
| 336 | Quelltext, so wie bei der Kopie vor dem Verwerfen. |
| 337 | */ |
| 338 | const quelle = readFileSync( |
| 339 | join(fileURLToPath(new URL('..', import.meta.url)), 'src', 'main', 'ipc.ts'), |
| 340 | 'utf8', |
| 341 | ); |
| 342 | |
| 343 | it('ruft die selbsttätige Kopie auch beim Öffnen des Lernstands', () => { |
| 344 | const block = /function lernstand\(\): Lernstand \{[\s\S]*?\n\}/u.exec(quelle)?.[0] ?? ''; |
| 345 | |
| 346 | expect(block).toContain('selbstsicherungBeimOeffnen('); |
| 347 | }); |
| 348 | |
| 349 | it('erwägt sie höchstens einmal je Programmlauf', () => { |
| 350 | /* `lernstand()` läuft bei jedem IPC-Kanal. Ohne Merker liefe die |
| 351 | Fälligkeitsprüfung hundertfach – sie schreibt zwar nichts, liest aber |
| 352 | jedes Mal das Verzeichnis. */ |
| 353 | const block = /function selbstsicherungBeimOeffnen\([\s\S]*?\n\}/u.exec(quelle)?.[0] ?? ''; |
| 354 | |
| 355 | expect(block).toContain('kopieBeimOeffnenErwogen'); |
| 356 | expect(block).toContain('return;'); |
| 357 | }); |
| 358 | |
| 359 | it('schluckt ihren Fehlschlag – sie ist eine Zugabe, keine Bedingung', () => { |
| 360 | /* Anders als die Kopie vor dem Verwerfen: Dort steht ein Schritt |
| 361 | dahinter, der Daten vernichtet, hier nicht. */ |
| 362 | const block = /function selbstsicherungBeimOeffnen\([\s\S]*?\n\}/u.exec(quelle)?.[0] ?? ''; |
| 363 | |
| 364 | expect(block).toContain('catch'); |
| 365 | }); |
| 366 | }); |