Files
dogfather-universe/server/pruef-agentur.mjs
T
DogFatherGitandClaude Opus 5 3e1e0810ba Agentur-Events: die Karte als Aushang, Aufgaben zum Abhaken
Filipe, mit einem Bildschirmfoto des Oktober-Events: "ich will das viel
geiler viel perfekter. und auch wenn man die eintraegt und so soll viel
mehr perfektioniert und uebersichtlicher sein."

VORHER GEMESSEN (1280 px, eine einzige Eventkarte):

  Kartenhoehe            1196 px   -- das Fenster ist 900 px
  davon Banner            638 px   -- 53 %, ohne einen Buchstaben
  Titel steht bei        1482 px   -- zweimal scrollen bis zum Namen
  Titelgroesse          15,36 px   -- 1,4 px mehr als der Fliesstext
  Etikett               10,88 px   -- unter der eigenen Grenze (11,5)
  Datum                     3 x    -- in drei Zeilen untereinander
  Knoepfe              4 x gleich  -- "loeschen" wie "zur Aufgabe"

NACHHER, dieselbe Messung: Karte 618 px, Buehne 203 px, Titel 75 px
unter der Kartenkante und 24,8 px gross, Etikett 12 px, das Datum
einmal, "loeschen" abgesetzt.

DIE KARTE
Das Banner ist nicht mehr ein Anhang ueber dem Titel, sondern die
Buehne dahinter -- Hoehe gedeckelt, Titel darauf. Dazu die Frage, die
bei einem Wettbewerb wirklich zaehlt und bisher nirgends beantwortet
wurde: "noch 12 Tage", mit Zeitbalken. Ein Knopf "Banner ganz" holt das
vollstaendige Bild zurueck, das beim Umbau sonst verloren gegangen
waere -- bei Filipes Banner steht die Ansage IM Bild.

DIE AUFGABEN
Aus Fliesstext wird eine Liste, und jeder hakt fuer sich ab (neue
Tabelle event_punkte, Schluessel aus dem Zeilentext). Umsortieren
laesst den Haken, wo er ist; wird die Bedingung selbst umgeschrieben,
faellt er -- beides nachgemessen. Die Leitung sieht, wer wie weit ist,
eine Creatorin sieht diese Liste gar nicht erst.

DAS FORMULAR
Ein Feld je Zeile statt eines leeren Textfeldes. Im echten Event stand
".Jeden Tag Live gehen" neben ". 33k Diamanten erreichen" -- einmal mit
Leerzeichen, einmal ohne. Das ist die zwangslaeufige Folge davon, dass
die Aufzaehlungszeichen von Hand getippt werden; jetzt setzt sie das
Formular. Dazu "Laeuft 14 Tage.", eine Warnung bei verdrehtem Zeitraum
und eine Vorschau, die dieselbe Funktion benutzt wie die echte Karte.

NEBENBEI GEFUNDEN UND BEHOBEN
* Das Banner kam bei Creatorn mit 404 zurueck -- also an genau der
  Karte, die fuer sie gemacht ist. Die Regel "wer das Brett sieht,
  sieht das Bild" galt nur fuer die sieben Community-Bretter. Jetzt
  fragt der Bildweg dieselbe Regel wie das Brett (darfEintragSehen);
  die Gegenprobe zeigt, dass ein fremder Eintrag weiterhin 404 gibt.
* Zwei Stellen setzten das Formular zurueck, die kuerzere liess
  Vorschau und Zeilen-Editor stehen -- beim naechsten "Neuer Eintrag"
  standen die Aufgaben des vorigen Events noch da.
* Der Schriftgrund ragte auf dem Handy 6 px ueber die Karte (feste
  -20 px gegen 14 px Polsterung); jetzt an die Polsterung gekoppelt.

GEPRUEFT
pruef-eventkarte.mjs (neu): 77 Pruefungen, 0 Fehler -- mit Gegenproben
zu jeder Zusage und einer Kontrastmessung am Bildpunkt auf einem
weissen Banner (14,7:1; ohne den Schriftgrund 1,05:1).
pruef-agentur.mjs auf die neuen Bausteine nachgezogen, Zahl unveraendert
bei 62. pruef-eintrag-bild.mjs 24/0.

OFFEN, NICHT AUS DIESEM UMBAU: Das Brett `agentur` ist auf der
Crew-Adresse erreichbar. Zweimal gemessen, mit und ohne diese
Aenderung -- gleiches Ergebnis. Gehoert zur Trennungsregel vom
24.09.2026 und wird getrennt entschieden.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 19:02:05 +02:00

