/* 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, writeFileSync } 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; /* DER DRITTE AUSGANG (11.09.2026). Diese Datei schreibt die Serverausgabe immer schon mit -- und zeigte sie nur, wenn der Server gar nicht erst hochkam. Faellt er spaeter mit 503 um (die Antwort bei einem Datenbankfehler), standen hier 31-mal "FEHL" und kein einziges Wort dazu, WARUM. Eine Pruefung, die nicht sagen kann, woran sie scheitert, kostet eine Stunde Suchen. */ let letzteAusgabe = () => ""; /* UND SIE MUSS AUCH DANN KOMMEN, WENN DER LAUF ABSTUERZT. Playwright wirft bei einem fehlenden Knopf eine Ausnahme -- der Prozess stirbt, und mit ihm die Zusammenfassung samt Diagnose. Genau dann braucht man sie am dringendsten. */ function serverAusgabeZeigen(warum) { const t = letzteAusgabe(); console.log(""); console.log(`=== Was der Server dazu gesagt hat (${warum}, letzte 1800 Zeichen) ===`); console.log(t ? t.slice(-1800) : "(nichts -- der Server hat geschwiegen)"); /* Und vollstaendig auf Zuruf, falls die Ursache weiter oben steht: SERVERLOG=. Die letzten 1800 Zeichen zeigen den Schaden, nicht immer seine Ursache. */ if (t && process.env.SERVERLOG) { writeFileSync(process.env.SERVERLOG, t); console.log(`(vollstaendig in ${process.env.SERVERLOG})`); } } for (const art of ["uncaughtException", "unhandledRejection"]) { process.on(art, (e) => { console.log(` !! ${art}: ${e?.message || e}`); serverAusgabeZeigen(art); process.exit(1); }); } 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; letzteAusgabe = () => aus; 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; } /* Mia kam am 07.09.2026 dazu, und sie ist kein Beiwerk: Sie legt nichts an, ihr gehoert nichts, sie betreut niemanden. Genau deshalb beweist erst SIE, dass ein Agentur-Eintrag wirklich fuer alle sichtbar ist -- bei Luna allein bliebe offen, ob es an ihr oder an der Regel lag. */ let idDogi, idLuna, idMia; { const d = new DatabaseSync(DB); idDogi = anlegen(d, "Filipe", "admin", "CODE-DOGI-0001"); idLuna = anlegen(d, "Luna", "creator", "CODE-CREA-0001"); idMia = anlegen(d, "Mia", "creator", "CODE-CREA-0002"); 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}")`); /* DIE DREI EVENT-SPALTEN MUESSEN DEN TAUSCH UEBERLEBEN (07.09.2026). Sie entstehen frueher als diese Umstellung (Spalten-Nachtrag in workspace.js), und der Neubau hier schreibt eine feste Spaltenliste ab. Wer sie dort vergisst, verliert sie samt Inhalt -- ohne Fehlermeldung, und die Zeilenzahl stimmte weiterhin. Genau dieser Weg wird hier wirklich gegangen: Die Tabelle stand vorher im ALTEN Bauplan, ohne diese Spalten. `PRAGMA table_info` und nicht "SELECT laeuft durch": Eine fehlende Spalte faellt beim SELECT zwar auch auf -- aber erst irgendwann, beim naechsten Event, in einem 503. */ const spalten = d.prepare("PRAGMA table_info(eintraege)").all().map((s) => s.name); const fehlend = ["event_ende", "event_aufgaben", "event_regeln"].filter((s) => !spalten.includes(s)); ok(fehlend.length === 0, fehlend.length ? `nach der Umstellung fehlen: ${fehlend.join(", ")}` : "die drei Event-Spalten haben die Umstellung ueberlebt"); /* 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"); const keksMia = await anmelden("creator", "CODE-CREA-0002"); 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"); } /* ANGELEGT WIE IM ECHTEN BETRIEB: OHNE ZUORDNUNG (07.09.2026). Hier stand `creator_id: idLuna` -- und damit prueften die Zeilen weiter unten einen Fall, den es im Alltag nicht gibt. Ein Agentur- Event gehoert niemandem einzeln; Filipe legt es fuer alle an, das Feld bleibt leer. Weil die Pruefung es aber jedes Mal Luna zuwies, sah Luna es auch -- und die Zeile "sie sieht ihre 4 Eintraege" war grün. In Wirklichkeit war die Agentur-Seite fuer JEDEN Creator leer: Ein Eintrag ohne creator_id traf weder `creator_id = ich` noch `erstellt_von = ich`. Gemessen am 07.09.2026, bevor es repariert wurde: von drei Eintraegen sah die Creatorin genau den einen, den die Pruefung ihr zugeschoben hatte. Das ist die dritte Sorte Fehler aus CLAUDE.md -- nicht uebersprungen, nicht kaputt: gelaufen, gruen, und am Thema vorbei. Deshalb wird ab jetzt der Weg gegangen, den es wirklich gibt. */ 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", }); 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})`); } /* ======================================================================= 4b. FUER ALLE, NICHT FUER EINEN Die Agentur ist der einzige Bereich, in dem ein Eintrag ALLEN gilt. Geprueft wird deshalb beides: dass jeder Creator alles sieht -- und dass diese Ausnahme NICHT auf die Betreuungsbereiche abfaerbt. Der zweite Teil ist der wichtigere: Eine Sichtbarkeitsregel, die man an der falschen Stelle aufmacht, gibt fremde Akten frei, und man sieht es der Zeile nicht an. ======================================================================= */ melde("\n=== Fuer alle, nicht fuer einen ==="); { 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 alle ${(daten?.eintraege || []).length} Eintraege — obwohl keiner ihr zugeordnet ist`); ok((daten?.eintraege || []).every((e) => e.creator_id === null), "und keiner davon traegt eine Creator-Zuordnung"); } { /* Mia ist eine ZWEITE Creatorin, die nichts angelegt hat und der nichts gehoert. Wenn die Regel nur ueber `erstellt_von` gestolpert waere, saehe Luna vielleicht etwas und Mia nichts. */ const { daten } = await ruf("GET", "/workspace/api/bereich/agentur", keksMia); ok((daten?.eintraege || []).length === ARTEN.length, `auch Mia sieht alle ${(daten?.eintraege || []).length} — sie hat mit keinem davon etwas zu tun`); } { /* Eine ausdrueckliche Zuordnung muss der Server verwerfen. Sonst genuegt ein Aufruf an der Oberflaeche vorbei -- oder ein spaeteres Formular, das das Feld wieder mitschickt --, und das Event ist fuer alle anderen wieder unsichtbar. */ await ruf("POST", "/workspace/api/bereich/agentur", keksDogi, { art: "kampagne", titel: "Versuch mit Zuordnung", datum: heute, creator_id: idLuna, }); const { daten } = await ruf("GET", "/workspace/api/bereich/agentur", keksMia); const e = (daten?.eintraege || []).find((x) => x.titel === "Versuch mit Zuordnung"); ok(!!e, "ein Eintrag, der ausdruecklich Luna zugewiesen wurde, ist trotzdem fuer Mia da"); ok(e?.creator_id === null, `und seine Zuordnung wurde verworfen (creator_id = ${e?.creator_id})`); /* Und derselbe Riegel fuer den freien Namen -- er wird an einer ANDEREN Stelle gesetzt (externPruefen) und waere sonst das Loch neben der geschlossenen Tuer. */ await ruf("POST", "/workspace/api/bereich/agentur", keksDogi, { art: "kampagne", titel: "Versuch mit Namen", datum: heute, creator_extern: "Marke XY", }); const { daten: d2 } = await ruf("GET", "/workspace/api/bereich/agentur", keksMia); const n = (d2?.eintraege || []).find((x) => x.titel === "Versuch mit Namen"); ok(n?.creator_extern === null || n?.creator_extern === undefined, `auch ein frei getippter Name wird verworfen (creator_extern = ${JSON.stringify(n?.creator_extern)})`); } { /* DIE ENTSCHEIDENDE GEGENPROBE. Die Ausnahme haette man auch in sichtbar() einbauen koennen -- eine Zeile weniger, und dieselbe Wirkung fuer die Agentur. Nur haengen an derselben Funktion auch Aufgaben, Dateien, Termine und Calls. Diese Pruefung ist der Grund, warum das auffiele. */ await ruf("POST", "/workspace/api/bereich/technik", keksDogi, { art: "problem", titel: "Nur fuer Mia", datum: heute, creator_id: idMia, }); const { daten } = await ruf("GET", "/workspace/api/bereich/technik", keksLuna); const fremd = (daten?.eintraege || []).filter((e) => e.titel === "Nur fuer Mia"); ok(fremd.length === 0, `Luna sieht Mias Technik-Eintrag NICHT — die Agentur-Ausnahme faerbt nicht ab (${fremd.length} gefunden)`); } { /* Sehen ja, schreiben nein -- ausdruecklicher Wunsch: "die sollen alles sehen was wir eintragen aber nicht eintragen koennen". */ const { status } = await ruf("POST", "/workspace/api/bereich/agentur", keksLuna, { art: "kampagne", titel: "Von Luna", datum: heute, }); ok(status === 403, `Luna darf hier NICHTS eintragen (HTTP ${status})`); } /* ======================================================================= 4c. AGENTUR-EVENTS: Zeitraum, Aufgaben, Regeln ======================================================================= */ melde("\n=== Agentur-Events ==="); { /* ORTSZEIT, nicht UTC. `new Date(...).toISOString().slice(0,10)` waere zwischen Mitternacht und zwei Uhr einen Tag daneben -- und weil der Server den Zeitraum gegen `heute` (Ortszeit) prueft, waere diese Pruefung nachts rot, ohne dass sich etwas geaendert haette. Gefunden von pruef-struktur, das genau danach sucht. Mittags gerechnet, damit auch eine Zeitumstellung im Zeitraum nichts verschiebt. */ const ende = (() => { const d = new Date(`${heute}T12:00:00`); d.setDate(d.getDate() + 14); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; })(); const { status } = await ruf("POST", "/workspace/api/bereich/agentur", keksDogi, { art: "kampagne", titel: "Sommer-Aktion", text: "Beschreibung", datum: heute, event_ende: ende, event_aufgaben: "1 Punkt je Live-Stunde", event_regeln: "Nur eigenes Konto", }); ok(status === 201, `ein Event mit Zeitraum, Aufgaben und Regeln laesst sich anlegen (HTTP ${status})`); const { daten } = await ruf("GET", "/workspace/api/bereich/agentur", keksLuna); const e = (daten?.eintraege || []).find((x) => x.titel === "Sommer-Aktion"); ok(e?.event_ende === ende, `das Ende kommt beim Creator an (${e?.event_ende})`); ok(e?.event_aufgaben === "1 Punkt je Live-Stunde", "die Aufgaben kommen an"); ok(e?.event_regeln === "Nur eigenes Konto", "die Regeln kommen an"); /* GEGENPROBEN. Ohne sie hiesse "es laesst sich anlegen" nur, dass der Server irgendetwas annimmt. */ const verdreht = await ruf("POST", "/workspace/api/bereich/agentur", keksDogi, { art: "kampagne", titel: "Verdreht", datum: ende, event_ende: heute }); ok(verdreht.status === 400, `ein Ende VOR dem Beginn wird abgelehnt (HTTP ${verdreht.status})`); const unsinn = await ruf("POST", "/workspace/api/bereich/agentur", keksDogi, { art: "kampagne", titel: "Unsinn", datum: heute, event_ende: "31.12.2026" }); ok(unsinn.status === 400, `ein Datum in falscher Schreibweise wird abgelehnt (HTTP ${unsinn.status})`); /* Beim AENDERN gilt dasselbe -- und zwar auch dann, wenn nur EINE der beiden Seiten mitgeschickt wird. Genau hier waere die Pruefung sonst blind: pruefe() sieht nur, was im Aufruf steht. */ const { daten: d3 } = await ruf("GET", "/workspace/api/bereich/agentur", keksDogi); const id = (d3?.eintraege || []).find((x) => x.titel === "Sommer-Aktion")?.id; const nurEnde = await ruf("PATCH", `/workspace/api/bereich/agentur/${id}`, keksDogi, { event_ende: "2020-01-01" }); ok(nurEnde.status === 400, `auch beim Aendern nur des Endes schlaegt der verdrehte Zeitraum an (HTTP ${nurEnde.status})`); const nurBeginn = await ruf("PATCH", `/workspace/api/bereich/agentur/${id}`, keksDogi, { datum: "2099-01-01" }); ok(nurBeginn.status === 400, `und ebenso, wenn nur der Beginn nach hinten geschoben wird (HTTP ${nurBeginn.status})`); /* Die Event-Felder gehoeren der Agentur. In einem anderen Bereich muessen sie stillschweigend verfallen -- sonst haengt eines Tages ein Preisausschreiben an einem Schutzvorfall. */ await ruf("POST", "/workspace/api/bereich/schutz", keksDogi, { art: "vorfall", titel: "Kein Event", datum: heute, event_regeln: "darf nicht ankommen" }); const { daten: d4 } = await ruf("GET", "/workspace/api/bereich/schutz", keksDogi); const v = (d4?.eintraege || []).find((x) => x.titel === "Kein Event"); ok(!v?.event_regeln, `im Schutz-Bereich verfallen die Event-Felder (${JSON.stringify(v?.event_regeln)})`); } /* ======================================================================= 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(" · ")}`); /* ---- Das Event auf dem Bildschirm -------------------------------- Bis hierher ist bewiesen, dass die Daten stimmen. Das sagt noch nichts darueber, ob jemand sie SIEHT: Zeitraum, Aufgaben und Regeln koennen vollstaendig in der Antwort stehen und trotzdem nirgends gezeichnet werden -- ein vergessener Aufruf in karte() reicht, und niemand merkt es, weil die Karte ja da ist. */ const event = await seite.$$eval(".eintrag-karte", (karten) => { const k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Sommer-Aktion"); if (!k) return null; return { stand: k.dataset.event, marke: k.querySelector(".event-zeitraum__marke")?.textContent.trim(), spanne: k.querySelector(".event-zeitraum__spanne")?.textContent.trim(), schilder: [...k.querySelectorAll(".event-block__schild")].map((s) => s.textContent.trim()), texte: [...k.querySelectorAll(".event-block__text")].map((s) => s.textContent.trim()), }; }); ok(!!event, "die Event-Karte Sommer-Aktion steht auf der Seite"); ok(event?.stand === "laeuft", `sie ist als laufend gekennzeichnet ("${event?.stand}")`); ok(/läuft bis/.test(event?.marke || ""), `der Zustand steht als WORT da, nicht nur als Farbe ("${event?.marke}")`); ok(/–/.test(event?.spanne || ""), `und der Zeitraum als Spanne ("${event?.spanne}")`); ok(event?.schilder.join(",") === "Aufgaben,Regeln", `beide Bloecke sind beschriftet (${event?.schilder.join(", ")})`); ok(event?.texte.includes("1 Punkt je Live-Stunde") && event?.texte.includes("Nur eigenes Konto"), "und tragen wirklich ihren Inhalt"); /* GEGENPROBE dazu: Eine Karte, die KEIN Event ist, darf nichts davon haben. Sonst bewiese der Block oben nur, dass die Elemente existieren -- nicht, dass sie zur richtigen Art gehoeren. */ const schulung = await seite.$$eval(".eintrag-karte", (karten) => { const k = karten.find((x) => x.querySelector(".eintrag-titel")?.textContent.trim() === "Test schulung"); return k ? { event: k.dataset.event ?? null, bloecke: k.querySelectorAll(".event-block").length } : null; }); ok(schulung && !schulung.event && schulung.bloecke === 0, `eine Schulung bekommt weder Zustand noch Event-Bloecke (${JSON.stringify(schulung)})`); /* ---- Die Zuordnung bietet nur "Agentur" an ------------------------ Der Ausgangspunkt des ganzen Umbaus: Hier stand die Creator-Liste, und ein versehentlicher Klick darauf machte das Event fuer alle anderen unsichtbar. */ await seite.click("#neu-oeffnen"); await seite.waitForTimeout(400); const wahl = await seite.$$eval("#f-creator option", (o) => o.map((x) => x.textContent.trim())); ok(wahl.length === 1 && wahl[0] === "Agentur", `die Zuordnung bietet genau eine Wahl an: ${JSON.stringify(wahl)}`); const hinweis = await seite.$eval("#hinweis-creator", (e) => e.textContent.trim()).catch(() => ""); ok(/allen/.test(hinweis), `und sagt, was das bedeutet ("${hinweis}")`); /* ---- Das Formular wechselt mit der Art --------------------------- Beim Oeffnen steht die erste Art (Agentur-Events) drin, also muessen die Event-Felder sichtbar sein. Wechselt man auf Schulung, muessen sie verschwinden -- und geleert werden, sonst faehrt ein unsichtbares Enddatum an einer Schulung mit. */ const sichtbar = (id) => seite.$eval(id, (e) => !e.hidden); ok(await sichtbar("#feld-ende") && await sichtbar("#feld-aufgaben") && await sichtbar("#feld-regeln"), "bei Agentur-Events stehen Zeitraum, Aufgaben und Regeln im Formular"); const schild = await seite.$eval("#schild-titel", (e) => e.textContent.trim()); ok(schild === "Titel des Events", `und die Beschriftung passt zur Art ("${schild}")`); await seite.fill("#f-ende", "2030-01-01"); await seite.selectOption("#f-art", "schulung"); await seite.waitForTimeout(300); ok(!(await sichtbar("#feld-ende")) && !(await sichtbar("#feld-aufgaben")), "bei Schulung verschwinden sie wieder"); const restEnde = await seite.$eval("#f-ende", (e) => e.value); ok(restEnde === "", `und das Enddatum faehrt nicht unsichtbar mit (Wert: "${restEnde}")`); /* 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`); if (fehler) { const t = letzteAusgabe(); melde(""); melde("=== Was der Server dazu gesagt hat (letzte 1800 Zeichen) ==="); melde(t ? t.slice(-1800) : "(nichts -- der Server hat geschwiegen)"); } melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`); process.exit(fehler ? 1 : 0);