waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app tests selbstsicherung.test.ts

13,9 KB Rohdatei
app/tests/selbstsicherung.test.ts — 366 Zeilen
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 });