waffensachkunde

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

/ app tests lernstand-heil.test.ts

6,4 KB Rohdatei
app/tests/lernstand-heil.test.ts — 196 Zeilen
1 // @vitest-environment node
2 /**
3 * Die Heilprüfung der laufenden Lernstandsdatei.
4 *
5 * **Der Anlass.** Für *fremde* Sicherungsdateien lief seit jeher eine
6 * neunstufige Prüfkette (`sicherung.ts`, Stufe 4 mit `integrity_check`). Die
7 * eigene, laufende Datei bekam nie eine: `vorbereiten()` prüfte die
8 * Schemaversion, schaltete WAL ein und wandte das Schema an. Ein Bitfehler,
9 * ein defekter Datenträger oder ein abgebrochener Schreibvorgang auf einem
10 * USB-Stick blieben unbemerkt, bis irgendeine einzelne Abfrage
11 * `SQLITE_CORRUPT` warf – irgendwann mitten in einer Sitzung, mit „Ihr
12 * Lernprofil konnte nicht geladen werden“ als einziger Auskunft und ohne
13 * jedes Angebot.
14 *
15 * Hier wird an einer **echt beschädigten Datei** gemessen, nicht an einer
16 * Attrappe: Eine gültige Datenbank wird angelegt, geschlossen und danach in
17 * ihren Datenseiten überschrieben.
18 */
19
20 import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
21 import { tmpdir } from 'node:os';
22 import { join } from 'node:path';
23
24 import Database from 'better-sqlite3';
25 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
26
27 import {
28 istBeschaedigt,
29 LERNSTAND_BESCHAEDIGT,
30 Lernstand,
31 lernstandBeiseitelegen,
32 } from '../src/main/lernstand';
33 import type { Katalog } from '../src/shared/katalog';
34
35 const KATALOG: Katalog = {
36 meta: { titel: 'Test', herausgeber: 'Test', stand: '2024-12-16', quelle: 'Test' },
37 kapitel: [{ id: 'I', titel: 'Recht', abschnitte: [{ id: 'I.1', titel: 'Grundlagen' }] }],
38 bilder: {},
39 fragen: [
40 {
41 id: 'I.1-1',
42 kapitel: 'I',
43 abschnitt: 'I.1',
44 nummer: '1',
45 frage: 'Eine Frage?',
46 optionen: [{ label: 'a', text: 'Antwort', richtig: true }],
47 },
48 ],
49 } as unknown as Katalog;
50
51 let verzeichnis = '';
52 let pfad = '';
53
54 /** Legt eine gültige, geschlossene Lernstandsdatei an. */
55 function gueltigeDatei(): void {
56 const db = new Database(pfad);
57 new Lernstand(db, KATALOG);
58 db.close();
59 }
60
61 /**
62 * Überschreibt die Datenseiten – der Kopf bleibt stehen.
63 *
64 * Nur so entsteht der Fall, um den es geht: SQLite erkennt die Datei
65 * weiterhin als Datenbank und öffnet sie klaglos. Erst eine Prüfung merkt,
66 * dass der Inhalt hin ist. Wäre der Kopf mit zerstört, scheiterte schon das
67 * Öffnen und die Prüfung wäre überflüssig.
68 */
69 function beschaedigen(): void {
70 const inhalt = readFileSync(pfad);
71 inhalt.fill(0x7f, 1024, Math.min(inhalt.length, 16384));
72 writeFileSync(pfad, inhalt);
73 }
74
75 beforeEach(() => {
76 verzeichnis = mkdtempSync(join(tmpdir(), 'wsk-heil-'));
77 pfad = join(verzeichnis, 'lernstand.db');
78 });
79
80 afterEach(() => {
81 rmSync(verzeichnis, { recursive: true, force: true });
82 });
83
84 describe('Heilprüfung beim Öffnen', () => {
85 it('lässt eine gültige Datei ohne Weiteres durch', () => {
86 gueltigeDatei();
87
88 const db = new Database(pfad);
89 expect(() => new Lernstand(db, KATALOG)).not.toThrow();
90 db.close();
91 });
92
93 it('weist eine beschädigte Datei ab, statt sie in Betrieb zu nehmen', () => {
94 gueltigeDatei();
95 beschaedigen();
96
97 const db = new Database(pfad);
98 try {
99 expect(() => new Lernstand(db, KATALOG)).toThrow(/beschädigt/u);
100 } finally {
101 db.close();
102 }
103 });
104
105 it('macht den Befund für den Hauptprozess erkennbar', () => {
106 /* Der Hauptprozess muss genau diesen Fall von allen anderen
107 Öffnungsfehlern unterscheiden können – nur bei ihm gibt es etwas
108 anzubieten. */
109 gueltigeDatei();
110 beschaedigen();
111
112 const db = new Database(pfad);
113 try {
114 new Lernstand(db, KATALOG);
115 expect.unreachable('Die beschädigte Datei hätte abgewiesen werden müssen.');
116 } catch (fehler: unknown) {
117 expect(istBeschaedigt(fehler)).toBe(true);
118 expect((fehler as Error).message).toContain(LERNSTAND_BESCHAEDIGT);
119 } finally {
120 db.close();
121 }
122 });
123
124 it('hält andere Öffnungsfehler auseinander', () => {
125 expect(istBeschaedigt(new Error('Irgendetwas anderes'))).toBe(false);
126 expect(istBeschaedigt('kein Fehlerobjekt')).toBe(false);
127 expect(istBeschaedigt(null)).toBe(false);
128 });
129
130 it('läuft auch gegen eine Datenbank im Arbeitsspeicher', () => {
131 /* Die Tests der Anwendung arbeiten durchweg auf `:memory:`. Eine
132 Heilprüfung, die dort anschlüge, legte die halbe Testfläche lahm. */
133 const db = new Database(':memory:');
134 expect(() => new Lernstand(db, KATALOG)).not.toThrow();
135 db.close();
136 });
137 });
138
139 describe('Beschädigte Datei beiseitelegen', () => {
140 it('benennt um, statt zu löschen', () => {
141 gueltigeDatei();
142 const vorher = readFileSync(pfad);
143
144 const ziel = lernstandBeiseitelegen(pfad, new Date('2026-08-29T12:00:00.000Z'));
145
146 expect(existsSync(pfad)).toBe(false);
147 expect(existsSync(ziel)).toBe(true);
148 /* Aus einer beschädigten Datei ist oft noch etwas zu holen – gelöscht
149 ist sie endgültig weg. Der Inhalt muss deshalb unangetastet bleiben. */
150 expect(readFileSync(ziel)).toEqual(vorher);
151 });
152
153 it('trägt einen Zeitstempel, damit ein zweiter Fall den ersten nicht überschreibt', () => {
154 gueltigeDatei();
155 const erstes = lernstandBeiseitelegen(pfad, new Date('2026-08-29T12:00:00.000Z'));
156
157 gueltigeDatei();
158 const zweites = lernstandBeiseitelegen(pfad, new Date('2026-08-30T09:30:00.000Z'));
159
160 expect(erstes).not.toBe(zweites);
161 expect(existsSync(erstes)).toBe(true);
162 expect(existsSync(zweites)).toBe(true);
163 });
164
165 it('nimmt Schreibprotokoll und Sperrdatei mit', () => {
166 /* Bleiben `-wal` und `-shm` liegen, findet die frisch angelegte
167 Datenbank ein Schreibprotokoll vor, das nicht zu ihr gehört –
168 derselbe Fehler wie in docs/stand.md 7.8. */
169 gueltigeDatei();
170 writeFileSync(`${pfad}-wal`, 'protokoll');
171 writeFileSync(`${pfad}-shm`, 'sperre');
172
173 const ziel = lernstandBeiseitelegen(pfad, new Date('2026-08-29T12:00:00.000Z'));
174
175 expect(existsSync(`${pfad}-wal`)).toBe(false);
176 expect(existsSync(`${pfad}-shm`)).toBe(false);
177 expect(existsSync(`${ziel}-wal`)).toBe(true);
178 expect(existsSync(`${ziel}-shm`)).toBe(true);
179 });
180
181 it('kommt ohne Nebendateien aus', () => {
182 gueltigeDatei();
183
184 expect(() => lernstandBeiseitelegen(pfad, new Date('2026-08-29T12:00:00.000Z'))).not.toThrow();
185 });
186
187 it('macht den Weg für einen neuen Lernstand frei', () => {
188 gueltigeDatei();
189 beschaedigen();
190 lernstandBeiseitelegen(pfad, new Date('2026-08-29T12:00:00.000Z'));
191
192 const db = new Database(pfad);
193 expect(() => new Lernstand(db, KATALOG)).not.toThrow();
194 db.close();
195 });
196 });