waffensachkunde

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

/ app tests datenbank.test.ts

17,8 KB Rohdatei
app/tests/datenbank.test.ts — 526 Zeilen
1 // @vitest-environment node
2 // Schema-Schutzgitter. Geprüft wird gegen echte SQLite-Dateien, weil genau
3 // die Eigenschaften auf dem Spiel stehen, die eine In-Memory-Datenbank nicht
4 // zeigt: WAL-Journal, Fremdschlüssel über Neustarts hinweg, Dauerhaftigkeit.
5
6 import { mkdtempSync, rmSync } from 'node:fs';
7 import { tmpdir } from 'node:os';
8 import { join } from 'node:path';
9
10 import Database from 'better-sqlite3';
11 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
12
13 import { Lernstand, lernstandInstanz, lernstandSchliessen } from '../src/main/lernstand';
14 import { SCHEMA_VERSION } from '../src/main/schema';
15 import type { Frage, Katalog } from '../src/shared/katalog';
16
17 const FRAGEN: readonly Frage[] = [
18 {
19 id: 'I.1-01',
20 amtliche_nummer: '1.01',
21 kapitel: 'I',
22 abschnitt: 'I.1',
23 typ: 'mc',
24 seite: 1,
25 frage: { text: 'Frage', segmente: [{ t: 'Frage' }] },
26 bilder: [],
27 optionen: [
28 { label: 'a', inhalt: { text: 'a', segmente: [] }, korrekt: true, bilder: [] },
29 { label: 'b', inhalt: { text: 'b', segmente: [] }, korrekt: false, bilder: [] },
30 ],
31 },
32 {
33 id: 'II-01',
34 amtliche_nummer: '2.01',
35 kapitel: 'II',
36 abschnitt: null,
37 typ: 'freitext',
38 seite: 2,
39 frage: { text: 'Frage', segmente: [{ t: 'Frage' }] },
40 bilder: [],
41 musterantwort: { text: 'Antwort', segmente: [{ t: 'Antwort', h: true }] },
42 },
43 ];
44
45 const KATALOG: Katalog = {
46 meta: {
47 titel: 'Prüfkatalog',
48 herausgeber: 'Bundesverwaltungsamt',
49 stand: '2024-12-16',
50 quellenangabe: 'Amtlicher Fragenkatalog.',
51 quelle_url: 'https://www.bva.bund.de/',
52 quelldatei_sha256: 'a'.repeat(64),
53 fragen_gesamt: FRAGEN.length,
54 },
55 kapitel: [
56 { id: 'I', titel: 'Waffenrecht', abschnitte: [{ id: 'I.1', titel: 'Begriffe' }] },
57 { id: 'II', titel: 'Waffentechnik', abschnitte: [] },
58 ],
59 bilder: [],
60 fragen: FRAGEN,
61 };
62
63 let verzeichnis: string;
64 let pfad: string;
65 /** Alle geöffneten Verbindungen – auch die, deren Aufbau scheiterte. */
66 let verbindungen: Database.Database[];
67
68 function oeffnen(): { lernstand: Lernstand; db: Database.Database } {
69 const db = new Database(pfad);
70 // Vor der Konstruktion vormerken: schlägt sie fehl, muss die Verbindung
71 // trotzdem geschlossen werden, sonst bleibt die Datei unter Windows gesperrt.
72 verbindungen.push(db);
73 return { lernstand: new Lernstand(db, KATALOG), db };
74 }
75
76 beforeEach(() => {
77 verzeichnis = mkdtempSync(join(tmpdir(), 'waffensachkunde-'));
78 pfad = join(verzeichnis, 'lernstand.db');
79 verbindungen = [];
80 });
81
82 afterEach(() => {
83 // Die Modul-weite Instanz aus lernstandInstanz mit aufraeumen.
84 lernstandSchliessen();
85 for (const db of verbindungen) {
86 if (db.open) {
87 db.close();
88 }
89 }
90 rmSync(verzeichnis, { recursive: true, force: true });
91 });
92
93 // ─── Aufbau ─────────────────────────────────────────────────────────────────
94
95 describe('Schema', () => {
96 it('legt alle geforderten Tabellen an', () => {
97 const { db } = oeffnen();
98
99 const tabellen = (
100 db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as {
101 name: string;
102 }[]
103 ).map((z) => z.name);
104
105 expect(tabellen).toEqual(
106 expect.arrayContaining([
107 'schema_version',
108 'profil',
109 'frage_stand',
110 'antwort_log',
111 'katalog_stand',
112 ]),
113 );
114 });
115
116 it.each([
117 ['profil', ['id', 'name', 'pruefungstermin', 'erstellt_am']],
118 [
119 'frage_stand',
120 [
121 'profil_id',
122 'frage_id',
123 'versuche',
124 'richtige',
125 'zuletzt_beantwortet',
126 'faellig_ab',
127 'gemerkt',
128 'letzte_bewertung',
129 ],
130 ],
131 [
132 'antwort_log',
133 [
134 'id',
135 'profil_id',
136 'frage_id',
137 'zeitpunkt',
138 'richtig',
139 'bewertung',
140 'dauer_ms',
141 'auswahl',
142 'freitext',
143 ],
144 ],
145 ])('gibt der Tabelle %s die vereinbarten Spalten', (tabelle, spalten) => {
146 const { db } = oeffnen();
147
148 const vorhanden = (db.pragma(`table_info(${tabelle})`) as { name: string }[]).map(
149 (s) => s.name,
150 );
151
152 expect(vorhanden).toEqual(expect.arrayContaining(spalten));
153 });
154
155 it('nutzt (profil_id, frage_id) als Primärschlüssel von frage_stand', () => {
156 const { db } = oeffnen();
157
158 const schluessel = (db.pragma('table_info(frage_stand)') as { name: string; pk: number }[])
159 .filter((s) => s.pk > 0)
160 .sort((a, b) => a.pk - b.pk)
161 .map((s) => s.name);
162
163 expect(schluessel).toEqual(['profil_id', 'frage_id']);
164 });
165
166 it('schaltet WAL und Fremdschlüssel ein', () => {
167 const { db } = oeffnen();
168
169 expect(db.pragma('journal_mode', { simple: true })).toBe('wal');
170 expect(db.pragma('foreign_keys', { simple: true })).toBe(1);
171 });
172
173 it('hält den Schemastand fest', () => {
174 const { db } = oeffnen();
175
176 expect(db.prepare('SELECT MAX(version) AS version FROM schema_version').get()).toEqual({
177 version: SCHEMA_VERSION,
178 });
179 });
180 });
181
182 // ─── Beständigkeit ──────────────────────────────────────────────────────────
183
184 describe('Beständigkeit', () => {
185 it('behält Profile und Antworten über einen Neustart hinweg', () => {
186 const erste = oeffnen();
187 const profil = erste.lernstand.profilAnlegen('Olaf');
188 erste.lernstand.antworten(profil.id, {
189 frageId: 'I.1-01',
190 auswahl: ['a'],
191 richtig: true,
192 bewertung: 'gut',
193 dauerMs: 1200,
194 });
195 erste.lernstand.schliessen();
196
197 const zweite = oeffnen();
198
199 expect(zweite.lernstand.profile().map((p) => p.name)).toEqual(['Standard', 'Olaf']);
200 expect(zweite.lernstand.frageStand(profil.id, 'I.1-01')).toMatchObject({
201 versuche: 1,
202 richtige: 1,
203 letzteBewertung: 'gut',
204 });
205 });
206
207 it('legt beim erneuten Öffnen weder ein zweites Standardprofil noch einen zweiten Schemaeintrag an', () => {
208 oeffnen().lernstand.schliessen();
209 const { lernstand, db } = oeffnen();
210
211 expect(lernstand.profile()).toHaveLength(1);
212 expect(db.prepare('SELECT COUNT(*) AS n FROM schema_version').get()).toEqual({
213 n: SCHEMA_VERSION,
214 });
215 /* Auch der Katalogstand wird nur beim ersten Öffnen vermerkt – bei
216 Gleichstand entsteht keine zweite Zeile, sonst wüchse die Tabelle mit
217 jedem Start und der Verlauf sagte nichts mehr. */
218 expect(db.prepare('SELECT COUNT(*) AS n FROM katalog_stand').get()).toEqual({ n: 1 });
219 });
220
221 it('verweigert den Start, wenn der Lernstand aus einer neueren Version stammt', () => {
222 const { lernstand, db } = oeffnen();
223 db.prepare('INSERT INTO schema_version (version, angewendet_am) VALUES (?, ?)').run(
224 SCHEMA_VERSION + 5,
225 new Date().toISOString(),
226 );
227 lernstand.schliessen();
228
229 expect(() => oeffnen()).toThrow(/neueren Programmversion/u);
230 });
231
232 it('lässt nach einer Abweisung keine offene Verbindung zurück', () => {
233 /* Scheitert der Konstruktor, bliebe die eben geöffnete Verbindung sonst
234 liegen – und die Oberfläche versucht es nach jeder Sitzung erneut.
235 Unter Windows ist eine Datei mit offenem Handle nicht löschbar; genau
236 das ist hier der belastbare Nachweis. */
237 const { lernstand, db } = oeffnen();
238 db.prepare('INSERT INTO schema_version (version, angewendet_am) VALUES (?, ?)').run(
239 SCHEMA_VERSION + 5,
240 new Date().toISOString(),
241 );
242 lernstand.schliessen();
243
244 for (let versuch = 0; versuch < 3; versuch += 1) {
245 expect(() => lernstandInstanz(pfad, KATALOG)).toThrow(/neueren Programmversion/u);
246 }
247
248 expect(() => {
249 rmSync(pfad, { force: true });
250 }).not.toThrow();
251 });
252 });
253
254 // ─── Datenintegrität ────────────────────────────────────────────────────────
255
256 describe('Integritätsregeln', () => {
257 it('lehnt Protokollzeilen zu einem unbekannten Profil ab', () => {
258 const { db } = oeffnen();
259
260 expect(() =>
261 db
262 .prepare(
263 `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms)
264 VALUES (?, ?, ?, ?, ?, ?)`,
265 )
266 .run(4711, 'I.1-01', new Date().toISOString(), 1, 'gut', 100),
267 ).toThrow(/FOREIGN KEY/u);
268 });
269
270 it('räumt beim Löschen eines Profils dessen Daten mit ab', () => {
271 const { lernstand, db } = oeffnen();
272 const profil = lernstand.profilAnlegen('Olaf');
273 lernstand.antworten(profil.id, {
274 frageId: 'I.1-01',
275 auswahl: ['a'],
276 richtig: true,
277 bewertung: 'gut',
278 dauerMs: 10,
279 });
280
281 db.prepare('DELETE FROM profil WHERE id = ?').run(profil.id);
282
283 expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 0 });
284 expect(db.prepare('SELECT COUNT(*) AS n FROM frage_stand').get()).toEqual({ n: 0 });
285 });
286
287 it('lässt je Profil und Frage nur eine Standzeile zu', () => {
288 const { lernstand, db } = oeffnen();
289 const profilId = lernstand.profile()[0]!.id;
290 lernstand.merken(profilId, 'I.1-01', true);
291
292 expect(() =>
293 db
294 .prepare('INSERT INTO frage_stand (profil_id, frage_id) VALUES (?, ?)')
295 .run(profilId, 'I.1-01'),
296 ).toThrow(/UNIQUE|PRIMARY KEY/u);
297 });
298
299 it.each([
300 ['bewertung', 'auswendig'],
301 ['richtig', 7],
302 ])('lehnt einen unzulässigen Wert in antwort_log.%s ab', (spalte, wert) => {
303 const { lernstand, db } = oeffnen();
304 const profilId = lernstand.profile()[0]!.id;
305
306 const werte: Record<string, string | number> = {
307 profil_id: profilId,
308 frage_id: 'I.1-01',
309 zeitpunkt: new Date().toISOString(),
310 richtig: 1,
311 bewertung: 'gut',
312 dauer_ms: 10,
313 };
314 werte[spalte] = wert;
315
316 expect(() =>
317 db
318 .prepare(
319 `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms)
320 VALUES (@profil_id, @frage_id, @zeitpunkt, @richtig, @bewertung, @dauer_ms)`,
321 )
322 .run(werte),
323 ).toThrow(/CHECK/u);
324 });
325
326 it('lehnt einen doppelten Profilnamen auch auf Datenbankebene ab', () => {
327 const { db } = oeffnen();
328
329 expect(() =>
330 db
331 .prepare('INSERT INTO profil (name, erstellt_am) VALUES (?, ?)')
332 .run('Standard', new Date().toISOString()),
333 ).toThrow(/UNIQUE/u);
334 });
335 });
336
337 describe('Migration auf einen bestehenden Lernstand', () => {
338 /*
339 Der Fall, den kein bisheriger Test abdeckte – und der genau deshalb
340 gefährlich ist: Alle Tests legen frische Datenbanken an, und auf einer
341 frischen tut `CREATE TABLE IF NOT EXISTS` alles Nötige. Auf einer
342 bestehenden ist es ein reiner Leerlauf. Wer eine neue Spalte dort statt in
343 MIGRATIONEN einträgt, baut einen Fehler, den die ganze Suite nicht sieht:
344 Die Spalte entsteht nie, und das erste SELECT darauf scheitert mit
345 „no such column“ – beim Nutzer, nicht im Test.
346 */
347 it('ergänzt kapitel_ausschluss auf einer Datenbank ohne diese Spalte', () => {
348 const { lernstand } = oeffnen();
349 lernstand.schliessen();
350
351 /* Den Zustand vor der Migration nachstellen: Spalte weg, Versionsstand
352 zurück. SQLite kann DROP COLUMN seit 3.35. */
353 const roh = new Database(pfad);
354 verbindungen.push(roh);
355 roh.exec('ALTER TABLE profil DROP COLUMN kapitel_ausschluss');
356 roh.prepare('DELETE FROM schema_version WHERE version >= 6').run();
357 expect(
358 roh
359 .prepare<[], { name: string }>('PRAGMA table_info(profil)')
360 .all()
361 .some((z) => z.name === 'kapitel_ausschluss'),
362 ).toBe(false);
363 roh.close();
364
365 const zweite = oeffnen();
366
367 /* Die Spalte ist da, gefüllt, und das bestehende Profil ist unverändert
368 lesbar – ohne Vorgabewert scheiterte das ADD COLUMN an NOT NULL. */
369 expect(zweite.lernstand.profile()[0]?.kapitelAusschluss).toEqual([]);
370 expect(zweite.db.prepare('SELECT MAX(version) AS v FROM schema_version').get()).toEqual({
371 v: SCHEMA_VERSION,
372 });
373 });
374
375 it('bildet den belegten Abruf aus dem Antwortprotokoll exakt nach', () => {
376 /*
377 Der Punkt, an dem eine Migration entweder ehrlich ist oder rät.
378
379 Der Beleg ist neu – aber er lässt sich nicht schätzen, sondern
380 nachspielen: `antwort_log` ist die vollständige Historie und wird nie
381 überschrieben; der Kommentar über `frage_stand` im Schema sagt selbst,
382 dort stehe nur die Zusammenfassung, „die Wahrheit steht in antwort_log“.
383
384 Dieser Test schreibt eine Historie von Hand, nimmt die Spalte weg, öffnet
385 neu – und verlangt, dass genau die Fragen belegt sind, die es nach der
386 Regel sein müssen. Ein Vorgabewert (etwa „alles, was zuletzt richtig
387 war“) käme durch die anderen Tests durch und wäre trotzdem falsch.
388 */
389 const { lernstand, db } = oeffnen();
390 const profil = lernstand.profile()[0]!;
391 lernstand.schliessen();
392
393 const roh = new Database(pfad);
394 verbindungen.push(roh);
395
396 /* Vier Fragen, vier Verläufe. Die erwarteten Ergebnisse stehen daneben. */
397 const historie: readonly (readonly [string, number, string])[] = [
398 // I.1-01: einmal richtig – kein Beleg, das erste Mal zählt nie.
399 ['I.1-01', 1, '2026-01-01T09:00:00.000Z'],
400 // I.1-02: richtig, dann zwei Tage später wieder richtig – belegt.
401 ['I.1-02', 1, '2026-01-01T09:00:00.000Z'],
402 ['I.1-02', 1, '2026-01-03T09:00:00.000Z'],
403 // I.2-01: wie oben belegt, danach aber falsch – Beleg wieder weg.
404 ['I.2-01', 1, '2026-01-01T09:00:00.000Z'],
405 ['I.2-01', 1, '2026-01-03T09:00:00.000Z'],
406 ['I.2-01', 0, '2026-01-10T09:00:00.000Z'],
407 // II-01: zweimal richtig am selben Tag – kein Beleg, kein Schaden.
408 ['II-01', 1, '2026-01-01T09:00:00.000Z'],
409 ['II-01', 1, '2026-01-01T21:00:00.000Z'],
410 ];
411
412 const eintragen = roh.prepare(
413 `INSERT INTO antwort_log (profil_id, frage_id, zeitpunkt, richtig, bewertung, dauer_ms)
414 VALUES (?, ?, ?, ?, ?, 1500)`,
415 );
416 const stand = roh.prepare(
417 `INSERT INTO frage_stand (profil_id, frage_id, versuche, richtige, zuletzt_beantwortet)
418 VALUES (?, ?, 1, 1, ?)
419 ON CONFLICT (profil_id, frage_id) DO UPDATE SET versuche = versuche + 1`,
420 );
421 for (const [frageId, richtig, zeitpunkt] of historie) {
422 eintragen.run(profil.id, frageId, zeitpunkt, richtig, richtig === 1 ? 'gut' : 'nochmal');
423 stand.run(profil.id, frageId, zeitpunkt);
424 }
425
426 roh.exec('ALTER TABLE frage_stand DROP COLUMN bestaetigt');
427 roh.prepare('DELETE FROM schema_version WHERE version >= 7').run();
428 roh.close();
429
430 const zweite = oeffnen();
431 const belegt = zweite.db
432 .prepare<[], { frage_id: string }>(
433 'SELECT frage_id FROM frage_stand WHERE bestaetigt = 1 ORDER BY frage_id',
434 )
435 .all()
436 .map((z) => z.frage_id);
437
438 expect(belegt).toEqual(['I.1-02']);
439 expect(zweite.db.prepare('SELECT MAX(version) AS v FROM schema_version').get()).toEqual({
440 v: SCHEMA_VERSION,
441 });
442 void db;
443 });
444
445 it('ergänzt nur_historie und lässt bestehende Zeilen mitzählen', () => {
446 /*
447 Schemafassung 8. Rückwirkend lässt sich nicht ermitteln, welche
448 Protokollzeilen zu nie gestellten Prüfungsfragen gehören – sie bekommen
449 deshalb 0 und zählen weiter mit. Das ist die ehrlichere Wahl: Zu raten
450 hiesse, Zahlen zu ändern, von denen niemand weiss, ob sie falsch waren.
451 */
452 const { lernstand } = oeffnen();
453 const profil = lernstand.profile()[0]!;
454 lernstand.antworten(profil.id, {
455 frageId: 'I.1-01',
456 auswahl: ['a'],
457 richtig: true,
458 bewertung: 'gut',
459 dauerMs: 1000,
460 });
461 lernstand.schliessen();
462
463 const roh = new Database(pfad);
464 verbindungen.push(roh);
465 roh.exec('ALTER TABLE antwort_log DROP COLUMN nur_historie');
466 roh.prepare('DELETE FROM schema_version WHERE version >= 8').run();
467 roh.close();
468
469 const zweite = oeffnen();
470
471 expect(
472 zweite.db
473 .prepare<[], { anzahl: number }>(
474 'SELECT COUNT(*) AS anzahl FROM antwort_log WHERE nur_historie = 0',
475 )
476 .get()?.anzahl,
477 ).toBe(1);
478 /* Und die bestehende Zeile zählt weiterhin in die Tagesbilanz. */
479 expect(zweite.lernstand.uebersicht(profil.id).heuteRichtig).toBe(1);
480 });
481
482 it('vermerkt beim Aufstieg auf Fassung 9 den geladenen Katalogstand als Ausgangswert', () => {
483 /*
484 Schemafassung 9. Gegen welchen Katalogstand die vorhandenen Zeilen
485 wirklich entstanden, wurde nie festgehalten und lässt sich nicht
486 rekonstruieren – der geladene Stand ist der ehrlichste Ausgangswert.
487 Und er darf keine Meldung auslösen: Für den Nutzer hat sich an diesem
488 Tag nichts geändert, ein Alarm wäre erfunden.
489 */
490 const { lernstand } = oeffnen();
491 lernstand.schliessen();
492
493 const roh = new Database(pfad);
494 verbindungen.push(roh);
495 roh.exec('DROP TABLE katalog_stand');
496 roh.prepare('DELETE FROM schema_version WHERE version >= 9').run();
497 roh.close();
498
499 const zweite = oeffnen();
500
501 expect(zweite.lernstand.katalogwechsel).toBeNull();
502 expect(
503 zweite.db
504 .prepare<[], { stand: string }>('SELECT stand FROM katalog_stand ORDER BY id DESC LIMIT 1')
505 .get(),
506 ).toEqual({ stand: '2024-12-16' });
507 expect(zweite.db.prepare('SELECT MAX(version) AS v FROM schema_version').get()).toEqual({
508 v: SCHEMA_VERSION,
509 });
510 });
511
512 it('läuft ein zweites Mal durch, ohne zu scheitern', () => {
513 /* Ein nacktes ALTER TABLE würfe beim zweiten Lauf „duplicate column
514 name“, und eine einmal aus dem Tritt geratene Datenbank wäre dauerhaft
515 nicht mehr zu öffnen. `spalteErgaenzen` sieht deshalb erst nach. */
516 const { lernstand } = oeffnen();
517 lernstand.schliessen();
518
519 const roh = new Database(pfad);
520 verbindungen.push(roh);
521 roh.prepare('DELETE FROM schema_version WHERE version >= 6').run();
522 roh.close();
523
524 expect(() => oeffnen()).not.toThrow();
525 });
526 });