waffensachkunde

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

/ app tests sicherung.test.ts

23,0 KB Rohdatei
app/tests/sicherung.test.ts — 599 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 einstellungenAusSicherung,
30 kennzahlenLesen,
31 nebendateienAufraeumen,
32 sicherungsDateiname,
33 reisendeEinstellungen,
34 sicherungSchreiben,
35 } from '../src/main/sicherung';
36 import { EINSTELLUNGEN_STANDARD } from '../src/shared/ipc';
37
38 let ordner: string;
39 let offene: Database.Database[];
40
41 /** Ein Lernstand, wie ihn die Anwendung anlegt – im WAL-Modus. */
42 function lernstandAnlegen(pfad: string, antworten = 5): Database.Database {
43 const db = new Database(pfad);
44 offene.push(db);
45 db.pragma('journal_mode = WAL');
46 db.exec(`
47 CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
48 CREATE TABLE profil (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
49 CREATE TABLE frage_stand (
50 profil_id INTEGER NOT NULL REFERENCES profil(id),
51 frage_id TEXT NOT NULL,
52 gemerkt INTEGER NOT NULL DEFAULT 0,
53 PRIMARY KEY (profil_id, frage_id)
54 );
55 CREATE TABLE antwort_log (
56 id INTEGER PRIMARY KEY AUTOINCREMENT,
57 profil_id INTEGER NOT NULL REFERENCES profil(id),
58 zeitpunkt TEXT NOT NULL
59 );
60 `);
61 for (let version = 1; version <= SCHEMA_VERSION; version++) {
62 db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(version);
63 }
64 db.prepare('INSERT INTO profil (name) VALUES (?)').run('Standard');
65 const einfuegen = db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt) VALUES (1, ?)');
66 const alle = db.transaction(() => {
67 for (let i = 0; i < antworten; i++) {
68 einfuegen.run(`2026-08-${String(10 + (i % 10)).padStart(2, '0')}T09:00:00.000Z`);
69 }
70 });
71 alle();
72 db.prepare('INSERT INTO frage_stand (profil_id, frage_id, gemerkt) VALUES (1, ?, 1)').run(
73 'I.1-01',
74 );
75 return db;
76 }
77
78 beforeEach(() => {
79 ordner = mkdtempSync(join(tmpdir(), 'wsk-sicherung-'));
80 offene = [];
81 });
82
83 afterEach(() => {
84 for (const db of offene) {
85 try {
86 db.close();
87 } catch {
88 /* Schon zu. */
89 }
90 }
91 rmSync(ordner, { recursive: true, force: true });
92 });
93
94 describe('Warum eine bloße Dateikopie nicht genügt', () => {
95 it('verliert bei offener Verbindung im WAL-Modus alles – und meldet trotzdem „ok“', () => {
96 /*
97 Der Messwert, der die ganze Umsetzung begründet. Er steht hier als Test,
98 damit niemand später auf die naheliegende Abkürzung verfällt: Die Kopie
99 ist nicht bloß älter, sie ist LEER – bei tadellosem integrity_check.
100 */
101 const quelle = join(ordner, 'lernstand.db');
102 lernstandAnlegen(quelle, 200);
103
104 const kopie = join(ordner, 'nur-hauptdatei.db');
105 copyFileSync(quelle, kopie);
106
107 const db = new Database(kopie, { readonly: true });
108 offene.push(db);
109 const zeilen = db.pragma('integrity_check') as { integrity_check: string }[];
110 expect(zeilen[0]?.integrity_check).toBe('ok');
111
112 const tabellen = db
113 .prepare<[], { name: string }>("SELECT name FROM sqlite_master WHERE type = 'table'")
114 .all();
115 expect(tabellen).toHaveLength(0);
116 });
117 });
118
119 describe('Eine Sicherung schreiben', () => {
120 it('nimmt alles mit, obwohl die Verbindung offen bleibt', () => {
121 const quelle = join(ordner, 'lernstand.db');
122 const db = lernstandAnlegen(quelle, 200);
123
124 const ziel = join(ordner, 'sicherung.wsklernstand');
125 const bytes = sicherungSchreiben(db, ziel, Database);
126
127 expect(bytes).toBeGreaterThan(0);
128 const gelesen = new Database(ziel, { readonly: true });
129 offene.push(gelesen);
130 expect(
131 gelesen.prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM antwort_log').get()
132 ?.anzahl,
133 ).toBe(200);
134 });
135
136 it('hinterlässt genau eine Datei, auch nachdem sie gelesen wurde', () => {
137 /*
138 Der Grund gegen `db.backup()`: Dessen Ergebnis steht im WAL-Modus, und
139 wer es auch nur ansieht, erzeugt daneben `-wal` und `-shm`, die nach dem
140 Schließen liegen bleiben. Eine Sicherung, von der man zwei Dateien
141 vergisst, ist genau der Fehler, den sie verhüten soll.
142 */
143 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
144 const ziel = join(ordner, 'sicherung.wsklernstand');
145 sicherungSchreiben(db, ziel, Database);
146
147 const gelesen = new Database(ziel, { readonly: true });
148 expect((gelesen.pragma('journal_mode') as { journal_mode: string }[])[0]?.journal_mode).toBe(
149 'delete',
150 );
151 gelesen.close();
152
153 expect(existsSync(`${ziel}-wal`)).toBe(false);
154 expect(existsSync(`${ziel}-shm`)).toBe(false);
155 });
156
157 it('trägt die Kennung dieser Anwendung und die Schemafassung im Kopf', () => {
158 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
159 const ziel = join(ordner, 'sicherung.wsklernstand');
160 sicherungSchreiben(db, ziel, Database);
161
162 const gelesen = new Database(ziel, { readonly: true });
163 offene.push(gelesen);
164 expect(
165 (gelesen.pragma('application_id') as { application_id: number }[])[0]?.application_id,
166 ).toBe(ANWENDUNGSKENNUNG);
167 expect((gelesen.pragma('user_version') as { user_version: number }[])[0]?.user_version).toBe(
168 SCHEMA_VERSION,
169 );
170 });
171
172 it('lässt keine Teildatei zurück', () => {
173 /* Erst nach `.teil`, dann umbenennen: Ein Abbruch mitten im Schreiben
174 hinterlässt damit nie eine halbe Datei unter dem richtigen Namen. */
175 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
176 const ziel = join(ordner, 'sicherung.wsklernstand');
177 sicherungSchreiben(db, ziel, Database);
178
179 expect(existsSync(`${ziel}.teil`)).toBe(false);
180 });
181
182 it('nimmt einen Pfad mit Anführungszeichen im Namen', () => {
183 /* Der Zielpfad wird gebunden und nicht in die Anweisung eingesetzt.
184 Eingesetzt zerlegte ein Anführungszeichen die Anweisung. */
185 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
186 const ziel = join(ordner, "Olafs 'Sicherung'.wsklernstand");
187
188 expect(() => sicherungSchreiben(db, ziel, Database)).not.toThrow();
189 expect(existsSync(ziel)).toBe(true);
190 });
191 });
192
193 describe('Die Prüfkette', () => {
194 it('nimmt eine echte Sicherung an und beziffert sie', () => {
195 const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 42);
196 const ziel = join(ordner, 'sicherung.wsklernstand');
197 sicherungSchreiben(db, ziel, Database);
198
199 const befund = dateiPruefen(ziel, Database);
200
201 expect(befund.art).toBe('brauchbar');
202 if (befund.art !== 'brauchbar') {
203 return;
204 }
205 expect(befund.kennzahlen.profilnamen).toEqual(['Standard']);
206 expect(befund.kennzahlen.antworten).toBe(42);
207 expect(befund.kennzahlen.gemerkt).toBe(1);
208 expect(befund.kennzahlen.schemafassung).toBe(SCHEMA_VERSION);
209 expect(befund.kennzahlen.letzteAntwort).not.toBeNull();
210 });
211
212 it('weist eine leere Datei ab – der gefährlichste Fall überhaupt', () => {
213 /*
214 Eine Datei von null Byte besteht `integrity_check` mit „ok“ und hat null
215 Tabellen. Ohne diese Abweisung durchliefe sie die vollständige
216 Migrationskette und stünde als tadelloser, LEERER Lernstand da. Das
217 Einspielen meldete Erfolg, und die Arbeit von Wochen wäre fort.
218 */
219 const leer = join(ordner, 'leer.wsklernstand');
220 writeFileSync(leer, '');
221
222 const befund = dateiPruefen(leer, Database);
223
224 expect(befund.art).toBe('abgelehnt');
225 expect(befund.art === 'abgelehnt' && befund.grund).toContain('Es wurde nichts verändert.');
226 });
227
228 it('weist eine gültige, aber fremde SQLite-Datenbank ab', () => {
229 /* Auch eine Datenbank mit null Tabellen ist syntaktisch tadellos. Was
230 fehlt, sind die Tabellen dieser Anwendung. */
231 const fremd = join(ordner, 'fremd.db');
232 const db = new Database(fremd);
233 db.exec('CREATE TABLE adressen (id INTEGER PRIMARY KEY, name TEXT)');
234 db.close();
235
236 const befund = dateiPruefen(fremd, Database);
237
238 expect(befund.art).toBe('abgelehnt');
239 expect(befund.art === 'abgelehnt' && befund.grund).toContain('kein Lernstand dieser Anwendung');
240 });
241
242 it('weist ein umbenanntes Bild ab, ohne eine englische Datenbankmeldung', () => {
243 /* Eine Textdatei lässt sich readonly ÖFFNEN – erst die erste Abfrage
244 wirft SQLITE_NOTADB. Deshalb wird der Dateikopf vorher gelesen. */
245 const foto = join(ordner, 'urlaub.wsklernstand');
246 writeFileSync(foto, 'x'.repeat(2048));
247
248 const befund = dateiPruefen(foto, Database);
249
250 expect(befund.art).toBe('abgelehnt');
251 expect(befund.art === 'abgelehnt' && befund.grund).toContain('keine Datenbank');
252 expect(befund.art === 'abgelehnt' && befund.grund).not.toMatch(/SQLITE_|not a database/u);
253 });
254
255 it('weist eine abgeschnittene Datei ab', () => {
256 const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 500);
257 const ziel = join(ordner, 'sicherung.wsklernstand');
258 sicherungSchreiben(db, ziel, Database);
259
260 const halb = join(ordner, 'halb.wsklernstand');
261 const { size } = statSync(ziel);
262 const inhalt = readFileSync(ziel);
263 writeFileSync(halb, inhalt.subarray(0, Math.floor(size * 0.4)));
264
265 const befund = dateiPruefen(halb, Database);
266
267 expect(befund.art).toBe('abgelehnt');
268 });
269
270 it('weist eine Sicherung aus einer neueren Programmfassung ab', () => {
271 /* Vorwärts migrieren kann die Anwendung, rückwärts nicht. Eine neuere
272 Sicherung stillschweigend anzunehmen hiesse, Spalten zu verlieren. */
273 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
274 const ziel = join(ordner, 'sicherung.wsklernstand');
275 sicherungSchreiben(db, ziel, Database);
276
277 /* Die Fassung wird erst NACH dem Schreiben hochgesetzt: Diese Anwendung
278 kann eine solche Datei gar nicht erzeugen – sie entsteht auf einem
279 Rechner mit neuerem Programm. */
280 const zukunft = join(ordner, 'zukunft.wsklernstand');
281 copyFileSync(ziel, zukunft);
282 const neuer = new Database(zukunft);
283 neuer.prepare('INSERT INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION + 1);
284 neuer.close();
285
286 const befund = dateiPruefen(zukunft, Database);
287
288 expect(befund.art).toBe('abgelehnt');
289 expect(befund.art === 'abgelehnt' && befund.grund).toContain('neueren Fassung');
290 });
291
292 it('nimmt eine ältere Sicherung an – die Migrationskette zieht sie hoch', () => {
293 /*
294 `docs/stand.md` sagte „bei gleicher Schemafassung". Das ist zu streng:
295 Eine alte Sicherung einzuspielen ist byteweise dieselbe Handlung wie ein
296 alter Lernstand nach einem Programmupdate, und diesen Weg geht jeder
297 Nutzer nach jeder Aktualisierung.
298 */
299 const db = lernstandAnlegen(join(ordner, 'lernstand.db'));
300 db.prepare('DELETE FROM schema_version WHERE version > 3').run();
301 const ziel = join(ordner, 'alt.wsklernstand');
302 sicherungSchreiben(db, ziel, Database);
303
304 const befund = dateiPruefen(ziel, Database);
305
306 expect(befund.art).toBe('brauchbar');
307 expect(befund.art === 'brauchbar' && befund.kennzahlen.schemafassung).toBe(3);
308 });
309
310 it('lässt nach jeder Abweisung die Datei wieder los', () => {
311 /* Bleibt die Verbindung offen, ist die Datei unter Windows gesperrt und
312 liesse sich weder löschen noch umbenennen. */
313 const fremd = join(ordner, 'fremd.db');
314 const db = new Database(fremd);
315 db.exec('CREATE TABLE adressen (id INTEGER PRIMARY KEY)');
316 db.close();
317
318 expect(dateiPruefen(fremd, Database).art).toBe('abgelehnt');
319 expect(() => {
320 rmSync(fremd);
321 }).not.toThrow();
322 });
323 });
324
325 describe('Der Dateiname', () => {
326 it('trägt Datum und Uhrzeit auf die Sekunde genau', () => {
327 /* Auf die Sekunde, damit zwei Sicherungen in derselben Minute einander
328 nicht überschreiben – die selbsttätige Sicherheitskopie vor dem
329 Einspielen fragt niemanden. */
330 const name = sicherungsDateiname(new Date(2026, 7, 22, 14, 32, 7));
331
332 expect(name).toBe('Waffensachkunde-Lernstand-2026-08-22-143207.wsklernstand');
333 });
334
335 it('nennt keinen Profilnamen', () => {
336 /* Gesichert wird die ganze Datenbank mit allen Profilen. Ein Name darin
337 wäre gelogen und verleitete dazu, die Datei später für die Sicherung
338 eines einzelnen Profils zu halten. */
339 expect(sicherungsDateiname(new Date())).not.toMatch(/Standard|Olaf/u);
340 });
341 });
342
343 describe('Aufräumen und Kennzahlen', () => {
344 /*
345 Nachgemessen: Wird eine Datenbankdatei nur LESEND geöffnet, bleiben
346 danach ein 0 Byte grosses `-wal` und ein 32 KiB grosses `-shm` liegen.
347 Im heutigen Ablauf ist das harmlos – der nächste Durchgang liest die
348 richtige Datei. Läge dort aber je ein GEFÜLLTES `-wal`, bekäme SQLite den
349 Inhalt der vorigen Datenbank untergeschoben, bei grünem
350 `integrity_check`; nachgestellt: erwartet 900 Zeilen, gelesen 5000 aus der
351 anderen Datei. Deshalb wird es entfernt, bevor der erste schreibende
352 Zugriff auf eine Arbeitskopie dazukommt.
353 */
354 it('nimmt beim Aufräumen die Nebendateien mit', () => {
355 const pfad = join(ordner, 'stand.db');
356 /* Die schreibende Verbindung muss zu sein, sonst hält Windows die
357 Nebendateien und der Test misst das Betriebssystem statt den Code. */
358 lernstandAnlegen(pfad).close();
359
360 const lesend = new Database(pfad, { readonly: true });
361 offene.push(lesend);
362 lesend.prepare('SELECT COUNT(*) AS n FROM antwort_log').get();
363 lesend.close();
364
365 expect(existsSync(`${pfad}-shm`)).toBe(true);
366
367 aufraeumen(pfad);
368
369 expect(existsSync(pfad)).toBe(false);
370 expect(existsSync(`${pfad}-wal`)).toBe(false);
371 expect(existsSync(`${pfad}-shm`)).toBe(false);
372 });
373
374 it('stört sich nicht daran, wenn nichts da ist', () => {
375 expect(() => {
376 aufraeumen(join(ordner, 'gibt-es-nicht.db'));
377 }).not.toThrow();
378 });
379
380 /*
381 Beim Anlegen einer Sicherung über eine vorhandene darf nur das
382 Schreibprotokoll fallen, nie die Datei selbst: Bis 0.24.1 wurde sie vorher
383 gelöscht, und ein Fehlschlag danach nahm beide Fassungen mit.
384 */
385 it('räumt nur die Nebendateien weg und lässt die Sicherung stehen', () => {
386 const pfad = join(ordner, 'sicherung.wsklernstand');
387 writeFileSync(pfad, 'ALTE SICHERUNG', 'utf8');
388 writeFileSync(`${pfad}-wal`, 'protokoll', 'utf8');
389 writeFileSync(`${pfad}-shm`, 'gemeinsam', 'utf8');
390
391 nebendateienAufraeumen(pfad);
392
393 expect(readFileSync(pfad, 'utf8')).toBe('ALTE SICHERUNG');
394 expect(existsSync(`${pfad}-wal`)).toBe(false);
395 expect(existsSync(`${pfad}-shm`)).toBe(false);
396 });
397
398 /*
399 Der Grund, warum das Vorab-Löschen entbehrlich ist: `sicherungSchreiben`
400 schreibt nach `<ziel>.teil` und schiebt die fertige Datei mit `renameSync`
401 an ihren Platz. Das ersetzt ein vorhandenes Ziel unteilbar.
402 */
403 it('ersetzt eine vorhandene Zieldatei, ohne dass sie vorher gelöscht wird', () => {
404 const db = lernstandAnlegen(join(ordner, 'lernstand.db'), 30);
405 const ziel = join(ordner, 'sicherung.wsklernstand');
406 writeFileSync(ziel, 'ALTE SICHERUNG', 'utf8');
407
408 const bytes = sicherungSchreiben(db, ziel, Database);
409
410 expect(bytes).toBeGreaterThan(0);
411 expect(dateiPruefen(ziel, Database).art).toBe('brauchbar');
412 });
413
414 it('lässt die vorhandene Zieldatei unangetastet, wenn das Schreiben scheitert', () => {
415 /* Eine Quelle ohne die Pflichttabellen: Die Gegenprobe in
416 `sicherungSchreiben` lehnt das Ergebnis ab und wirft. */
417 const untauglich = new Database(join(ordner, 'untauglich.db'));
418 offene.push(untauglich);
419 untauglich.exec('CREATE TABLE irgendwas (a INTEGER)');
420
421 const ziel = join(ordner, 'sicherung.wsklernstand');
422 writeFileSync(ziel, 'ALTE SICHERUNG', 'utf8');
423
424 expect(() => sicherungSchreiben(untauglich, ziel, Database)).toThrow();
425 expect(readFileSync(ziel, 'utf8')).toBe('ALTE SICHERUNG');
426 expect(existsSync(`${ziel}.teil`)).toBe(false);
427 });
428
429 /*
430 `antwort_log` enthält seit Schemafassung 8 auch Zeilen mit
431 `nur_historie = 1`: Fragen eines abgelaufenen Prüfungsbogens, die nie
432 aufgeschlagen wurden. Sie gehören in die Historie, aber nicht in eine
433 Zahl, über die jemand einen nicht rücknehmbaren Schritt freigibt.
434 */
435 it('zählt nie aufgeschlagene Prüfungsfragen nicht als Antworten', () => {
436 const pfad = join(ordner, 'mit-historie.db');
437 const db = lernstandAnlegen(pfad, 5);
438 db.exec('ALTER TABLE antwort_log ADD COLUMN nur_historie INTEGER NOT NULL DEFAULT 0');
439 db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt, nur_historie) VALUES (1, ?, 1)').run(
440 '2026-08-20T09:00:00.000Z',
441 );
442 db.prepare('INSERT INTO antwort_log (profil_id, zeitpunkt, nur_historie) VALUES (1, ?, 1)').run(
443 '2026-08-21T09:00:00.000Z',
444 );
445
446 const kennzahlen = kennzahlenLesen(db, SCHEMA_VERSION, new Set(['profil', 'antwort_log']));
447
448 expect(db.prepare('SELECT COUNT(*) AS n FROM antwort_log').get()).toEqual({ n: 7 });
449 expect(kennzahlen.antworten).toBe(5);
450 });
451
452 it('zählt bei älteren Sicherungen ohne die Spalte weiterhin alles', () => {
453 /* Rückwirkend liesse sich nicht ermitteln, welche Zeile nie gestellt
454 wurde. Zu raten wäre schlechter, als es stehen zu lassen. */
455 const pfad = join(ordner, 'alt.db');
456 const db = lernstandAnlegen(pfad, 4);
457
458 const kennzahlen = kennzahlenLesen(db, 7, new Set(['profil', 'antwort_log']));
459
460 expect(kennzahlen.antworten).toBe(4);
461 });
462 });
463
464 describe('Die Einstellungen reisen mit der Sicherung', () => {
465 /*
466 Bis Fassung 0.26.7 enthielt eine Sicherung Profile, Antworten, Merklisten,
467 Termine und Verlauf – aber keine Einstellung. Wer 400 Prozent
468 Anzeigegröße braucht, musste auf dem zweiten Rechner ohne sie anfangen,
469 um sie einzustellen. Genau das, was Barrierefreiheit herstellt, blieb
470 zurück.
471 */
472
473 it('schreibt die übergebenen Einstellungen in die Datei', () => {
474 const db = lernstandAnlegen(join(ordner, 'mit-einstellungen.db'), 3);
475 const ziel = join(ordner, 'mit-einstellungen.wsklernstand');
476
477 sicherungSchreiben(db, ziel, Database, { anzeigegroesse: 400, thema: 'dunkel' });
478
479 const gelesen = new Database(ziel, { readonly: true });
480 offene.push(gelesen);
481 expect(einstellungenAusSicherung(gelesen)).toEqual({ anzeigegroesse: 400, thema: 'dunkel' });
482 });
483
484 it('schreibt ohne Übergabe keine Tabelle – alte Sicherungen bleiben, wie sie waren', () => {
485 const db = lernstandAnlegen(join(ordner, 'ohne-einstellungen.db'), 3);
486 const ziel = join(ordner, 'ohne-einstellungen.wsklernstand');
487
488 sicherungSchreiben(db, ziel, Database);
489
490 const gelesen = new Database(ziel, { readonly: true });
491 offene.push(gelesen);
492 expect(
493 gelesen
494 .prepare<[], { anzahl: number }>(
495 "SELECT COUNT(*) AS anzahl FROM sqlite_master WHERE name = 'einstellungen_kopie'",
496 )
497 .get()?.anzahl,
498 ).toBe(0);
499 expect(einstellungenAusSicherung(gelesen)).toBeNull();
500 });
501
502 it('liest eine Sicherung ohne die Tabelle als „keine Einstellungen“, nicht als Fehler', () => {
503 /* Der Regelfall der Vergangenheit: jede Sicherung vor 0.27.0. */
504 const db = lernstandAnlegen(join(ordner, 'alt.db'), 1);
505 offene.push(db);
506
507 expect(() => einstellungenAusSicherung(db)).not.toThrow();
508 expect(einstellungenAusSicherung(db)).toBeNull();
509 });
510
511 it('liest beschädigten Inhalt als „keine“ – das Einspielen darf daran nicht scheitern', () => {
512 const db = lernstandAnlegen(join(ordner, 'kaputt.db'), 1);
513 offene.push(db);
514 db.exec('CREATE TABLE einstellungen_kopie (inhalt TEXT NOT NULL)');
515 db.prepare<[string]>('INSERT INTO einstellungen_kopie (inhalt) VALUES (?)').run('{kein json');
516
517 expect(einstellungenAusSicherung(db)).toBeNull();
518 });
519
520 it.each(['profilId', 'fenster', 'letzteSicherung', 'zuschnittGefragt'])(
521 'lässt %s auch dann liegen, wenn es von Hand in die Datei geschrieben wurde',
522 (verboten) => {
523 /*
524 Die Auswahl gilt auf BEIDEN Seiten. Ohne die Filterung beim Lesen
525 brächte eine von Hand veränderte Sicherungsdatei eine Profilnummer
526 des fremden Rechners mit – und die zeigt hier auf ein anderes Profil
527 oder auf keines. Begründung je Feld bei EINSTELLUNGEN_REISEN.
528 */
529 const db = lernstandAnlegen(join(ordner, `verboten-${verboten}.db`), 1);
530 offene.push(db);
531 db.exec('CREATE TABLE einstellungen_kopie (inhalt TEXT NOT NULL)');
532 db.prepare<[string]>('INSERT INTO einstellungen_kopie (inhalt) VALUES (?)').run(
533 JSON.stringify({ [verboten]: 99, sprechtempo: 1.5 }),
534 );
535
536 expect(einstellungenAusSicherung(db)).toEqual({ sprechtempo: 1.5 });
537 },
538 );
539
540 it('reicht unsinnige Werte durch, statt sie hier zu prüfen', () => {
541 /*
542 Festgehalten, damit es niemand für eine Prüfstelle hält: Gelesen wird
543 die Form, nicht der Wert. Gereinigt wird auf dem Weg dahinter, in
544 `einstellungenBereinigen` – genau einmal und an einer Stelle. Stünde
545 hier eine zweite Prüfung, gäbe es zwei Wahrheiten darüber, was gilt.
546 */
547 const db = lernstandAnlegen(join(ordner, 'unsinn.db'), 1);
548 offene.push(db);
549 db.exec('CREATE TABLE einstellungen_kopie (inhalt TEXT NOT NULL)');
550 db.prepare<[string]>('INSERT INTO einstellungen_kopie (inhalt) VALUES (?)').run(
551 JSON.stringify({ anzeigegroesse: 'riesig', thema: 42 }),
552 );
553
554 expect(einstellungenAusSicherung(db)).toEqual({ anzeigegroesse: 'riesig', thema: 42 });
555 });
556
557 it('nimmt aus dem laufenden Betrieb nur die mitreisenden Felder', () => {
558 const stueck = reisendeEinstellungen({
559 ...EINSTELLUNGEN_STANDARD,
560 anzeigegroesse: 250,
561 profilId: 7,
562 letzteSicherung: '2026-09-01T00:00:00.000Z',
563 fenster: { breite: 1, hoehe: 2 },
564 zuschnittGefragt: true,
565 });
566
567 expect(stueck).not.toHaveProperty('profilId');
568 expect(stueck).not.toHaveProperty('letzteSicherung');
569 expect(stueck).not.toHaveProperty('fenster');
570 expect(stueck).not.toHaveProperty('zuschnittGefragt');
571 expect(stueck.anzeigegroesse).toBe(250);
572 });
573
574 it('ersetzt beim erneuten Sichern die mitgereisten Werte, statt sie zu häufen', () => {
575 /*
576 `VACUUM INTO` kopiert die laufende Datenbank. Wer eine Sicherung
577 eingespielt hat, trägt die Tabelle darin – ohne Löschen davor stünden
578 nach dem zweiten Sichern zwei Zeilen da, und gelesen würde die falsche.
579 */
580 const quelle = join(ordner, 'zweimal.db');
581 const db = lernstandAnlegen(quelle, 1);
582 db.exec('CREATE TABLE einstellungen_kopie (inhalt TEXT NOT NULL)');
583 db.prepare<[string]>('INSERT INTO einstellungen_kopie (inhalt) VALUES (?)').run(
584 JSON.stringify({ anzeigegroesse: 100 }),
585 );
586
587 const ziel = join(ordner, 'zweimal.wsklernstand');
588 sicherungSchreiben(db, ziel, Database, { anzeigegroesse: 400 });
589
590 const gelesen = new Database(ziel, { readonly: true });
591 offene.push(gelesen);
592 expect(
593 gelesen
594 .prepare<[], { anzahl: number }>('SELECT COUNT(*) AS anzahl FROM einstellungen_kopie')
595 .get()?.anzahl,
596 ).toBe(1);
597 expect(einstellungenAusSicherung(gelesen)).toEqual({ anzeigegroesse: 400 });
598 });
599 });