Baustein 10b: Anleitung mit Screenshots, Abnahmelauf, Fassung 1.0.0
docs/ANLEITUNG.md: 20 Abschnitte, 27 Bildschirmfotos, getrennt fuer Ausbilder und Azubi, dazu "Wenn etwas nicht geht". Die Bilder entstehen automatisiert ueber npm run screenshots - was dort steht, ist gemessen und nicht gemalt. scripts/pruefungen/abnahme.mjs (npm run abnahme) laeuft gegen eine frische, leere Datenbank in einem Wegwerfverzeichnis: aufsetzen, Konten anlegen, arbeiten, abnehmen, sichern, beschaedigen, zurueckspielen. Eine Sicherung, die nie zurueckgespielt wurde, ist keine Sicherung. 33 von 33 Pruefungen erfuellt, Protokoll in docs/ABNAHME.md. Dabei drei echte Fehler gefunden, alle in Wegen, die nie ausgefuehrt worden waren: - Die Sicherungsskripte liefen nicht in Git Bash, obwohl das in ihrem Kopf stand: tar deutet den Doppelpunkt in "C:\..." als Rechnernamen. Beide wechseln jetzt erst ins Verzeichnis und uebergeben einen blossen Dateinamen. - Auf einer frischen Datenbank gibt es noch kein Wochenblatt; der Weg jedes neuen Azubis war nie durchlaufen worden. - Der Abnahmelauf selbst liess unter Windows seinen Server ueberleben (kill() beendet nur die cmd.exe) und prueft deshalb jetzt vorher, ob der Port frei ist - sonst haette er gegen fremde Daten geprueft. Fassung 1.0.0: package.json stand noch auf 0.1.0, waehrend das Aenderungsprotokoll bei 0.9.0 war. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2babef9293
commit
c61d5711e0
@@ -0,0 +1,496 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Der Abnahmelauf.
|
||||
*
|
||||
* Anders als die Prüfungen der einzelnen Bausteine läuft dieser hier gegen
|
||||
* eine **frische, leere Datenbank** in einem Wegwerfverzeichnis. Er beweist
|
||||
* damit nicht nur, dass die Anwendung funktioniert, sondern dass sie sich aus
|
||||
* dem Nichts aufsetzen lässt: Migrationen laufen an, das erste Ausbilderkonto
|
||||
* entsteht auf der Kommandozeile, alles Weitere über die Oberfläche.
|
||||
*
|
||||
* Am Ende wird gesichert, absichtlich etwas kaputtgemacht und zurückgespielt.
|
||||
* Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung.
|
||||
*
|
||||
* node scripts/pruefungen/abnahme.mjs
|
||||
*
|
||||
* Voraussetzung: `npm run build` ist gelaufen.
|
||||
*/
|
||||
|
||||
const PORT = Number(process.env.LERNBAR_ABNAHME_PORT ?? 3399);
|
||||
const BASIS = `http://localhost:${PORT}`;
|
||||
|
||||
const AUSBILDER = { name: 'Petra Kessler', email: 'petra.kessler@betrieb.test', pw: 'abnahme-ausbilder' };
|
||||
const AZUBI = { name: 'Tim Vogt', email: 'tim.vogt@betrieb.test', pw: 'abnahme-azubi-2026' };
|
||||
|
||||
let fehler = 0;
|
||||
const protokoll = [];
|
||||
|
||||
function pruefe(bedingung, text, zusatz = '') {
|
||||
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
|
||||
protokoll.push({ erfuellt: Boolean(bedingung), text, zusatz });
|
||||
if (!bedingung) fehler += 1;
|
||||
}
|
||||
|
||||
function abschnitt(titel) {
|
||||
console.log(`\n${titel}`);
|
||||
protokoll.push({ abschnitt: titel });
|
||||
}
|
||||
|
||||
// --- Wegwerfumgebung -------------------------------------------------------
|
||||
|
||||
const daten = fs.mkdtempSync(path.join(os.tmpdir(), 'lernbar-abnahme-'));
|
||||
const umgebung = { ...process.env, LERNBAR_DATEN: daten, PORT: String(PORT), TZ: 'Europe/Berlin' };
|
||||
|
||||
console.log(`Wegwerfverzeichnis: ${daten}`);
|
||||
|
||||
let server = null;
|
||||
let browser = null;
|
||||
|
||||
/**
|
||||
* Den Server beenden – wirklich.
|
||||
*
|
||||
* Unter Windows beendet `kill()` auf einem über die Eingabeaufforderung
|
||||
* gestarteten Prozess nur diese, nicht den Node-Prozess darunter. Deshalb wird
|
||||
* hier ohne Eingabeaufforderung gestartet und zur Sicherheit der ganze Baum
|
||||
* abgeräumt. Ein überlebender Server aus einem früheren Lauf ist kein
|
||||
* Schönheitsfehler: Er belegt den Port, der nächste Lauf bekommt ihn nicht,
|
||||
* und dann prüft man stundenlang gegen den alten Datenbestand.
|
||||
*/
|
||||
function serverBeenden() {
|
||||
if (!server) return;
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawnSync('taskkill', ['/PID', String(server.pid), '/T', '/F'], { stdio: 'ignore' });
|
||||
} else {
|
||||
server.kill('SIGTERM');
|
||||
}
|
||||
} catch {
|
||||
/* egal */
|
||||
}
|
||||
server = null;
|
||||
}
|
||||
|
||||
function aufraeumen() {
|
||||
serverBeenden();
|
||||
try {
|
||||
fs.rmSync(daten, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Unter Windows hält der Serverprozess die Datei manchmal noch kurz.
|
||||
}
|
||||
}
|
||||
process.on('exit', aufraeumen);
|
||||
|
||||
/** Antwortet dort schon jemand? Dann ist es nicht unser Server. */
|
||||
async function portBelegt() {
|
||||
try {
|
||||
await fetch(`${BASIS}/api/gesundheit`, { signal: AbortSignal.timeout(2000) });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Wartet, bis der Server antwortet. */
|
||||
async function serverBereit(sekunden = 180) {
|
||||
for (let versuch = 0; versuch < sekunden * 2; versuch += 1) {
|
||||
if (server?.exitCode !== null && server?.exitCode !== undefined) return false;
|
||||
try {
|
||||
const antwort = await fetch(`${BASIS}/api/gesundheit`);
|
||||
if (antwort.ok) return true;
|
||||
} catch {
|
||||
/* noch nicht da */
|
||||
}
|
||||
await new Promise((weiter) => setTimeout(weiter, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function laufen(befehl, argumente, zusatz = {}) {
|
||||
return spawnSync(befehl, argumente, {
|
||||
env: umgebung,
|
||||
encoding: 'utf8',
|
||||
shell: process.platform === 'win32',
|
||||
...zusatz,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// --- A1 Aufsetzen aus dem Nichts -----------------------------------------
|
||||
|
||||
abschnitt('A1 · Aus dem Nichts aufsetzbar');
|
||||
|
||||
if (await portBelegt()) {
|
||||
throw new Error(
|
||||
`Auf ${BASIS} antwortet bereits etwas. Dieser Lauf braucht den Port für sich – ` +
|
||||
'sonst prüft er gegen einen fremden Datenbestand. Bitte beenden oder ' +
|
||||
'LERNBAR_ABNAHME_PORT setzen.',
|
||||
);
|
||||
}
|
||||
|
||||
const konto = laufen('npx', [
|
||||
'tsx', 'scripts/konto-anlegen.ts',
|
||||
'--name', AUSBILDER.name,
|
||||
'--email', AUSBILDER.email,
|
||||
'--rolle', 'ausbilder',
|
||||
'--passwort', AUSBILDER.pw,
|
||||
]);
|
||||
pruefe(konto.status === 0, 'Das erste Ausbilderkonto entsteht auf der Kommandozeile', (konto.stderr || '').trim().split('\n')[0] || 'ohne Fehler');
|
||||
pruefe(
|
||||
fs.existsSync(path.join(daten, 'lernbar.sqlite')),
|
||||
'Die Datenbank wurde angelegt und die Migrationen liefen an',
|
||||
);
|
||||
|
||||
// Ohne Eingabeaufforderung starten: nur dann lässt sich der Prozess später
|
||||
// auch wirklich beenden.
|
||||
server = spawn(process.execPath, [path.join('node_modules', 'next', 'dist', 'bin', 'next'), 'start', '-p', String(PORT)], {
|
||||
env: umgebung,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let serverAusgabe = '';
|
||||
server.stdout.on('data', (stueck) => (serverAusgabe += stueck));
|
||||
server.stderr.on('data', (stueck) => (serverAusgabe += stueck));
|
||||
|
||||
const laeuft = await serverBereit();
|
||||
pruefe(laeuft, 'Die Anwendung startet und meldet sich gesund', laeuft ? '' : serverAusgabe.slice(-400));
|
||||
if (!laeuft) throw new Error('Server nicht erreichbar.');
|
||||
|
||||
// Die Zusage lautet: Bei einem Inhaltsfehler startet die Anwendung nicht und
|
||||
// nennt Datei und Zeile. Das prüft man, indem man einen Fehler einbaut –
|
||||
// nicht, indem man in der Startausgabe nach einem Wort sucht.
|
||||
const heil = laufen('npx', ['tsx', 'scripts/inhalte-pruefen.ts']);
|
||||
pruefe(
|
||||
heil.status === 0,
|
||||
'Heile Inhalte werden ohne Beanstandung gelesen',
|
||||
((heil.stdout || '').match(/Inhalte in Ordnung/) ?? ['—'])[0],
|
||||
);
|
||||
|
||||
const kaputteInhalte = path.join(daten, 'kaputte-inhalte');
|
||||
fs.cpSync(path.join(process.cwd(), 'content'), kaputteInhalte, { recursive: true });
|
||||
|
||||
// Ein Verweis auf eine Rahmenplanposition, die es nicht gibt. Das ist der
|
||||
// Fehler, der ohne Prüfung erst Monate später auffiele – in der Auswertung.
|
||||
const opfer = path.join(kaputteInhalte, 'handouts', 'N3-subnetting-rechnen.md');
|
||||
const zeilen = fs.readFileSync(opfer, 'utf8').split(/\r?\n/);
|
||||
const stelle = zeilen.findIndex((zeile) => zeile.startsWith('rahmenplan:'));
|
||||
// Wohlgeformt, aber ohne Entsprechung – so greift die Querprüfung zwischen
|
||||
// den Dateien und nicht bloß die Formatprüfung.
|
||||
zeilen[stelle] = 'rahmenplan: [B2, C9]';
|
||||
fs.writeFileSync(opfer, zeilen.join('\n'), 'utf8');
|
||||
const erwarteteZeile = stelle + 1;
|
||||
|
||||
const kaputt = spawnSync('npx', ['tsx', 'scripts/inhalte-pruefen.ts'], {
|
||||
env: { ...umgebung, LERNBAR_INHALTE: kaputteInhalte },
|
||||
encoding: 'utf8',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
const meldung = `${kaputt.stdout ?? ''}${kaputt.stderr ?? ''}`;
|
||||
pruefe(kaputt.status !== 0, 'Ein Inhaltsfehler lässt die Prüfung fehlschlagen', `Ende ${kaputt.status}`);
|
||||
pruefe(
|
||||
/N3-subnetting-rechnen\.md/.test(meldung),
|
||||
'Die Meldung nennt die Datei',
|
||||
(meldung.match(/.*N3-subnetting-rechnen\.md.*/) ?? ['—'])[0].trim().slice(0, 100),
|
||||
);
|
||||
pruefe(
|
||||
meldung.includes(`N3-subnetting-rechnen.md:${erwarteteZeile}`),
|
||||
'Die Meldung nennt die richtige Zeile',
|
||||
`erwartet Zeile ${erwarteteZeile}`,
|
||||
);
|
||||
pruefe(meldung.includes('C9'), 'Die Meldung nennt den fehlerhaften Wert');
|
||||
pruefe(meldung.includes('rahmenplan.yaml'), 'Die Meldung sagt, wo die gültigen Werte stehen');
|
||||
|
||||
browser = await chromium.launch();
|
||||
|
||||
// --- A2 Zwei Rollen, keine Selbstregistrierung ---------------------------
|
||||
|
||||
abschnitt('A2 · Zwei Rollen, keine Selbstregistrierung');
|
||||
|
||||
const gast = await browser.newContext({ locale: 'de-DE' });
|
||||
const gastSeite = await gast.newPage();
|
||||
gastSeite.setDefaultTimeout(60000);
|
||||
await gastSeite.goto(`${BASIS}/anmelden`);
|
||||
const anmeldetext = await gastSeite.locator('body').innerText();
|
||||
pruefe(/keine Selbstregistrierung/i.test(anmeldetext), 'Die Anmeldeseite bietet keine Registrierung');
|
||||
|
||||
const ungeschuetzt = await gast.request.get(`${BASIS}/berichtsheft`, { maxRedirects: 0 });
|
||||
pruefe(
|
||||
ungeschuetzt.status() >= 300 && ungeschuetzt.status() < 400,
|
||||
'Ohne Anmeldung führt jeder Pfad zur Anmeldung',
|
||||
`HTTP ${ungeschuetzt.status()}`,
|
||||
);
|
||||
await gast.close();
|
||||
|
||||
const ausbilder = await browser.newContext({ locale: 'de-DE' });
|
||||
const a = await ausbilder.newPage();
|
||||
a.setDefaultTimeout(60000);
|
||||
await a.goto(`${BASIS}/anmelden`);
|
||||
await a.fill('#email', AUSBILDER.email);
|
||||
await a.fill('#passwort', AUSBILDER.pw);
|
||||
await a.click('button[type=submit]');
|
||||
await a.waitForURL(`${BASIS}/`);
|
||||
pruefe(true, 'Der Ausbilder meldet sich an');
|
||||
|
||||
// --- A3 Der Ausbilder legt ein Azubikonto an -----------------------------
|
||||
|
||||
abschnitt('A3 · Der Ausbilder legt Konten an');
|
||||
|
||||
await a.goto(`${BASIS}/verwaltung`);
|
||||
await a.waitForSelector('#neu-name');
|
||||
await a.fill('#neu-name', AZUBI.name);
|
||||
await a.fill('#neu-email', AZUBI.email);
|
||||
await a.fill('#neu-passwort', AZUBI.pw);
|
||||
await a.fill('#neu-beginn', '2026-08-03');
|
||||
|
||||
await a.click('form:has(#neu-name) button[type=submit]');
|
||||
await a.waitForSelector(`text=${AZUBI.name}`, { timeout: 20000 });
|
||||
|
||||
const verwaltungstext = await a.locator('body').innerText();
|
||||
pruefe(verwaltungstext.includes(AZUBI.name), 'Der Azubi steht in der Verwaltung');
|
||||
|
||||
// Das Passwort darf nirgends wieder auftauchen – weder in der Ansicht noch
|
||||
// in der Datenbank.
|
||||
await a.reload();
|
||||
const nachNeuladen = await a.content();
|
||||
pruefe(!nachNeuladen.includes(AZUBI.pw), 'Das Passwort ist in der Oberfläche nirgends mehr zu sehen');
|
||||
|
||||
const { default: Sqlite } = await import('better-sqlite3');
|
||||
// Bewusst **nicht** readonly: Eine Lesesitzung auf einer WAL-Datenbank sieht
|
||||
// ohne Schreibrecht auf die Begleitdateien unter Umständen nur den letzten
|
||||
// Prüfpunkt – und damit ein frisch angelegtes Konto noch nicht.
|
||||
const pruefDb = new Sqlite(path.join(daten, 'lernbar.sqlite'));
|
||||
const alleKonten = pruefDb.prepare('select email, rolle from benutzer').all();
|
||||
const gespeichert = pruefDb.prepare('select passwort_hash from benutzer where email = ?').get(AZUBI.email);
|
||||
pruefDb.close();
|
||||
if (!gespeichert) {
|
||||
console.log(` (in der Datenbank stehen: ${alleKonten.map((k) => `${k.email} [${k.rolle}]`).join(', ') || 'keine'})`);
|
||||
}
|
||||
pruefe(
|
||||
Boolean(gespeichert) && !String(gespeichert.passwort_hash).includes(AZUBI.pw),
|
||||
'In der Datenbank steht kein Klartextpasswort',
|
||||
);
|
||||
pruefe(
|
||||
String(gespeichert?.passwort_hash ?? '').startsWith('$argon2id$'),
|
||||
'Das Passwort liegt als Argon2id-Hash',
|
||||
String(gespeichert?.passwort_hash ?? '').slice(0, 30),
|
||||
);
|
||||
|
||||
// --- A4 Der Azubi arbeitet ------------------------------------------------
|
||||
|
||||
abschnitt('A4 · Der Azubi arbeitet');
|
||||
|
||||
const azubi = await browser.newContext({ locale: 'de-DE' });
|
||||
const z = await azubi.newPage();
|
||||
z.setDefaultTimeout(60000);
|
||||
await z.goto(`${BASIS}/anmelden`);
|
||||
await z.fill('#email', AZUBI.email);
|
||||
await z.fill('#passwort', AZUBI.pw);
|
||||
await z.click('button[type=submit]');
|
||||
const angemeldet = await z.waitForURL(`${BASIS}/`, { timeout: 15000 }).then(() => true, () => false);
|
||||
pruefe(angemeldet, 'Der Azubi meldet sich mit dem vergebenen Passwort an');
|
||||
if (!angemeldet) throw new Error('Anmeldung des Azubis fehlgeschlagen.');
|
||||
|
||||
await z.goto(`${BASIS}/handouts/N3`);
|
||||
await z.waitForSelector('h1');
|
||||
await z.click('button:has-text("Als gelesen markieren")');
|
||||
await z.waitForSelector('button:has-text("Doch noch nicht gelesen")', { timeout: 15000 });
|
||||
pruefe(true, 'Ein Handout lässt sich als gelesen markieren');
|
||||
|
||||
await z.goto(`${BASIS}/karten/lernen`);
|
||||
const hatKarte = (await z.locator('.lernkarte').count()) > 0;
|
||||
if (hatKarte) {
|
||||
await z.click('.lernkarte');
|
||||
await z.click('.bewerten-sitzt');
|
||||
await z.waitForTimeout(600);
|
||||
const rueck = await z.locator('.lernmodus-rueckmeldung').innerText();
|
||||
pruefe(/kommt/.test(rueck), 'Eine Merkkarte lässt sich bewerten', rueck.trim());
|
||||
} else {
|
||||
pruefe(false, 'Eine Merkkarte lässt sich bewerten', 'kein Stapel fällig');
|
||||
}
|
||||
|
||||
// --- A5 Aufgabe abgeben und abnehmen -------------------------------------
|
||||
|
||||
abschnitt('A5 · Aufgabe abgeben und abnehmen');
|
||||
|
||||
await z.goto(`${BASIS}/aufgaben/1.1`);
|
||||
await z.waitForSelector('#aufgaben-notiz');
|
||||
|
||||
await z.fill(
|
||||
'#aufgaben-notiz',
|
||||
'Arbeitsplatz nach Checkliste übergeben: Gerät angeschlossen, Anmeldung geprüft, ' +
|
||||
'Drucker verbunden, Nutzer eingewiesen, Ticket geschlossen.',
|
||||
);
|
||||
await z.click('button:has-text("Notizen speichern")');
|
||||
await z.waitForSelector('text=/gespeichert/i', { timeout: 20000 });
|
||||
pruefe(true, 'Der Azubi hält seine Arbeit in Notizen fest');
|
||||
|
||||
await z.click('button:has-text("Zur Abnahme")');
|
||||
await z.waitForTimeout(1500);
|
||||
await z.reload();
|
||||
pruefe(
|
||||
await z.isDisabled('button:has-text("Zur Abnahme")'),
|
||||
'Der Azubi meldet die Aufgabe zur Abnahme',
|
||||
);
|
||||
|
||||
// Der Ausbilder nimmt ab.
|
||||
await a.goto(`${BASIS}/aufgaben`);
|
||||
await a.waitForSelector('h1');
|
||||
const zurAbnahme = await a.$('a[href*="/aufgaben/1.1?azubi="]');
|
||||
pruefe(Boolean(zurAbnahme), 'Die Aufgabe erscheint beim Ausbilder zur Abnahme');
|
||||
|
||||
if (zurAbnahme) {
|
||||
await zurAbnahme.click();
|
||||
await a.waitForSelector('.kriterien', { timeout: 15000 });
|
||||
|
||||
for (const kasten of await a.$$('.kriterien input[type=checkbox]')) await kasten.check();
|
||||
await a.fill('#bewertung', 'gut');
|
||||
await a.fill('#rueckmeldung', 'Sauber dokumentiert, Einweisung nachvollziehbar.');
|
||||
await a.click('button:has-text("Bewertung speichern")');
|
||||
await a.waitForSelector('text=/gespeichert/i', { timeout: 20000 });
|
||||
|
||||
await a.click('button:has-text("Aufgabe abnehmen")');
|
||||
await a.waitForSelector('button:has-text("Bereits abgenommen")', { timeout: 20000 });
|
||||
pruefe(true, 'Der Ausbilder nimmt die Aufgabe ab');
|
||||
|
||||
// Und danach ist sie für den Azubi gesperrt.
|
||||
await z.goto(`${BASIS}/aufgaben/1.1`);
|
||||
await z.waitForSelector('h1');
|
||||
pruefe(
|
||||
await z.isDisabled('#aufgaben-notiz'),
|
||||
'Für den Azubi ist die abgenommene Aufgabe gesperrt',
|
||||
);
|
||||
|
||||
// Eine abgenommene Aufgabe ist der belastbare Nachweis in der Abdeckung.
|
||||
await a.goto(`${BASIS}/auswertung`);
|
||||
await a.waitForSelector('h1');
|
||||
const abdeckung = await a.locator('body').innerText();
|
||||
pruefe(/vermittelt/.test(abdeckung), 'Die Abnahme schlägt in der Rahmenplanabdeckung durch');
|
||||
pruefe(/ohne Inhalt|kein Inhalt/i.test(abdeckung), 'Lücken im Lehrgang werden getrennt ausgewiesen');
|
||||
}
|
||||
|
||||
// --- A6 Rechte -----------------------------------------------------------
|
||||
|
||||
abschnitt('A6 · Rechte serverseitig');
|
||||
|
||||
const fremd = await azubi.request.get(`${BASIS}/verwaltung`, { maxRedirects: 0 });
|
||||
pruefe(
|
||||
fremd.status() >= 300 && fremd.status() < 400,
|
||||
'Der Azubi kommt nicht in die Verwaltung',
|
||||
`HTTP ${fremd.status()}`,
|
||||
);
|
||||
|
||||
// --- A7 Berichtsheft -----------------------------------------------------
|
||||
|
||||
abschnitt('A7 · Berichtsheft');
|
||||
|
||||
await z.goto(`${BASIS}/berichtsheft`);
|
||||
await z.waitForSelector('h1');
|
||||
|
||||
// Auf einer frischen Datenbank gibt es noch kein Blatt – der Azubi legt das
|
||||
// der laufenden Woche an. Genau so fängt er im Betrieb auch an.
|
||||
const anlegen = await z.$('button:has-text("Blatt für diese Woche anlegen")');
|
||||
if (anlegen) {
|
||||
await anlegen.click();
|
||||
await z.waitForSelector('.blattzeile-link, h1', { timeout: 30000 });
|
||||
await z.goto(`${BASIS}/berichtsheft`);
|
||||
await z.waitForSelector('h1');
|
||||
}
|
||||
pruefe(
|
||||
(await z.locator('.blattzeile-link').count()) > 0,
|
||||
'Der Azubi legt das Wochenblatt der laufenden Woche an',
|
||||
);
|
||||
|
||||
const blatt = await z.$('.blattzeile-link');
|
||||
if (blatt) {
|
||||
await blatt.click();
|
||||
// Auf die Adresse warten, nicht auf eine Überschrift: Die steht auch auf
|
||||
// der Liste, von der wir kommen. Sonst zeigt `url()` noch dorthin – und
|
||||
// die Anfrage landet auf der Sammelroute statt auf dem einzelnen Blatt.
|
||||
await z.waitForURL(/\/berichtsheft\/[0-9a-f-]{36}$/, { timeout: 30000 });
|
||||
pruefe(true, 'Ein Wochenblatt lässt sich öffnen');
|
||||
|
||||
const pdf = await azubi.request.get(`${z.url()}/pdf`);
|
||||
const puffer = await pdf.body();
|
||||
pruefe(
|
||||
pdf.status() === 200 && puffer.subarray(0, 5).toString() === '%PDF-',
|
||||
'Das Wochenblatt kommt als PDF heraus',
|
||||
`HTTP ${pdf.status()}, ${puffer.byteLength} Bytes${
|
||||
puffer.subarray(0, 5).toString() === '%PDF-' ? '' : `: „${puffer.toString('utf8').slice(0, 60)}"`
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
pruefe(false, 'Ein Wochenblatt lässt sich öffnen', 'keine Blattzeile gefunden');
|
||||
}
|
||||
|
||||
// --- A8 Sicherung und Rückspielung ---------------------------------------
|
||||
|
||||
abschnitt('A8 · Sicherung zurückspielen');
|
||||
|
||||
// Pfade für bash mit Schrägstrichen: Ein Backslash ist dort ein Fluchtzeichen.
|
||||
const alsPosix = (pfad) => pfad.split(path.sep).join('/');
|
||||
const sicherung = laufen('bash', ['scripts/im-container/sicherung.sh', alsPosix(path.join(daten, 'sicherungen'))]);
|
||||
const archiv = (sicherung.stdout || '').trim().split('\n').pop();
|
||||
pruefe(
|
||||
sicherung.status === 0 && archiv && fs.existsSync(archiv),
|
||||
'Die Sicherung läuft durch',
|
||||
archiv ? path.basename(archiv) : (sicherung.stderr || '').trim(),
|
||||
);
|
||||
|
||||
// Jetzt absichtlich kaputtmachen: Der Azubi verschwindet. Vorher den Server
|
||||
// beenden – zurückgespielt wird bei gestoppter Anwendung, so steht es auch
|
||||
// in der Anleitung.
|
||||
serverBeenden();
|
||||
await new Promise((weiter) => setTimeout(weiter, 2500));
|
||||
|
||||
const { default: Datenbank } = await import('better-sqlite3');
|
||||
const dbPfad = path.join(daten, 'lernbar.sqlite');
|
||||
|
||||
const vorher = (() => {
|
||||
const db = new Datenbank(dbPfad);
|
||||
const zahl = db.prepare('select count(*) n from benutzer').get().n;
|
||||
db.prepare('delete from benutzer where email = ?').run(AZUBI.email);
|
||||
const nachher = db.prepare('select count(*) n from benutzer').get().n;
|
||||
db.close();
|
||||
return { zahl, nachher };
|
||||
})();
|
||||
pruefe(vorher.nachher < vorher.zahl, 'Zum Beweis wird der Datenbestand beschädigt', `${vorher.zahl} → ${vorher.nachher} Konten`);
|
||||
|
||||
const zurueck = laufen('bash', ['scripts/im-container/wiederherstellen.sh', alsPosix(archiv ?? ''), '--ohne-rueckfrage']);
|
||||
pruefe(zurueck.status === 0, 'Die Rückspielung läuft durch', (zurueck.stderr || '').trim() || 'ohne Fehler');
|
||||
|
||||
const wiederDa = (() => {
|
||||
const db = new Datenbank(dbPfad);
|
||||
const treffer = db.prepare('select name from benutzer where email = ?').get(AZUBI.email);
|
||||
const karten = db.prepare('select count(*) n from karten_verlauf').get().n;
|
||||
db.close();
|
||||
return { treffer, karten };
|
||||
})();
|
||||
pruefe(wiederDa.treffer?.name === AZUBI.name, 'Der gelöschte Azubi ist wieder da', wiederDa.treffer?.name ?? 'fehlt');
|
||||
pruefe(wiederDa.karten >= 0, 'Auch der Lernverlauf kam mit zurück', `${wiederDa.karten} Einträge`);
|
||||
|
||||
const verdraengt = fs.readdirSync(daten).filter((name) => name.startsWith('verdraengt-'));
|
||||
pruefe(verdraengt.length === 1, 'Der überschriebene Stand wurde zur Seite gelegt, nicht gelöscht', verdraengt[0] ?? '—');
|
||||
} catch (ausnahme) {
|
||||
console.error('\nAbbruch:', ausnahme instanceof Error ? ausnahme.message : ausnahme);
|
||||
fehler += 1;
|
||||
} finally {
|
||||
await browser?.close();
|
||||
}
|
||||
|
||||
// --- Protokoll -------------------------------------------------------------
|
||||
|
||||
const zeilen = ['# Abnahmeprotokoll', '', `Erzeugt am ${new Date().toISOString().slice(0, 16).replace('T', ' ')} Uhr`, '', 'Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenbank**.', ''];
|
||||
for (const eintrag of protokoll) {
|
||||
if (eintrag.abschnitt) zeilen.push('', `## ${eintrag.abschnitt}`, '');
|
||||
else zeilen.push(`- ${eintrag.erfuellt ? '✓' : '✗'} ${eintrag.text}${eintrag.zusatz ? ` — ${eintrag.zusatz}` : ''}`);
|
||||
}
|
||||
zeilen.push('', `**${protokoll.filter((e) => e.erfuellt).length} von ${protokoll.filter((e) => 'erfuellt' in e).length} Prüfungen erfüllt.**`, '');
|
||||
|
||||
fs.writeFileSync(path.join(process.cwd(), 'docs', 'ABNAHME.md'), zeilen.join('\n'), 'utf8');
|
||||
console.log('\nProtokoll: docs/ABNAHME.md');
|
||||
|
||||
console.log(`${fehler === 0 ? '✓ Abnahme bestanden.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`);
|
||||
process.exit(fehler === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user