waffensachkunde

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

/ app tests selbstsicherung.test.ts

7,1 KB Rohdatei
app/tests/selbstsicherung.test.ts — 192 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 { existsSync, mkdirSync, readdirSync, rmSync, mkdtempSync, utimesSync } from 'node:fs';
17 import { tmpdir } from 'node:os';
18 import { join } from 'node:path';
19
20 import Database from 'better-sqlite3';
21 import { afterEach, beforeEach, describe, expect, it } from 'vitest';
22
23 import { Lernstand } from '../src/main/lernstand';
24 import {
25 ABSTAND_MS,
26 istFaellig,
27 KOPIEN_BEHALTEN,
28 selbstsicherungAnlegen,
29 selbstsicherungsordner,
30 SELBSTSICHERUNG_VORSATZ,
31 } from '../src/main/selbstsicherung';
32 import type { Katalog } from '../src/shared/katalog';
33
34 const KATALOG: Katalog = {
35 meta: { titel: 'Test', herausgeber: 'Test', stand: '2024-12-16', quelle: 'Test' },
36 kapitel: [{ id: 'I', titel: 'Recht', abschnitte: [{ id: 'I.1', titel: 'Grundlagen' }] }],
37 bilder: {},
38 fragen: [
39 {
40 id: 'I.1-1',
41 kapitel: 'I',
42 abschnitt: 'I.1',
43 nummer: '1',
44 frage: 'Eine Frage?',
45 optionen: [{ label: 'a', text: 'Antwort', richtig: true }],
46 },
47 ],
48 } as unknown as Katalog;
49
50 let userData = '';
51 let db: Database.Database;
52
53 /** Alle Kopien im Sicherungsordner. */
54 function kopien(): string[] {
55 const ordner = selbstsicherungsordner(userData);
56 return existsSync(ordner)
57 ? readdirSync(ordner).filter((name) => name.startsWith(SELBSTSICHERUNG_VORSATZ))
58 : [];
59 }
60
61 beforeEach(() => {
62 userData = mkdtempSync(join(tmpdir(), 'wsk-selbstsicherung-'));
63 db = new Database(join(userData, 'lernstand.db'));
64 new Lernstand(db, KATALOG);
65 });
66
67 afterEach(() => {
68 db.close();
69 rmSync(userData, { recursive: true, force: true });
70 });
71
72 describe('Fälligkeit', () => {
73 it('ist fällig, wenn noch nie gesichert wurde', () => {
74 expect(istFaellig(null, Date.now())).toBe(true);
75 });
76
77 it('ist nicht fällig, solange die letzte Kopie frisch ist', () => {
78 const jetzt = Date.UTC(2026, 7, 29);
79 expect(istFaellig(jetzt - ABSTAND_MS + 1000, jetzt)).toBe(false);
80 });
81
82 it('ist fällig, sobald der Abstand erreicht ist', () => {
83 const jetzt = Date.UTC(2026, 7, 29);
84 expect(istFaellig(jetzt - ABSTAND_MS, jetzt)).toBe(true);
85 });
86
87 it('gilt eine Kopie aus der Zukunft als überfällig', () => {
88 /* Ein Uhrsprung oder ein kopierter Ordner kann einen Zeitstempel aus der
89 Zukunft hinterlassen. Gälte er als frisch, unterbliebe die Sicherung
90 bis zu dem Tag, den er behauptet – unter Umständen jahrelang. */
91 const jetzt = Date.UTC(2026, 7, 29);
92 expect(istFaellig(jetzt + ABSTAND_MS, jetzt)).toBe(true);
93 });
94 });
95
96 describe('Kopie anlegen', () => {
97 it('legt beim ersten Mal eine Kopie an', () => {
98 const pfad = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00'));
99
100 expect(pfad).not.toBeNull();
101 expect(existsSync(pfad ?? '')).toBe(true);
102 expect(kopien()).toHaveLength(1);
103 });
104
105 it('schreibt eine Kopie, die sich wirklich öffnen lässt', () => {
106 /* Eine Sicherungsdatei, die niemand einlesen kann, ist schlimmer als
107 keine: Sie wiegt in Sicherheit, bis es darauf ankommt. */
108 const pfad = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00'));
109
110 const kopie = new Database(pfad ?? '', { readonly: true });
111 try {
112 const zeilen = kopie.pragma('integrity_check') as { integrity_check: string }[];
113 expect(zeilen[0]?.integrity_check).toBe('ok');
114 const profile = kopie
115 .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM profil')
116 .get();
117 expect(profile?.anzahl).toBeGreaterThan(0);
118 } finally {
119 kopie.close();
120 }
121 });
122
123 it('legt keine zweite Kopie an, solange die erste frisch ist', () => {
124 const erste = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00'));
125 /* Über die Frische entscheidet die Änderungszeit der Datei, nicht ihr
126 Name. Ohne dieses Zurückstellen redete die Systemuhr mit: Die Kopie
127 entsteht im Augenblick des Laufs, und sobald dieser Augenblick nach
128 dem zweiten Aufruf liegt, gilt sie nach `istFaellig` als aus der
129 Zukunft und damit als überfällig. Der Fall war keine Theorie – ab dem
130 30.08.2026, 20:00 Uhr war dieser Test rot, ohne dass sich am geprüften
131 Verhalten etwas geändert hätte. */
132 const angelegt = new Date('2026-08-29T20:00:00');
133 utimesSync(erste ?? '', angelegt, angelegt);
134
135 const zweite = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-30T20:00:00'));
136
137 expect(zweite).toBeNull();
138 expect(kopien()).toHaveLength(1);
139 });
140
141 it('legt wieder eine an, sobald der Abstand überschritten ist', () => {
142 const erste = selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00'));
143 /* Entscheidend ist die Änderungszeit der Datei, nicht der Name – sie
144 wird hier zurückgestellt, statt eine Woche zu warten. */
145 const alt = new Date(Date.now() - ABSTAND_MS - 60_000);
146 utimesSync(erste ?? '', alt, alt);
147
148 const zweite = selbstsicherungAnlegen(db, userData, Database, new Date());
149
150 expect(zweite).not.toBeNull();
151 expect(kopien()).toHaveLength(2);
152 });
153
154 it('behält nur die jüngsten Kopien', () => {
155 for (let lauf = 0; lauf < KOPIEN_BEHALTEN + 2; lauf++) {
156 const pfad = selbstsicherungAnlegen(db, userData, Database, new Date(2026, 7, 1 + lauf, 20));
157 if (pfad !== null) {
158 const alt = new Date(Date.now() - ABSTAND_MS * (10 - lauf));
159 utimesSync(pfad, alt, alt);
160 }
161 }
162
163 expect(kopien().length).toBeLessThanOrEqual(KOPIEN_BEHALTEN);
164 });
165
166 it('rührt fremde Dateien im Ordner nicht an', () => {
167 /* Aufgeräumt wird ausschließlich das eigene Muster. Ein Ordner, in dem
168 ein Programm nach Gutdünken löscht, ist kein Ort für Sicherungen. */
169 const ordner = selbstsicherungsordner(userData);
170 mkdirSync(ordner, { recursive: true });
171 const fremd = join(ordner, 'Wichtige-eigene-Datei.wsklernstand');
172 new Database(fremd).close();
173
174 for (let lauf = 0; lauf < KOPIEN_BEHALTEN + 2; lauf++) {
175 const pfad = selbstsicherungAnlegen(db, userData, Database, new Date(2026, 7, 1 + lauf, 20));
176 if (pfad !== null) {
177 const alt = new Date(Date.now() - ABSTAND_MS * (10 - lauf));
178 utimesSync(pfad, alt, alt);
179 }
180 }
181
182 expect(existsSync(fremd)).toBe(true);
183 });
184
185 it('legt den Ordner selbst an', () => {
186 expect(existsSync(selbstsicherungsordner(userData))).toBe(false);
187
188 selbstsicherungAnlegen(db, userData, Database, new Date('2026-08-29T20:00:00'));
189
190 expect(existsSync(selbstsicherungsordner(userData))).toBe(true);
191 });
192 });