835 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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, readdirSync } 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 { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "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=<datei>. 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();
}
/* NICHT AUF DEN WORTLAUT FESTNAGELN (11.09.2026). Hier stand
/Bereich 'agentur' freigeschaltet/ -- der Satz des alten, von Hand
abgeschriebenen Blocks. Seit die Umstellung ueber
checkListeErweitern laeuft, heisst es "'agentur' in
eintraege.bereich freigeschaltet". Gemeint ist beides Mal dasselbe;
geprueft wird deshalb die AUSSAGE, nicht der Satzbau. */
ok(/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 || {};
/* ABGELEITET STATT GEZAEHLT (11.09.2026). Hier stand `=== 6`. Das
war richtig, als es sechs Bereiche gab; inzwischen sind es elf,
und die Zeile war seither still falsch -- versteckt hinter einem
Absturz weiter oben, der den Lauf vorher beendete. Dieselbe feste
Zahl, dieselbe Falle wie in der Umstellung, die diese Datei
aufgedeckt hat.
Verglichen wird jetzt gegen die Bereiche, die die DATENBANK
ueberhaupt zulaesst -- die CHECK-Regel ist die einzige Liste, an
der niemand vorbeikommt. Damit stimmt die Pruefung auch beim
zwoelften Bereich noch, ohne dass jemand sie anfasst. */
const erlaubt = (() => {
const dd = new DatabaseSync(DB, { readOnly: true });
const plan = dd.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='eintraege'").get()?.sql || "";
dd.close();
/* Ohne regulaeren Ausdruck: Der Bauplan enthaelt die Regel
woertlich als "bereich IN (...)". Ein Muster mit Klammern und
Leerzeichenklassen waere hier nur eine weitere Stelle, an der
man sich vertun kann. */
const auf = plan.indexOf("bereich IN (");
if (auf < 0) return [];
const zu = plan.indexOf(")", auf);
if (zu < 0) return [];
return plan.slice(auf + "bereich IN (".length, zu)
.split(",").map((t) => t.trim().split("'").join(""));
})();
const fehlend = erlaubt.filter((n) => !(n in b));
ok(erlaubt.length > 0 && fehlend.length === 0,
`der Wochenbericht kennt alle ${erlaubt.length} Bereiche der Datenbank`
+ (fehlend.length ? ` -- es fehlen: ${fehlend.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. */
/* ==== AUF DIE NEUE KARTE UMGESTELLT (03.10.2026) ================
Die Eventkarte wurde umgebaut (siehe pruef-eventkarte.mjs): Buehne
mit Titel auf dem Banner, Zeitraum einmal statt dreimal, Aufgaben
als Liste zum Abhaken. Dabei haben die Bausteine neue Namen
bekommen -- `.event-zeitraum__marke` heisst jetzt `.ev-noch`,
`.event-block__text` ist zu einer Liste aus `.ev-punkt__text`
geworden.
DIESE PRUEFUNG WURDE DESHALB NICHT GELOESCHT, SONDERN NACHGEZOGEN:
Sie sagt weiterhin dieselben FUENF Dinge zu, nur an der neuen
Stelle. Eine Pruefung wegzunehmen, weil sie nach einem Umbau rot
wird, senkt die Zahl und beweist danach nichts mehr
(Projektgedaechtnis, 28.08.2026: 789 statt 804 Pruefungen, und
acht davon fielen still weg).
WAS SICH INHALTLICH AENDERT: Der Zustand steht nicht mehr als
"laeuft bis TT.MM.JJJJ" da, sondern als "noch N Tage" -- dieselbe
Aussage, nur beantwortet sie jetzt die Frage, die man wirklich
hat. Geprueft wird deshalb auf ein WORT (nicht nur Farbe), nicht
mehr auf einen bestimmten Satz. */
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(".ev-noch")?.textContent.trim(),
spanne: k.querySelector(".ev-spanne")?.textContent.trim(),
schilder: [...k.querySelectorAll(".ev-block__schild")].map((s) => s.textContent.trim()),
texte: [...k.querySelectorAll(".ev-punkt__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}")`);
/* Ein Wort, kein leerer Platzhalter -- und es muss zum Zustand
passen: "noch N Tage" bzw. "heute ist der letzte Tag". */
ok(/^(noch \d+ Tage?|heute ist der letzte Tag)$/.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 (${JSON.stringify(event?.texte)})`);
/* 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(".ev-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();
}
/* =======================================================================
6. UND SIE LAEUFT GENAU EINMAL (11.09.2026)
=======================================================================
Der Fall, der auf dem echten Server gilt: Dort kennt die CHECK-Regel
'agentur' laengst. Ein Neustart darf die Tabelle dann NICHT noch
einmal umbauen -- jeder Umbau ist ein Kopieren von Hand auf echten
Daten, und was man nicht tun muss, tut man auf einer Live-Datenbank
nicht.
GEMESSEN AN DEN SICHERUNGEN, nicht an der Absicht: Vor jedem Umbau
legt die Anwendung eine Datei ".vor-agentur-<stempel>" an. Diese
Datei ist der Beweis, dass umgebaut wurde. Waeren es zwei, haette
ein Neustart erneut angefasst -- und genau das soll hier auffallen.
Der Server ist zwischendurch mehrfach neu gestartet worden, der Fall
ist also wirklich durchgespielt und nicht nur behauptet. */
{
const spuren = readdirSync(ordner).filter((n) => n.includes(".vor-agentur-"));
ok(spuren.length === 1,
`die Agentur-Umstellung lief genau EINMAL, trotz mehrerer Neustarts `
+ `(${spuren.length} Sicherung(en))`);
}
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);