/* DER SECHSTE BEREICH — "Die Agentur bleibt angebunden" Kernprinzip 04 des Konzepts, und bis zum 06.09.2026 gab es dafuer nichts: Fuenf Bereiche standen (LIVE, Content, Technik, Community, Schutz), der sechste fehlte vollstaendig. --------------------------------------------------------------------- DAS EIGENTLICHE RISIKO IST NICHT DER BEREICH, SONDERN DER UMBAU Der erlaubte Wertebereich einer Spalte steckt in SQLite in einem CHECK, und ein CHECK laesst sich nicht aendern -- die Tabelle muss neu gebaut werden. Dabei werden ALLE vorhandenen Eintraege umgeschrieben: LIVE-Auswertungen, Content-Ideen, Technikprobleme, Community-Konflikte, Vorfaelle. Wer dort eine Spalte vergisst, verliert ihren Inhalt still. Und das ist ein Weg, den man nicht zurueckdrehen kann. Deshalb wird er hier nicht "im Prinzip" geprueft, sondern WIRKLICH GEGANGEN: Die Pruefung baut eine Datenbank im ALTEN Stand nach, fuellt sie, laesst die echte Anwendung darueberlaufen und zaehlt danach jede Zeile und jedes Feld nach. GEGENPROBEN durchgehend -- vor allem die eine, auf die es ankommt: Der CHECK muss danach immer noch BEISSEN. Eine Umstellung, die nebenbei die Schranke entfernt, sieht aus wie ein Erfolg und ist der schlimmere Ausgang. ===================================================================== */ import { mkdtempSync, rmSync, appendFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import { DatabaseSync } from "node:sqlite"; import { scryptSync, randomBytes } from "node:crypto"; import { heuteLokal } from "./helfer-zeit.mjs"; const HIER = dirname(fileURLToPath(import.meta.url)); const { portMussFreiSein } = await import("./helfer-port.mjs"); const PORT = 4341; await portMussFreiSein(PORT, "die Agentur-Pruefung"); const BASIS = `http://127.0.0.1:${PORT}`; const ordner = mkdtempSync(join(tmpdir(), "ws-agentur-")); const DB = join(ordner, "workspace.db"); const PROTOKOLL = join(process.cwd(), "pruef-agentur-lauf.txt"); try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ } const melde = (z) => { console.log(z); try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } }; let fehler = 0; let gemacht = 0; const ok = (b, t) => { gemacht++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; /* --------------------------------------------------------------------- Die Anwendung als eigener Prozess -- weil sie ZWEIMAL starten muss: einmal, um die Datenbank anzulegen, und einmal nach dem Zurueckbau, damit die Wanderung wirklich laeuft. In einem Prozess ginge das nicht: Ein Modul wird nur einmal geladen. --------------------------------------------------------------------- */ let laufend = null; async function serverStarten() { const kind = spawn(process.execPath, [join(HIER, "index.js")], { env: { ...process.env, WORKSPACE_DB: DB, PORT: String(PORT), SITE_ACCESS_SECRET: "lokaler-test", SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00", }, stdio: ["ignore", "pipe", "pipe"], }); let aus = ""; kind.stdout.on("data", (d) => { aus += d; }); kind.stderr.on("data", (d) => { aus += d; }); for (let i = 0; i < 80; i++) { await new Promise((r) => setTimeout(r, 250)); if (kind.exitCode !== null) break; try { const a = await fetch(BASIS + "/workspace/", { redirect: "manual" }); if (a.status < 500) { laufend = kind; return { kind, aus: () => aus }; } } catch { /* noch nicht oben */ } } throw new Error("Server startete nicht:\n" + aus.slice(-1200)); } async function serverStoppen() { if (!laufend) return; laufend.kill(); laufend = null; /* Warten, bis der Port wirklich frei ist -- sonst startet der zweite Lauf gegen einen halb geschlossenen Anschluss und misst Unsinn. */ for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 150)); try { await fetch(BASIS + "/workspace/", { redirect: "manual" }); } catch { return; } } } /* ======================================================================= 1. Erster Start: die Datenbank entsteht ======================================================================= */ melde("=== Die Datenbank anlegen ==="); { const { aus } = await serverStarten(); ok(true, "die Anwendung ist oben und hat das Schema angelegt"); void aus; } const jetzt = new Date().toISOString(); /* ORTSZEIT fuer das Tagesdatum, nicht der Schnitt aus dem UTC-Stempel. `jetzt.slice(0, 10)` liegt zwischen Mitternacht und zwei Uhr einen Tag daneben -- die Pruefung waere dann nachts rot, ohne dass sich etwas geaendert haette. Gefunden von pruef-struktur, das genau danach sucht. */ const heute = heuteLokal(); function anlegen(d, name, rolle, code) { const salz = randomBytes(16).toString("hex"); const hash = scryptSync(code, salz, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") .run(name, rolle, hash, salz, 32768, jetzt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } let idDogi, idLuna; { const d = new DatabaseSync(DB); idDogi = anlegen(d, "Filipe", "admin", "CODE-DOGI-0001"); idLuna = anlegen(d, "Luna", "creator", "CODE-CREA-0001"); d.close(); } /* ======================================================================= 2. Die Datenbank auf den ALTEN Stand zurueckbauen ======================================================================= Genau so sieht sie auf dem Server aus, bevor die Wanderung laeuft. Ohne diesen Schritt pruefte die ganze Datei nur eine frisch angelegte Tabelle -- und damit ausgerechnet den Fall, den es in der Wirklichkeit nicht gibt. */ melde("\n=== Zurueck auf den alten Stand (fuenf Bereiche) ==="); await serverStoppen(); const ALTE_EINTRAEGE = [ ["live", "auswertung", "Auswertung Montag", "Der Hook saß, die Mitte hing.", 4, "hoch"], ["content", "idee", "Serie: Basteln mit Kindern", "Fünf Teile.", null, "mittel"], ["technik", "problem", "Ton bricht ab", "Nur bei OBS 30.", null, "hoch"], ["community", "konflikt", "Streit in den Kommentaren", "Zwei Zuschauer.", null, "niedrig"], ["schutz", "vorfall", "Meldung wegen Sprache", "Geklärt.", null, "hoch"], ]; { const d = new DatabaseSync(DB); d.exec("PRAGMA foreign_keys = OFF"); d.exec("DROP TABLE IF EXISTS eintraege"); /* WOERTLICH der alte Bauplan, mit allen Spalten, die zu diesem Zeitpunkt schon da waren. */ d.exec(` CREATE TABLE eintraege ( id INTEGER PRIMARY KEY AUTOINCREMENT, bereich TEXT NOT NULL CHECK (bereich IN ('live','content','technik','community','schutz')), art TEXT NOT NULL, titel TEXT NOT NULL, text TEXT, datum TEXT NOT NULL, bewertung INTEGER CHECK (bewertung IS NULL OR (bewertung BETWEEN 1 AND 5)), dringlichkeit TEXT NOT NULL DEFAULT 'mittel' CHECK (dringlichkeit IN ('hoch','mittel','niedrig')), status TEXT NOT NULL DEFAULT 'offen' CHECK (status IN ('offen','erledigt')), creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, erstellt TEXT NOT NULL, erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, geaendert TEXT, hook TEXT, format TEXT, saeule_id INTEGER, geplant TEXT, creator_extern TEXT ); CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id);`); const setzen = d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, datum, bewertung, dringlichkeit, status, creator_id, erstellt, erstellt_von, hook, creator_extern) VALUES (?,?,?,?,?,?,?, 'offen', ?,?,?,?,?)`); for (const [b, art, titel, text, bew, dring] of ALTE_EINTRAEGE) { setzen.run(b, art, titel, text, heute, bew, dring, idLuna, jetzt, idDogi, b === "content" ? "Der Hook: 'Das habt ihr noch nie gesehen'" : null, b === "live" ? "Gastauftritt Mia" : null); } d.exec("PRAGMA foreign_keys = ON"); const n = d.prepare("SELECT COUNT(*) AS n FROM eintraege").get().n; ok(n === ALTE_EINTRAEGE.length, `${n} Eintraege im alten Stand angelegt`); /* GEGENPROBE: Der alte CHECK muss "agentur" WIRKLICH ablehnen -- sonst gaebe es nichts umzustellen, und der Rest dieser Datei bewiese nichts. */ let abgelehnt = false; try { d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, erstellt) VALUES ('agentur','kampagne','Darf noch nicht',?,?)`).run(heute, jetzt); } catch { abgelehnt = true; } ok(abgelehnt, "im alten Stand wird 'agentur' von der Datenbank abgelehnt"); d.close(); } /* ======================================================================= 3. Die Wanderung -- der Weg, den man nicht zurueckdrehen kann ======================================================================= */ melde("\n=== Die Umstellung ==="); let ausgabe = ""; { const { aus } = await serverStarten(); await new Promise((r) => setTimeout(r, 600)); ausgabe = aus(); } ok(/Bereich 'agentur' freigeschaltet/.test(ausgabe), "die Anwendung meldet die Umstellung" + (/agentur/.test(ausgabe) ? "" : ` — Ausgabe: ${ausgabe.slice(-300)}`)); { const d = new DatabaseSync(DB, { readOnly: true }); const plan = d.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='eintraege'").get()?.sql || ""; ok(/'agentur'/.test(plan), "der neue Bauplan kennt 'agentur'"); /* KEINE ZEILE DARF FEHLEN -- und keine darf sich VERAENDERT haben. Die Zeilenzahl allein reichte nicht: Wer beim Umschreiben eine Spalte vergisst, behaelt alle Zeilen und verliert ihren Inhalt. Genau das faellt sonst erst Wochen spaeter auf. */ const alle = d.prepare("SELECT * FROM eintraege ORDER BY id").all(); ok(alle.length === ALTE_EINTRAEGE.length, `alle ${alle.length} Eintraege sind noch da (vorher ${ALTE_EINTRAEGE.length})`); let abweichungen = []; ALTE_EINTRAEGE.forEach(([b, art, titel, text, bew, dring], i) => { const z = alle[i]; if (!z) { abweichungen.push(`${b}: Zeile fehlt`); return; } if (z.bereich !== b) abweichungen.push(`${b}: bereich = ${z.bereich}`); if (z.art !== art) abweichungen.push(`${b}: art = ${z.art}`); if (z.titel !== titel) abweichungen.push(`${b}: titel = ${z.titel}`); if (z.text !== text) abweichungen.push(`${b}: text = ${z.text}`); if (z.bewertung !== bew) abweichungen.push(`${b}: bewertung = ${z.bewertung}`); if (z.dringlichkeit !== dring) abweichungen.push(`${b}: dringlichkeit = ${z.dringlichkeit}`); if (z.creator_id !== idLuna) abweichungen.push(`${b}: creator_id = ${z.creator_id}`); if (z.erstellt_von !== idDogi) abweichungen.push(`${b}: erstellt_von = ${z.erstellt_von}`); }); ok(abweichungen.length === 0, abweichungen.length ? `Inhalte haben sich veraendert: ${abweichungen.slice(0, 5).join(" | ")}` : `jedes Feld jeder Zeile ist unveraendert (${alle.length} Zeilen x 8 Felder geprueft)`); /* Auch die SELTENEN Spalten -- gerade die vergisst man beim Abschreiben des Bauplans. */ const mitHook = alle.find((z) => z.bereich === "content"); const mitExtern = alle.find((z) => z.bereich === "live"); ok(/Das habt ihr noch nie gesehen/.test(mitHook?.hook || ""), `die Spalte "hook" hat ihren Inhalt behalten ("${String(mitHook?.hook).slice(0, 30)}…")`); ok(mitExtern?.creator_extern === "Gastauftritt Mia", `die Spalte "creator_extern" ebenfalls ("${mitExtern?.creator_extern}")`); /* Der Index muss nach dem Tausch wieder da sein -- er wird beim DROP mit entfernt. Ohne ihn laeuft alles weiter, nur langsam, und niemand merkt es je. */ const indizes = d.prepare( "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='eintraege'").all() .map((r) => r.name); ok(indizes.includes("idx_eintraege_bereich"), `der Index steht wieder (${indizes.join(", ")})`); d.close(); } /* GEGENPROBE, DIE WICHTIGSTE: Die Schranke muss noch BEISSEN. Eine Umstellung, die nebenbei den CHECK entfernt, sieht aus wie ein Erfolg und ist der schlimmere Ausgang -- ab da kann jeder Tippfehler einen Bereich erfinden, den es nicht gibt. */ { const d = new DatabaseSync(DB); let abgelehnt = false; try { d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, erstellt) VALUES ('erfundenerbereich','x','Darf nicht',?,?)`).run(heute, jetzt); } catch { abgelehnt = true; } ok(abgelehnt, "ein erfundener Bereich wird weiterhin abgelehnt — der CHECK beisst noch"); let angenommen = false; try { d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, erstellt) VALUES ('agentur','kampagne','Direkt in die Datenbank',?,?)`).run(heute, jetzt); angenommen = true; d.exec("DELETE FROM eintraege WHERE titel = 'Direkt in die Datenbank'"); } catch { /* bleibt false */ } ok(angenommen, "'agentur' wird jetzt angenommen"); d.close(); } /* ======================================================================= 4. Der Bereich im Betrieb ======================================================================= */ melde("\n=== Der Bereich im Betrieb ==="); async function anmelden(rolle, code) { const a = await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle, code }) }); const keks = (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`); return keks; } const keksDogi = await anmelden("admin", "CODE-DOGI-0001"); const keksLuna = await anmelden("creator", "CODE-CREA-0001"); async function ruf(art, weg, keks, koerper) { const a = await fetch(BASIS + weg, { method: art, headers: { "Content-Type": "application/json", cookie: keks, origin: BASIS }, body: koerper ? JSON.stringify(koerper) : undefined, }); let daten = null; try { daten = await a.json(); } catch { /* kein JSON */ } return { status: a.status, daten }; } /* Die vier Arten -- jede einzeln, denn eine, die der Server nicht kennt, waere im Formular waehlbar und beim Speichern ein Fehler. */ const ARTEN = ["kampagne", "schulung", "anliegen", "zustaendig"]; { const { daten } = await ruf("GET", "/workspace/api/bereich/agentur", keksDogi); ok(daten?.einstellung?.name === "Agentur", `der Bereich meldet sich als "${daten?.einstellung?.name}"`); const arten = Object.keys(daten?.einstellung?.arten || {}); ok(arten.length === 4 && ARTEN.every((a) => arten.includes(a)), `vier Arten: ${arten.join(", ")}`); ok(daten?.einstellung?.dringlichkeit === true, "Dringlichkeit ist an — ein Anliegen von gestern ist etwas anderes als eines von vor drei Wochen"); ok(daten?.einstellung?.bewertung === false, "Bewertung ist aus — hier wird zusammengearbeitet, nicht benotet"); } let angelegt = 0; for (const art of ARTEN) { const { status } = await ruf("POST", "/workspace/api/bereich/agentur", keksDogi, { art, titel: `Test ${art}`, text: "Inhalt", datum: heute, dringlichkeit: "mittel", creator_id: idLuna, }); if (status === 200 || status === 201) angelegt++; else ok(false, `Art "${art}" liess sich nicht anlegen (HTTP ${status})`); } ok(angelegt === ARTEN.length, `alle ${angelegt} Arten lassen sich anlegen`); /* GEGENPROBE: eine erfundene Art muss abgelehnt werden. Sonst pruefte die Zeile darueber nur, dass der Server irgendetwas annimmt. */ { const { status } = await ruf("POST", "/workspace/api/bereich/agentur", keksDogi, { art: "erfundeneart", titel: "Darf nicht", datum: heute, }); ok(status >= 400, `eine erfundene Art wird abgelehnt (HTTP ${status})`); } /* Der Creator SIEHT seinen Agentur-Bereich -- er ist der Grund, warum es ihn gibt. */ { const { status, daten } = await ruf("GET", "/workspace/api/bereich/agentur", keksLuna); ok(status === 200, `Luna darf den Bereich oeffnen (HTTP ${status})`); ok((daten?.eintraege || []).length === ARTEN.length, `sie sieht ihre ${(daten?.eintraege || []).length} Eintraege`); } /* ======================================================================= 5. Kommt er ueberall an, wo Bereiche vorkommen? ======================================================================= */ melde("\n=== Ueberall, wo Bereiche vorkommen ==="); { /* Der Wochenbericht. Hier stand die Liste der fuenf Bereiche noch einmal von Hand abgeschrieben -- der neue waere schlicht nicht vorgekommen, ohne Fehler und ohne Luecke. */ const { daten } = await ruf("GET", "/workspace/api/report", keksDogi); const b = daten?.bereiche || {}; ok(Object.keys(b).length === 6, `der Wochenbericht kennt ${Object.keys(b).length} Bereiche: ${Object.keys(b).join(", ")}`); ok(b.agentur && b.agentur.neu >= ARTEN.length, `und zaehlt ${b.agentur?.neu} neue Agentur-Eintraege`); } { /* Die Suche. Ein Treffer ohne Ueberschrift sieht aus wie ein Fehler. */ const { daten } = await ruf("GET", "/workspace/api/suche?q=" + encodeURIComponent("Test kampagne"), keksDogi); const text = JSON.stringify(daten); ok(/Agentur/.test(text), "die Suche beschriftet Agentur-Treffer mit dem richtigen Namen"); } /* ======================================================================= 6. Die Kachel — mit einem echten Browser ======================================================================= */ melde("\n=== Die Kachel ==="); { const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await pw.chromium.launch(); const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const seite = await ctx.newPage(); await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); await seite.click('.rolle[data-rolle="admin"]'); await seite.fill("#code", "CODE-DOGI-0001"); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 25000 }); await seite.waitForTimeout(1100); const kacheln = await seite.$$eval(".kachel", (k) => k.map((x) => ({ name: x.querySelector(".kachel__name")?.textContent.trim(), ziel: x.querySelector(".kachel__link")?.getAttribute("href"), ton: x.dataset.ton, pfade: x.querySelectorAll(".kachel__zeichen path").length, }))); const a = kacheln.find((x) => x.name === "Agentur"); ok(!!a, `die Kachel "Agentur" steht da (${kacheln.length} Kacheln insgesamt)`); ok(a?.ziel === "bereich.html?b=agentur", `sie fuehrt nach "${a?.ziel}"`); ok(Number(a?.pfade) >= 4, `ihr Zeichen hat ${a?.pfade} Pfade — es ist wirklich gezeichnet`); /* KEINE ZWEI KACHELN MIT DEMSELBEN FARBTON NEBENEINANDER. Das Auge vergleicht nur, was benachbart ist -- zwei gleiche Toene direkt nebeneinander lesen sich als Fehler. */ const toene = kacheln.map((x) => x.ton); const nachbarnGleich = toene.filter((t, i) => i > 0 && t === toene[i - 1]); ok(nachbarnGleich.length === 0, nachbarnGleich.length ? `zwei Nachbarkacheln haben denselben Ton (${nachbarnGleich.join(", ")})` : `keine zwei benachbarten Kacheln teilen einen Farbton (${toene.length} geprueft)`); /* Die Seite selbst muss aufgehen und ihren Namen tragen. */ await seite.goto(BASIS + "/workspace/bereich.html?b=agentur", { waitUntil: "networkidle" }); await seite.waitForTimeout(900); const titel = await seite.$eval(".titel", (e) => e.textContent.trim()).catch(() => ""); ok(/Agentur/i.test(titel), `die Bereichsseite heisst "${titel}"`); /* .eintrag-karte -- der Name aus bereich.js. Beim ersten Anlauf stand hier geraten ".eintrag, .karte"; das traf nichts und meldete "0 Eintraege", obwohl vier dastanden. Ein falscher Selektor meldet einen Fehler, den es nicht gibt -- genauso schaedlich wie einer, der etwas uebersieht. */ const eintraege = await seite.$$eval(".eintrag-karte", (e) => e.length).catch(() => 0); ok(eintraege >= ARTEN.length, `sie zeigt ${eintraege} Eintraege (mindestens die ${ARTEN.length} angelegten)`); /* Und die Titel muessen die sein, die oben angelegt wurden -- sonst bewiese die Zahl nur, dass IRGENDWAS gezeichnet wurde. */ const kartenTitel = await seite.$$eval(".eintrag-titel", (e) => e.map((x) => x.textContent.trim())); const fehlend = ARTEN.map((a) => `Test ${a}`).filter((t) => !kartenTitel.includes(t)); ok(fehlend.length === 0, fehlend.length ? `diese Eintraege fehlen auf der Seite: ${fehlend.join(", ")}` : `alle vier Titel stehen da: ${kartenTitel.join(" · ")}`); /* GEGENPROBE zum Selektor: Ein erfundener Bereich darf NICHT wie ein echter aussehen -- weder in der Ueberschrift noch mit Eintraegen. Die Anwendung schickt einen dorthin zurueck, wo er hingehoert. */ await seite.goto(BASIS + "/workspace/bereich.html?b=gibtesnicht", { waitUntil: "networkidle" }); await seite.waitForTimeout(900); const falsch = await seite.$eval(".titel", (e) => e.textContent.trim()).catch(() => ""); const falschKarten = await seite.$$eval(".eintrag-karte", (e) => e.length).catch(() => 0); ok(!/Agentur/i.test(falsch) && falschKarten === 0, `ein erfundener Bereich zeigt weder die Agentur noch Eintraege ` + `("${falsch}", ${falschKarten} Karten)`); await ctx.close(); await browser.close(); } await serverStoppen(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } melde(`\n${gemacht} Pruefungen`); melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`); process.exit(fehler ? 1 : 0);