waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app tests lernstand-heil.test.ts
| 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 | }); |