/* ===================================================================== DIE ANLEITUNG — stimmt sie mit den Kacheln ueberein? (Etappe 1) ===================================================================== DIE EIGENTLICHE FRAGE DIESER DATEI steht im Bauplan gar nicht; sie kam beim Nachmessen dazu, und sie ist der Grund, warum es diese Pruefung ueberhaupt gibt: Erklaert die Anleitung genau die Kacheln, die es gibt -- und zwar JEDE und KEINE DARUEBER HINAUS? Beides kann lautlos kaputtgehen, und beides auf dieselbe Weise: Wer eine Kachel hinzufuegt, vergisst die Karte -- dann steht auf der Startseite etwas, das die Einweisung nicht kennt. Wer eine Kachel entfernt, laesst die Karte stehen -- dann erklaert die Einweisung etwas, das es nicht mehr gibt, mit einem Knopf, der ins Leere fuehrt. Der Bauplan sieht dafuer nur einen Hinweis in der Pflege-Ansicht vor ("Fuer diese Kachel fehlt ein Eintrag"). Der hilft nur, wenn jemand hinsieht. Diese Pruefung sieht immer hin. --------------------------------------------------------------------- WOHER DIE KACHELLISTE KOMMT Aus `workspace/assets/js/bereiche.js`, und zwar indem die Datei AUSGEFUEHRT wird -- nicht, indem jemand sie mit einem Muster durchsucht. Ein Muster waere eine zweite, ungenaue Nachbildung der Datei; sie wuerde bei der ersten ungewohnten Schreibweise still danebenliegen und dann "alles in Ordnung" melden. Der Server kennt diese Liste nicht (bereicheFuer gibt fuers Agenturhaus `null` zurueck). Deshalb muss die Pruefung sie sich selbst holen -- genau wie der Browser es tut. ===================================================================== */ import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import vm from "node:vm"; import { eigenerPort } from "./helfer-port.mjs"; const PORT = await eigenerPort(import.meta, "pruef-anleitung"); const ordner = mkdtempSync(join(tmpdir(), "ws-anl-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = String(PORT); process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; import { notbremse } from "./helfer-notbremse.mjs"; await import("./index.js"); notbremse(180_000, "pruef-anleitung"); await new Promise((r) => setTimeout(r, 800)); const BASIS = `http://127.0.0.1:${PORT}`; let fehler = 0, geprueft = 0; const melde = (t) => console.log(t); const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; function braucht(bedingung, was) { if (bedingung) return; console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}`); console.log("Das ist KEIN Befund am Code. Die Pruefung konnte ihre"); console.log("Voraussetzung nicht herstellen und sagt das, statt zu schweigen.\n"); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } process.exit(2); } /* ===================================================================== DIE KACHELLISTE — bereiche.js wirklich ausfuehren ===================================================================== */ function bereicheLaden() { const quelle = readFileSync( new URL("../workspace/assets/js/bereiche.js", import.meta.url), "utf8"); const fenster = {}; const tot = () => ({ setAttribute() {}, append() {}, style: { setProperty() {} } }); const umgebung = { window: fenster, URLSearchParams, document: { createElement: tot, createElementNS: tot } }; umgebung.globalThis = umgebung; vm.createContext(umgebung); vm.runInContext(quelle, umgebung, { filename: "bereiche.js" }); return fenster.Bereiche; } const B = bereicheLaden(); braucht(B && Array.isArray(B.GRUPPEN) && B.GRUPPEN.length > 0, "bereiche.js liefert keine Kachelliste — ohne sie ist nichts zu vergleichen"); braucht(typeof B.schluesselVonZiel === "function", "bereiche.js kennt schluesselVonZiel nicht"); melde("\n=== Der Kachel-Schluessel rechnet richtig ==="); const SCHLUESSEL_PROBEN = [ ["uebersicht.html", "uebersicht"], ["bereich.html?b=live", "bereich:live"], ["bereich.html?b=agentur", "bereich:agentur"], ["profil.html", "profil"], ["chat.html?raum=treff", "chat"], ["", ""], ]; for (const [ziel, erwartet] of SCHLUESSEL_PROBEN) { const ist = B.schluesselVonZiel(ziel); ok(ist === erwartet, `"${ziel || "(leer)"}" -> "${ist}" (erwartet "${erwartet}")`); } /* GEGENPROBE: Die Rechnung muss auch TRENNEN koennen. Lieferte sie fuer alles dasselbe, waeren die sechs Zeilen oben trotzdem teils gruen. */ ok(B.schluesselVonZiel("bereich.html?b=live") !== B.schluesselVonZiel("bereich.html?b=technik"), "Gegenprobe: zwei Bereiche derselben Seite bekommen verschiedene Schluessel"); function kachelnFuer(rolle) { const nach = new Map(); for (const g of B.GRUPPEN) { for (const k of (g.bereiche || [])) { if (Array.isArray(k.rollen) && !k.rollen.includes(rolle)) continue; const s = B.schluesselVonZiel(k.ziel); if (s) nach.set(s, k); } } return nach; } /* ===================================================================== LEUTE UND ANMELDUNG ===================================================================== */ const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetztIso = new Date().toISOString(); const kennSchluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", kennSchluessel, jetztIso); function anlegen(name, rolle, code) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 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," + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", kennSchluessel).update(code).digest("hex"), jetztIso); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } const ROLLEN = [["admin", "Filipe"], ["spicy", "Spicy Media"], ["manager", "Patrick"], ["scout", "Kevin"], ["creator", "Luna"], ["modi", "Miss"], ["hand", "VanVan"]]; const CODES = {}; for (const [i, [rolle, name]] of ROLLEN.entries()) { CODES[rolle] = `CODE-A${String(i).padStart(2, "0")}-000001`; anlegen(name, rolle, CODES[rolle]); } /* Wie viele Zeilen hat die Erstbefuellung angelegt? Direkt in der Datenbank nachgezaehlt und nicht ueber die Schnittstelle: Sonst pruefte man die Schnittstelle mit sich selbst. */ const inDb = d.prepare( "SELECT COUNT(*) n FROM anl_karte WHERE haus = 'agentur' AND rolle = 'creator'").get().n; d.close(); const { request } = await import("node:http"); const CREW_HOST = "crew.dogfather-universe.com"; function roh(pfad, host, koerper, keks, methode) { return new Promise((fertig, schief) => { const daten = koerper ? JSON.stringify(koerper) : null; const a = request({ host: "127.0.0.1", port: PORT, path: pfad, method: methode || (daten ? "POST" : "GET"), headers: { ...(host ? { Host: host } : {}), Origin: host ? `https://${host}` : BASIS, ...(keks ? { Cookie: keks } : {}), ...(daten ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(daten) } : {}), }, }, (antwort) => { let text = ""; antwort.on("data", (t) => { text += t; }); antwort.on("end", () => fertig({ code: antwort.statusCode, kopf: antwort.headers, text })); }); a.on("error", schief); if (daten) a.write(daten); a.end(); }); } async function anmelden(rolle, host = null) { const r = await roh("/workspace/api/anmelden", host, { rolle, code: CODES[rolle] }); if (r.code !== 200) return null; return [].concat(r.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ") || null; } melde("\n=== Alle Rollen kommen herein ==="); const kekse = {}; for (const [rolle] of ROLLEN) { const haus = (rolle === "modi" || rolle === "hand") ? CREW_HOST : null; kekse[rolle] = await anmelden(rolle, haus); ok(!!kekse[rolle], `${rolle}: angemeldet`); } braucht(Object.values(kekse).every(Boolean), "nicht alle Rollen konnten sich anmelden"); const holen = async (rolle, host = null) => { const r = await roh("/workspace/api/anleitung", host, null, kekse[rolle]); let j = null; try { j = JSON.parse(r.text); } catch { /* bleibt null */ } return { code: r.code, j }; }; /* ===================================================================== DIE ERSTBEFUELLUNG ===================================================================== */ melde("\n=== Die Inhalte sind eingespielt ==="); ok(inDb === 18, `Creator hat 18 Karten in der Datenbank (${inDb})`); const creator = await holen("creator"); ok(creator.code === 200, `Creator bekommt die Anleitung (HTTP ${creator.code})`); braucht(creator.code === 200 && creator.j, "die Schnittstelle antwortet dem Creator nicht"); ok(creator.j.hat_fassung === true, "Creator hat eine eigene Fassung"); ok(creator.j.anzahl === 18, `18 Karten ueber die Schnittstelle (${creator.j.anzahl})`); ok((creator.j.saetze || []).length === 3, `drei Saetze „In 60 Sekunden" (${(creator.j.saetze || []).length})`); ok((creator.j.schritte || []).length === 5, `fuenf Erste Schritte (${(creator.j.schritte || []).length})`); ok((creator.j.nicht || []).length === 5, `fuenf Zeilen „Nicht deins" (${(creator.j.nicht || []).length})`); ok((creator.j.takt || []).length === 4, `vier Rhythmus-Zeilen (${(creator.j.takt || []).length})`); ok((creator.j.fragen || []).length === 4, `vier haeufige Fragen (${(creator.j.fragen || []).length})`); ok(!!creator.j.leitgedanke && creator.j.leitgedanke.length > 40, "der Leitgedanke steht da"); /* DIE ZAHLEN DER ABNAHMELISTE — GERECHNET, NICHT ABGESCHRIEBEN. Der Bauplan nennt 5/6/7. Diese Zeile prueft nicht gegen die Zahl, sondern gegen die Summe: Zusammen muessen es so viele sein, wie es Karten gibt, und keine Karte darf eine unbekannte Stufe tragen. */ melde("\n=== Die Stufen ==="); const jeStufe = {}; for (const k of creator.j.karten) jeStufe[k.stufe] = (jeStufe[k.stufe] || 0) + 1; const bekannt = new Set((creator.j.stufen || []).map((s) => s.schluessel)); ok([...Object.keys(jeStufe)].every((s) => bekannt.has(s)), `jede Karte traegt eine bekannte Stufe (${Object.keys(jeStufe).join(", ")})`); ok(Object.values(jeStufe).reduce((a, b) => a + b, 0) === creator.j.karten.length, "die Stufen zusammen ergeben alle Karten"); ok(jeStufe.muss === 5 && jeStufe.regelmaessig === 6 && jeStufe.bedarf === 7, `Verteilung 5/6/7 wie im Bauplan (${jeStufe.muss}/${jeStufe.regelmaessig}/${jeStufe.bedarf})`); /* ===================================================================== DIE ZUORDNUNG — IN BEIDE RICHTUNGEN ===================================================================== */ melde("\n=== Jede Kachel hat eine Karte, jede Karte eine Kachel ==="); const kacheln = kachelnFuer("creator"); /* Die Anleitung selbst bekommt keine Karte. Eine Karte, die die Seite erklaert, auf der man gerade steht, ist Fuellmaterial. */ kacheln.delete("anleitung"); braucht(kacheln.size > 0, "fuer den Creator findet sich keine einzige Kachel"); const habenKarte = new Set(creator.j.karten.map((k) => k.kachel)); const ohneKarte = [...kacheln.keys()].filter((s) => !habenKarte.has(s)); const ohneKachel = [...habenKarte].filter((s) => !kacheln.has(s)); ok(ohneKarte.length === 0 && kacheln.size > 0, ohneKarte.length ? `${ohneKarte.length} Kacheln OHNE Karte: ${ohneKarte.join(", ")}` : `alle ${kacheln.size} Kacheln des Creators haben eine Karte`); ok(ohneKachel.length === 0 && habenKarte.size > 0, ohneKachel.length ? `${ohneKachel.length} Karten OHNE Kachel: ${ohneKachel.join(", ")}` : `alle ${habenKarte.size} Karten zeigen auf eine Kachel, die es gibt`); /* GEGENPROBE. Beide Zeilen oben koennen "in Ordnung" sagen. Koennen sie auch "nicht in Ordnung"? Ohne diesen Beweis waere eine leere Menge auf beiden Seiten gruen -- und genau so sieht ein kaputter Abgleich aus. */ const erfundeneKachel = new Set([...kacheln.keys(), "gibtesnicht"]); ok([...erfundeneKachel].filter((s) => !habenKarte.has(s)).length === 1, "Gegenprobe: eine Kachel ohne Karte wird gefunden"); const erfundeneKarte = new Set([...habenKarte, "auchnicht"]); ok([...erfundeneKarte].filter((s) => !kacheln.has(s)).length === 1, "Gegenprobe: eine Karte ohne Kachel wird gefunden"); /* Jede Karte muss alle drei Saetze haben. Eine halb gefuellte Karte sieht aus wie ein Ladefehler. */ const luecken = creator.j.karten.filter((k) => !k.wozu || !k.tust || !k.merke); ok(luecken.length === 0, luecken.length ? `${luecken.length} Karten ohne vollstaendige drei Zeilen: ` + luecken.map((k) => k.kachel).join(", ") : "jede Karte hat Wozu, Was du hier tust und Merke"); /* Die Erste-Schritte-Ziele muessen Kacheln dieser Rolle sein -- oder gar kein Ziel haben ("Installieren" ist ein Knopf, keine Seite). Ein Sprung auf eine Seite, die ein Creator nicht oeffnen darf, waere ein Knopf, der auf die Startseite zurueckwirft. */ const schrittFehler = (creator.j.schritte || []) .filter((s) => s.ziel) .filter((s) => !kachelnFuer("creator").has(B.schluesselVonZiel(s.ziel))); ok(schrittFehler.length === 0, schrittFehler.length ? `Erste Schritte zeigen ins Leere: ${schrittFehler.map((s) => s.ziel).join(", ")}` : "jeder Erste Schritt mit Ziel fuehrt auf eine Kachel dieser Rolle"); /* ===================================================================== WER DARF, WER NICHT ===================================================================== */ melde("\n=== Die drei Schranken ==="); const admin = await holen("admin"); ok(admin.code === 200 && admin.j?.hat_fassung === false, `DogFather bekommt keine Fassung, sondern eine Auskunft (HTTP ${admin.code}, ` + `hat_fassung ${admin.j?.hat_fassung})`); ok(!!admin.j?.satz && admin.j.satz.length > 30, "…und die Auskunft sagt ihm, wie er trotzdem hineinsieht"); ok(admin.j?.darf_pflegen === true, "DogFather darf pflegen"); const spicy = await holen("spicy"); ok(spicy.code === 200, `Spicy Media bekommt eine Antwort (HTTP ${spicy.code})`); ok(spicy.j?.darf_pflegen === true, "Spicy Media darf pflegen (Entscheidung 06.10.2026, wie bei den Manager-Zielen)"); /* Etappe 1 bringt nur den Creator. Scout, Manager und Spicy haben noch keine Inhalte -- das ist der Stand der Arbeit und kein Fehler. Es wird aber AUSGESPROCHEN, nicht uebersprungen. */ melde(` -- Scout, Manager und Spicy Media haben noch keine Inhalte ` + `(Etappe 3) — Spicy meldet ${spicy.j?.anzahl ?? 0} Karten`); const creatorPflege = creator.j.darf_pflegen; ok(creatorPflege === false, "ein Creator darf NICHT pflegen"); const modi = await holen("modi", CREW_HOST); ok(modi.code === 404, `ein Modi bekommt die Anleitung nicht (HTTP ${modi.code}, erwartet 404)`); const hand = await holen("hand", CREW_HOST); ok(hand.code === 404, `die rechte Hand bekommt sie auf der Crew-Adresse nicht (HTTP ${hand.code}, erwartet 404)`); /* Die Seite selbst, nicht nur die Schnittstelle. ===================================================================== GEPRUEFT WIRD DAS ZIEL, NICHT NUR DIE 302. Das Haus beantwortet "darfst du nicht" mit einer Umleitung und nicht mit 403 (workspace.js, bei `darfSeite`) -- ausdruecklich, damit es auf "nein" nur EINE Antwort gibt und niemand aus dem Unterschied etwas ablesen kann. Genau deshalb reicht hier "Status ist 302" nicht: NICHT ANGEMELDET gibt ebenfalls 302. Eine Pruefung auf die blosse Zahl waere auch dann gruen, wenn die Sitzung des Modi gar nicht erkannt wuerde -- sie haette dann die Anmeldung gemessen und nicht die Schranke. Unterscheidbar sind die beiden nur am Ziel: nicht angemeldet -> /workspace/ angemeldet, aber gesperrt -> /workspace/start.html ===================================================================== */ const seiteCreator = await roh("/workspace/anleitung.html", null, null, kekse.creator); ok(seiteCreator.code === 200, `anleitung.html oeffnet fuer den Creator (${seiteCreator.code})`); const seiteModi = await roh("/workspace/anleitung.html", CREW_HOST, null, kekse.modi); ok(seiteModi.code === 302 && seiteModi.kopf.location === "/workspace/start.html", `ein angemeldeter Modi wird zur Startseite umgeleitet ` + `(${seiteModi.code} -> ${seiteModi.kopf.location})`); const seiteOhne = await roh("/workspace/anleitung.html", null, null, null); ok(seiteOhne.code === 302 && seiteOhne.kopf.location === "/workspace/", `ohne Anmeldung zur Anmeldung, nicht zur Startseite ` + `(${seiteOhne.code} -> ${seiteOhne.kopf.location})`); /* GEGENPROBE ZU DEN BEIDEN: Sie unterscheiden wirklich. Zeigten beide Umleitungen auf dasselbe, waeren die Zeilen oben zusammen trotzdem gruen zu bekommen -- und die Schranke waere ungeprueft. */ ok(seiteModi.kopf.location !== seiteOhne.kopf.location, "Gegenprobe: gesperrt und nicht angemeldet fuehren an verschiedene Orte"); /* ===================================================================== DIE ERSTBEFUELLUNG DARF NICHTS UEBERSCHREIBEN ===================================================================== Die wichtigste Zeile in anleitung-tabellen.js: Eingespielt wird nur, wenn fuer eine Rolle noch gar nichts da ist. Liefe sie bei jedem Start, waere jede Aenderung aus der Pflege-Ansicht beim naechsten Neustart weg -- lautlos, und man merkte es Wochen spaeter. */ melde("\n=== Ein zweiter Lauf laesst Aenderungen in Ruhe ==="); const d2 = new DatabaseSync(process.env.WORKSPACE_DB); d2.prepare("UPDATE anl_karte SET merke = ? WHERE haus='agentur' AND rolle='creator' AND kachel='chat'") .run("VON HAND GEAENDERT"); const { anleitungTabellen } = await import("./anleitung-tabellen.js"); anleitungTabellen(d2); const nachher = d2.prepare( "SELECT merke FROM anl_karte WHERE haus='agentur' AND rolle='creator' AND kachel='chat'").get(); const anzahlNachher = d2.prepare( "SELECT COUNT(*) n FROM anl_karte WHERE haus='agentur' AND rolle='creator'").get().n; d2.close(); ok(nachher?.merke === "VON HAND GEAENDERT", `der geaenderte Text steht noch da ("${(nachher?.merke || "").slice(0, 24)}")`); ok(anzahlNachher === 18, `und es sind keine Karten dazugekommen (${anzahlNachher})`); /* ===================================================================== ETAPPE 2 — DIE ERSTEN SCHRITTE ZUM ABHAKEN ===================================================================== */ melde("\n=== Abhaken: der Anfangszustand ==="); const schritte0 = creator.j.schritte || []; ok(schritte0.every((s) => Number.isInteger(s.id) && s.id > 0), "jeder Schritt hat eine Nummer"); ok(schritte0.every((s) => s.erledigt === false), "am Anfang ist kein Schritt abgehakt"); ok(creator.j.fortschritt?.erledigt === 0 && creator.j.fortschritt?.gesamt === 5, `Fortschritt 0 von 5 (${creator.j.fortschritt?.erledigt} von ${creator.j.fortschritt?.gesamt})`); ok(creator.j.darf_abhaken === true, "ein Creator darf seine eigenen Schritte abhaken"); const haken = (rolle, id, erledigt, host = null) => roh(`/workspace/api/anleitung/schritt/${id}`, host, { erledigt }, kekse[rolle], "PUT"); melde("\n=== Abhaken: setzen, zaehlen, wegnehmen ==="); const ersterId = schritte0[0].id; const r1 = await haken("creator", ersterId, true); let j1 = null; try { j1 = JSON.parse(r1.text); } catch { /* bleibt null */ } ok(r1.code === 200 && j1?.erledigt === true, `Schritt abgehakt (HTTP ${r1.code})`); ok(j1?.fortschritt?.erledigt === 1, `Fortschritt jetzt 1 von 5 (${j1?.fortschritt?.erledigt})`); /* ZWEIMAL DASSELBE IST DERSELBE ZUSTAND. Bei wackeligem Mobilfunk tippt man nach, und das darf nicht doppelt zaehlen. */ const r2 = await haken("creator", ersterId, true); let j2 = null; try { j2 = JSON.parse(r2.text); } catch { /* bleibt null */ } ok(r2.code === 200 && j2?.fortschritt?.erledigt === 1, `zweimal abhaken zaehlt einmal (${j2?.fortschritt?.erledigt})`); /* Und er haelt ueber einen neuen Abruf -- sonst stuende er nur im Arbeitsspeicher der Antwort. */ const nachHaken = await holen("creator"); ok(nachHaken.j?.fortschritt?.erledigt === 1, `der Haken steht auch beim naechsten Laden (${nachHaken.j?.fortschritt?.erledigt} von 5)`); ok(nachHaken.j?.schritte?.find((s) => s.id === ersterId)?.erledigt === true, "…und zwar an genau dem Schritt, der angetippt wurde"); const r3 = await haken("creator", ersterId, false); let j3 = null; try { j3 = JSON.parse(r3.text); } catch { /* bleibt null */ } ok(r3.code === 200 && j3?.fortschritt?.erledigt === 0, `der Haken laesst sich wieder wegnehmen (${j3?.fortschritt?.erledigt})`); melde("\n=== Abhaken: wer darf das nicht ==="); /* Ein Creator darf keinen Schritt abhaken, der es gar nicht ist -- sonst meldete der Fortschritt eines Tages 7 von 5. */ const eineFrage = await (async () => { const d3 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); const z = d3.prepare("SELECT id FROM anl_zeile WHERE art = 'frage' LIMIT 1").get(); d3.close(); return z?.id; })(); braucht(!!eineFrage, "keine Frage-Zeile gefunden, mit der sich das pruefen liesse"); const rFrage = await haken("creator", eineFrage, true); ok(rFrage.code === 404, `eine Frage laesst sich nicht abhaken (HTTP ${rFrage.code}, erwartet 404)`); /* DogFather hat keine eigene Fassung -- also auch keine Schritte. */ const rDogi = await haken("admin", ersterId, true); ok(rDogi.code === 404, `DogFather kann einen Creator-Schritt nicht abhaken (HTTP ${rDogi.code}, erwartet 404)`); const rModi = await haken("modi", ersterId, true, CREW_HOST); ok(rModi.code === 404, `ein Modi auch nicht (HTTP ${rModi.code}, erwartet 404)`); /* Unsinn als Nummer darf nicht durchrutschen. */ const rUnsinn = await roh("/workspace/api/anleitung/schritt/abc", null, { erledigt: true }, kekse.creator, "PUT"); ok(rUnsinn.code === 400 || rUnsinn.code === 404, `keine Nummer -> abgelehnt (HTTP ${rUnsinn.code})`); /* GEGENPROBE: Die Ablehnungen oben koennten auch davon kommen, dass der Weg gar nicht existiert. Dass er funktioniert, ist oben bewiesen -- hier wird es noch einmal unmittelbar daneben gezeigt. */ const rEcht = await haken("creator", schritte0[1].id, true); ok(rEcht.code === 200, "Gegenprobe: derselbe Weg nimmt einen gueltigen Schritt an"); await haken("creator", schritte0[1].id, false); /* ===================================================================== DER HINWEIS „Erste Schritte noch offen" ===================================================================== */ melde("\n=== Der Hinweis ==="); async function hinweise(rolle, host = null) { const r = await roh("/workspace/api/hinweise", host, null, kekse[rolle]); let j = null; try { j = JSON.parse(r.text); } catch { /* bleibt null */ } return { code: r.code, liste: j?.hinweise || [] }; } const hCreator = await hinweise("creator"); const hSchritte = hCreator.liste.find((h) => h.art === "anl_schritte"); ok(hCreator.code === 200 && !!hSchritte, `der Creator bekommt den Hinweis (HTTP ${hCreator.code})`); ok(hSchritte?.anzahl === 5, `…mit allen fuenf offenen Schritten (${hSchritte?.anzahl})`); ok(hSchritte?.ziel === "anleitung.html", `…und dem Weg zur Anleitung (${hSchritte?.ziel})`); /* Die Crew darf den Hinweis NICHT bekommen -- die dritte Tuer, durch die am 15.09.2026 ein Weg fuer die Community gerutscht ist. */ const hModi = await hinweise("modi", CREW_HOST); /* DIE ANTWORT MUSS ANGEKOMMEN SEIN. Ohne `code === 200` waere diese Zeile auch dann gruen, wenn die ganze Hinweis-Schnittstelle fuer den Modi scheitert -- eine leere Liste sieht genauso aus wie "kein Hinweis fuer ihn dabei". Dieselbe Falle wie am 03.09.2026 beim Gitea-Abgleich: Eine Pruefung, die ihre Voraussetzung nicht hat, darf nicht wie Erfolg aussehen. */ ok(hModi.code === 200 && !hModi.liste.some((h) => h.art === "anl_schritte"), `ein Modi bekommt den Hinweis nicht (HTTP ${hModi.code}, ` + `${hModi.liste.length} andere Hinweise)`); const hDogi = await hinweise("admin"); ok(hDogi.code === 200 && !hDogi.liste.some((h) => h.art === "anl_schritte"), `DogFather bekommt ihn auch nicht — er hat keine Einweisung ` + `(HTTP ${hDogi.code}, ${hDogi.liste.length} andere Hinweise)`); /* ---- Und er verschwindet von selbst ---- */ melde("\n=== Alles abgehakt: der Hinweis geht von allein ==="); for (const s of schritte0) await haken("creator", s.id, true); const fertig = await holen("creator"); ok(fertig.j?.fortschritt?.erledigt === 5, `alle fuenf abgehakt (${fertig.j?.fortschritt?.erledigt} von ${fertig.j?.fortschritt?.gesamt})`); const hNachher = await hinweise("creator"); ok(!hNachher.liste.some((h) => h.art === "anl_schritte"), "der Hinweis ist weg, ohne dass ihn jemand abgeschaltet hat"); /* GEGENPROBE: Er kommt wieder, wenn wieder etwas offen ist. Ohne das waere „weg" auch dann gruen, wenn der Hinweis gar nicht mehr entstehen KANN. */ await haken("creator", ersterId, false); const hWieder = await hinweise("creator"); ok(hWieder.liste.some((h) => h.art === "anl_schritte" && h.anzahl === 1), "Gegenprobe: ein wieder geoeffneter Schritt bringt ihn zurueck"); /* Aufraeumen, damit die Pruefungen darunter einen sauberen Stand sehen -- der Haken auf Schritt 2 und die vier von eben. */ for (const s of schritte0) await haken("creator", s.id, false); /* ---------- Abschluss ------------------------------------------------ */ try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(`\n${fehler ? "FEHLER: " + fehler : "Alles in Ordnung"} — ${geprueft} Prüfungen`); process.exit(fehler ? 1 : 0);