waffensachkunde

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

/ app tests sicherung.test.ts

17,2 KB Rohdatei
app/tests/sicherung.test.ts — 459 Zeilen
1 /**
2 * Sicherung des Lernstands.
3 *
4 * Alles hier läuft gegen echte SQLite-Dateien auf der Platte, nicht gegen
5 * Attrappen: Die Fallen dieses Merkmals sind Fallen von SQLite selbst, und
6 * eine Attrappe hätte keine davon.
7 */
8
9 import {
10 copyFileSync,
11 existsSync,
12 mkdtempSync,
13 readFileSync,
14 rmSync,
15 statSync,
16 writeFileSync,
17 } from 'node:fs';
18 import { tmpdir } from 'node:os';
19 import { join } from 'node:path';
20
21 import Database from 'better-sqlite3';
22 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
23
24 import { SCHEMA_VERSION } from '../src/main/schema';
25 import {
26 ANWENDUNGSKENNUNG,
27 aufraeumen,
28 dateiPruefen,
29 kennzahlenLesen,
30 nebendateienAufraeumen,
31 sicherungsDateiname,
32 sicherungSchreiben,
33 } from '../src/main/sicherung';
34
35 let ordner: string;
36 let offene: Database.Database[];
37
38 /** Ein Lernstand, wie ihn die Anwendung anlegt – im WAL-Modus. */
39 function lernstandAnlegen(pfad: string, antworten = 5): Database.Database {
40 const db = new Database(pfad);
41 offene.push(db);
42 db.pragma('journal_mode = WAL');
43 db.exec(`
44 CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
45 CREATE TABLE profil (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
46 CREATE TABLE frage_stand (
47 profil_id INTEGER NOT NULL REFERENCES profil(id),
48 frage_id TEXT NOT NULL,
49 gemerkt INTEGER NOT NULL DEFAULT 0,
50 PRIMARY KEY (profil_id, frage_id)
51 );
52 CREATE TABLE antwort_log (
53 id INTEGER PRIMARY KEY AUTOINCREMENT,
54 profil_id INTEGER NOT NULL REFERENCES profil(id),
55 zeitpunkt TEXT NOT NULL
56 );
57 `);
58 for (let version = 1; version <= SCHEMA_VERSION; version++) {
59 db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(version);
60 }
61 db.prepare('INSERT INTO profil (name) VALUES (?)').run('Standard');
62 const einfuegen = db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt) VALUES (1, ?)');
63 const alle = db.transaction(() => {
64 for (let i = 0; i < antworten; i++) {
65 einfuegen.run(`2026-08-${String(10 + (i % 10)).padStart(2, '0')}T09:00:00.000Z`);
66 }
67 });
68 alle();
69 db.prepare('INSERT INTO frage_stand (profil_id, frage_id, gemerkt) VALUES (1, ?, 1)').run(
70 'I.1-01',
71 );
72 return db;
73 }
74
75 beforeEach(() => {
76 ordner = mkdtempSync(join(tmpdir(), 'wsk-sicherung-'));
77 offene = [];
78 });
79
80 afterEach(() => {
81 for (const db of offene) {
82 try {
83 db.close();
84 } catch {
85 /* Schon zu. */
86 }
87 }
88 rmSync(ordner, { recursive: true, force: true });
89 });
90
91 describe('Warum eine bloße Dateikopie nicht genügt', () => {
92 it('verliert bei offener Verbindung im WAL-Modus alles – und meldet trotzdem „ok“', () => {
93 /*
94 Der Messwert, der die ganze Umsetzung begründet. Er steht hier als Test,
95 damit niemand später auf die naheliegende Abkürzung verfällt: Die Kopie
96 ist nicht bloß älter, sie ist LEER – bei tadellosem integrity_check.
97 */
98 const quelle = join(ordner, 'lernstand.db');
99 lernstandAnlegen(quelle, 200);
100
101 const kopie = join(ordner, 'nur-hauptdatei.db');
102 copyFileSync(quelle, kopie);
103
104 const db = new Database(kopie, { readonly: true });
105 offene.push(db);
106 const zeilen = db.pragma('integrity_check') as { integrity_check: string }[];
107 expect(zeilen[0]?.integrity_check).toBe('ok');
108
109 const tabellen = db
110 .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
111 .all();
112 expect(tabellen).toHaveLength(0);
113 });
114 });
115
116 describe('Eine Sicherung schreiben', () => {
117 it('nimmt alles mit, obwohl die Verbindung offen bleibt', () => {
118 const quelle = join(ordner, 'lernstand.db');
119 const db = lernstandAnlegen(quelle, 200);
120
121 const ziel = join(ordner, 'sicherung.wsklernstand');
122 const bytes = sicherungSchreiben(db, ziel, Database);
123
124 expect(bytes).toBeGreaterThan(0);
125 const gelesen = new Database(ziel, { readonly: true });
126 offene.push(gelesen);
127 expect(
128 gelesen.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM antwort_log').get()
129 ?.anzahl,
130 ).toBe(200);
131 });
132
133 it('hinterlässt genau eine Datei, auch nachdem sie gelesen wurde', () => {
134 /*
135 Der Grund gegen `db.backup()`: Dessen Ergebnis steht im WAL-Modus, und
136 wer es auch nur ansieht, erzeugt daneben `-wal` und `-shm`, die nach dem
137 Schließen liegen bleiben. Eine Sicherung, von der man zwei Dateien
138 vergisst, ist genau der Fehler, den sie verhüten soll.
139 */
140 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
141 const ziel = join(ordner, 'sicherung.wsklernstand');
142 sicherungSchreiben(db, ziel, Database);
143
144 const gelesen = new Database(ziel, { readonly: true });
145 expect((gelesen.pragma('journal_mode') as { journal_mode: string }[])[0]?.journal_mode).toBe(
146 'delete',
147 );
148 gelesen.close();
149
150 expect(existsSync(`${ziel}-wal`)).toBe(false);
151 expect(existsSync(`${ziel}-shm`)).toBe(false);
152 });
153
154 it('trägt die Kennung dieser Anwendung und die Schemafassung im Kopf', () => {
155 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
156 const ziel = join(ordner, 'sicherung.wsklernstand');
157 sicherungSchreiben(db, ziel, Database);
158
159 const gelesen = new Database(ziel, { readonly: true });
160 offene.push(gelesen);
161 expect(
162 (gelesen.pragma('application_id') as { application_id: number }[])[0]?.application_id,
163 ).toBe(ANWENDUNGSKENNUNG);
164 expect((gelesen.pragma('user_version') as { user_version: number }[])[0]?.user_version).toBe(
165 SCHEMA_VERSION,
166 );
167 });
168
169 it('lässt keine Teildatei zurück', () => {
170 /* Erst nach `.teil`, dann umbenennen: Ein Abbruch mitten im Schreiben
171 hinterlässt damit nie eine halbe Datei unter dem richtigen Namen. */
172 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
173 const ziel = join(ordner, 'sicherung.wsklernstand');
174 sicherungSchreiben(db, ziel, Database);
175
176 expect(existsSync(`${ziel}.teil`)).toBe(false);
177 });
178
179 it('nimmt einen Pfad mit Anführungszeichen im Namen', () => {
180 /* Der Zielpfad wird gebunden und nicht in die Anweisung eingesetzt.
181 Eingesetzt zerlegte ein Anführungszeichen die Anweisung. */
182 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
183 const ziel = join(ordner, "Olafs 'Sicherung'.wsklernstand");
184
185 expect(() => sicherungSchreiben(db, ziel, Database)).not.toThrow();
186 expect(existsSync(ziel)).toBe(true);
187 });
188 });
189
190 describe('Die Prüfkette', () => {
191 it('nimmt eine echte Sicherung an und beziffert sie', () => {
192 const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 42);
193 const ziel = join(ordner, 'sicherung.wsklernstand');
194 sicherungSchreiben(db, ziel, Database);
195
196 const befund = dateiPruefen(ziel, Database);
197
198 expect(befund.art).toBe('brauchbar');
199 if (befund.art !== 'brauchbar') {
200 return;
201 }
202 expect(befund.kennzahlen.profilnamen).toEqual(['Standard']);
203 expect(befund.kennzahlen.antworten).toBe(42);
204 expect(befund.kennzahlen.gemerkt).toBe(1);
205 expect(befund.kennzahlen.schemafassung).toBe(SCHEMA_VERSION);
206 expect(befund.kennzahlen.letzteAntwort).not.toBeNull();
207 });
208
209 it('weist eine leere Datei ab – der gefährlichste Fall überhaupt', () => {
210 /*
211 Eine Datei von null Byte besteht `integrity_check` mit „ok“ und hat null
212 Tabellen. Ohne diese Abweisung durchliefe sie die vollständige
213 Migrationskette und stünde als tadelloser, LEERER Lernstand da. Das
214 Einspielen meldete Erfolg, und die Arbeit von Wochen wäre fort.
215 */
216 const leer = join(ordner, 'leer.wsklernstand');
217 writeFileSync(leer, '');
218
219 const befund = dateiPruefen(leer, Database);
220
221 expect(befund.art).toBe('abgelehnt');
222 expect(befund.art === 'abgelehnt' && befund.grund).toContain('Es wurde nichts verändert.');
223 });
224
225 it('weist eine gültige, aber fremde SQLite-Datenbank ab', () => {
226 /* Auch eine Datenbank mit null Tabellen ist syntaktisch tadellos. Was
227 fehlt, sind die Tabellen dieser Anwendung. */
228 const fremd = join(ordner, 'fremd.db');
229 const db = new Database(fremd);
230 db.exec('CREATE TABLE adressen (id INTEGER PRIMARY KEY, name TEXT)');
231 db.close();
232
233 const befund = dateiPruefen(fremd, Database);
234
235 expect(befund.art).toBe('abgelehnt');
236 expect(befund.art === 'abgelehnt' && befund.grund).toContain('kein Lernstand dieser Anwendung');
237 });
238
239 it('weist ein umbenanntes Bild ab, ohne eine englische Datenbankmeldung', () => {
240 /* Eine Textdatei lässt sich readonly ÖFFNEN – erst die erste Abfrage
241 wirft SQLITE_NOTADB. Deshalb wird der Dateikopf vorher gelesen. */
242 const foto = join(ordner, 'urlaub.wsklernstand');
243 writeFileSync(foto, 'x'.repeat(2048));
244
245 const befund = dateiPruefen(foto, Database);
246
247 expect(befund.art).toBe('abgelehnt');
248 expect(befund.art === 'abgelehnt' && befund.grund).toContain('keine Datenbank');
249 expect(befund.art === 'abgelehnt' && befund.grund).not.toMatch(/SQLITE_|not a database/u);
250 });
251
252 it('weist eine abgeschnittene Datei ab', () => {
253 const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 500);
254 const ziel = join(ordner, 'sicherung.wsklernstand');
255 sicherungSchreiben(db, ziel, Database);
256
257 const halb = join(ordner, 'halb.wsklernstand');
258 const { size } = statSync(ziel);
259 const inhalt = readFileSync(ziel);
260 writeFileSync(halb, inhalt.subarray(0, Math.floor(size * 0.4)));
261
262 const befund = dateiPruefen(halb, Database);
263
264 expect(befund.art).toBe('abgelehnt');
265 });
266
267 it('weist eine Sicherung aus einer neueren Programmfassung ab', () => {
268 /* Vorwärts migrieren kann die Anwendung, rückwärts nicht. Eine neuere
269 Sicherung stillschweigend anzunehmen hiesse, Spalten zu verlieren. */
270 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
271 const ziel = join(ordner, 'sicherung.wsklernstand');
272 sicherungSchreiben(db, ziel, Database);
273
274 /* Die Fassung wird erst NACH dem Schreiben hochgesetzt: Diese Anwendung
275 kann eine solche Datei gar nicht erzeugen – sie entsteht auf einem
276 Rechner mit neuerem Programm. */
277 const zukunft = join(ordner, 'zukunft.wsklernstand');
278 copyFileSync(ziel, zukunft);
279 const neuer = new Database(zukunft);
280 neuer.prepare('INSERT INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION + 1);
281 neuer.close();
282
283 const befund = dateiPruefen(zukunft, Database);
284
285 expect(befund.art).toBe('abgelehnt');
286 expect(befund.art === 'abgelehnt' && befund.grund).toContain('neueren Fassung');
287 });
288
289 it('nimmt eine ältere Sicherung an – die Migrationskette zieht sie hoch', () => {
290 /*
291 `docs/stand.md` sagte „bei gleicher Schemafassung". Das ist zu streng:
292 Eine alte Sicherung einzuspielen ist byteweise dieselbe Handlung wie ein
293 alter Lernstand nach einem Programmupdate, und diesen Weg geht jeder
294 Nutzer nach jeder Aktualisierung.
295 */
296 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
297 db.prepare('DELETE FROM schema_version WHERE version > 3').run();
298 const ziel = join(ordner, 'alt.wsklernstand');
299 sicherungSchreiben(db, ziel, Database);
300
301 const befund = dateiPruefen(ziel, Database);
302
303 expect(befund.art).toBe('brauchbar');
304 expect(befund.art === 'brauchbar' && befund.kennzahlen.schemafassung).toBe(3);
305 });
306
307 it('lässt nach jeder Abweisung die Datei wieder los', () => {
308 /* Bleibt die Verbindung offen, ist die Datei unter Windows gesperrt und
309 liesse sich weder löschen noch umbenennen. */
310 const fremd = join(ordner, 'fremd.db');
311 const db = new Database(fremd);
312 db.exec('CREATE TABLE adressen (id INTEGER PRIMARY KEY)');
313 db.close();
314
315 expect(dateiPruefen(fremd, Database).art).toBe('abgelehnt');
316 expect(() => {
317 rmSync(fremd);
318 }).not.toThrow();
319 });
320 });
321
322 describe('Der Dateiname', () => {
323 it('trägt Datum und Uhrzeit auf die Sekunde genau', () => {
324 /* Auf die Sekunde, damit zwei Sicherungen in derselben Minute einander
325 nicht überschreiben – die selbsttätige Sicherheitskopie vor dem
326 Einspielen fragt niemanden. */
327 const name = sicherungsDateiname(new Date(2026, 7, 22, 14, 32, 7));
328
329 expect(name).toBe('Waffensachkunde-Lernstand-2026-08-22-143207.wsklernstand');
330 });
331
332 it('nennt keinen Profilnamen', () => {
333 /* Gesichert wird die ganze Datenbank mit allen Profilen. Ein Name darin
334 wäre gelogen und verleitete dazu, die Datei später für die Sicherung
335 eines einzelnen Profils zu halten. */
336 expect(sicherungsDateiname(new Date())).not.toMatch(/Standard|Olaf/u);
337 });
338 });
339
340 describe('Aufräumen und Kennzahlen', () => {
341 /*
342 Nachgemessen: Wird eine Datenbankdatei nur LESEND geöffnet, bleiben
343 danach ein 0 Byte grosses `-wal` und ein 32 KiB grosses `-shm` liegen.
344 Im heutigen Ablauf ist das harmlos – der nächste Durchgang liest die
345 richtige Datei. Läge dort aber je ein GEFÜLLTES `-wal`, bekäme SQLite den
346 Inhalt der vorigen Datenbank untergeschoben, bei grünem
347 `integrity_check`; nachgestellt: erwartet 900 Zeilen, gelesen 5000 aus der
348 anderen Datei. Deshalb wird es entfernt, bevor der erste schreibende
349 Zugriff auf eine Arbeitskopie dazukommt.
350 */
351 it('nimmt beim Aufräumen die Nebendateien mit', () => {
352 const pfad = join(ordner, 'stand.db');
353 /* Die schreibende Verbindung muss zu sein, sonst hält Windows die
354 Nebendateien und der Test misst das Betriebssystem statt den Code. */
355 lernstandAnlegen(pfad).close();
356
357 const lesend = new Database(pfad, { readonly: true });
358 offene.push(lesend);
359 lesend.prepare('SELECT COUNT(*) AS n FROM antwort_log').get();
360 lesend.close();
361
362 expect(existsSync(`${pfad}-shm`)).toBe(true);
363
364 aufraeumen(pfad);
365
366 expect(existsSync(pfad)).toBe(false);
367 expect(existsSync(`${pfad}-wal`)).toBe(false);
368 expect(existsSync(`${pfad}-shm`)).toBe(false);
369 });
370
371 it('stört sich nicht daran, wenn nichts da ist', () => {
372 expect(() => {
373 aufraeumen(join(ordner, 'gibt-es-nicht.db'));
374 }).not.toThrow();
375 });
376
377 /*
378 Beim Anlegen einer Sicherung über eine vorhandene darf nur das
379 Schreibprotokoll fallen, nie die Datei selbst: Bis 0.24.1 wurde sie vorher
380 gelöscht, und ein Fehlschlag danach nahm beide Fassungen mit.
381 */
382 it('räumt nur die Nebendateien weg und lässt die Sicherung stehen', () => {
383 const pfad = join(ordner, 'sicherung.wsklernstand');
384 writeFileSync(pfad, 'ALTE SICHERUNG', 'utf8');
385 writeFileSync(`${pfad}-wal`, 'protokoll', 'utf8');
386 writeFileSync(`${pfad}-shm`, 'gemeinsam', 'utf8');
387
388 nebendateienAufraeumen(pfad);
389
390 expect(readFileSync(pfad, 'utf8')).toBe('ALTE SICHERUNG');
391 expect(existsSync(`${pfad}-wal`)).toBe(false);
392 expect(existsSync(`${pfad}-shm`)).toBe(false);
393 });
394
395 /*
396 Der Grund, warum das Vorab-Löschen entbehrlich ist: `sicherungSchreiben`
397 schreibt nach `<ziel>.teil` und schiebt die fertige Datei mit `renameSync`
398 an ihren Platz. Das ersetzt ein vorhandenes Ziel unteilbar.
399 */
400 it('ersetzt eine vorhandene Zieldatei, ohne dass sie vorher gelöscht wird', () => {
401 const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 30);
402 const ziel = join(ordner, 'sicherung.wsklernstand');
403 writeFileSync(ziel, 'ALTE SICHERUNG', 'utf8');
404
405 const bytes = sicherungSchreiben(db, ziel, Database);
406
407 expect(bytes).toBeGreaterThan(0);
408 expect(dateiPruefen(ziel, Database).art).toBe('brauchbar');
409 });
410
411 it('lässt die vorhandene Zieldatei unangetastet, wenn das Schreiben scheitert', () => {
412 /* Eine Quelle ohne die Pflichttabellen: Die Gegenprobe in
413 `sicherungSchreiben` lehnt das Ergebnis ab und wirft. */
414 const untauglich = new Database(join(ordner, 'untauglich.db'));
415 offene.push(untauglich);
416 untauglich.exec('CREATE TABLE irgendwas (a INTEGER)');
417
418 const ziel = join(ordner, 'sicherung.wsklernstand');
419 writeFileSync(ziel, 'ALTE SICHERUNG', 'utf8');
420
421 expect(() => sicherungSchreiben(untauglich, ziel, Database)).toThrow();
422 expect(readFileSync(ziel, 'utf8')).toBe('ALTE SICHERUNG');
423 expect(existsSync(`${ziel}.teil`)).toBe(false);
424 });
425
426 /*
427 `antwort_log` enthält seit Schemafassung 8 auch Zeilen mit
428 `nur_historie = 1`: Fragen eines abgelaufenen Prüfungsbogens, die nie
429 aufgeschlagen wurden. Sie gehören in die Historie, aber nicht in eine
430 Zahl, über die jemand einen nicht rücknehmbaren Schritt freigibt.
431 */
432 it('zählt nie aufgeschlagene Prüfungsfragen nicht als Antworten', () => {
433 const pfad = join(ordner, 'mit-historie.db');
434 const db = lernstandAnlegen(pfad, 5);
435 db.exec('ALTER TABLE antwort_log ADD COLUMN nur_historie INTEGER NOT NULL DEFAULT 0');
436 db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt, nur_historie) VALUES (1, ?, 1)').run(
437 '2026-08-20T09:00:00.000Z',
438 );
439 db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt, nur_historie) VALUES (1, ?, 1)').run(
440 '2026-08-21T09:00:00.000Z',
441 );
442
443 const kennzahlen = kennzahlenLesen(db, SCHEMA_VERSION, new Set(['profil', 'antwort_log']));
444
445 expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 7 });
446 expect(kennzahlen.antworten).toBe(5);
447 });
448
449 it('zählt bei älteren Sicherungen ohne die Spalte weiterhin alles', () => {
450 /* Rückwirkend liesse sich nicht ermitteln, welche Zeile nie gestellt
451 wurde. Zu raten wäre schlechter, als es stehen zu lassen. */
452 const pfad = join(ordner, 'alt.db');
453 const db = lernstandAnlegen(pfad, 4);
454
455 const kennzahlen = kennzahlenLesen(db, 7, new Set(['profil', 'antwort_log']));
456
457 expect(kennzahlen.antworten).toBe(4);
458 });
459 });