waffensachkunde

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

/ app tests selbstsicherung.test.ts

12,2 KB Rohdatei
app/tests/selbstsicherung.test.ts — 323 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 });