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