Files
dogfather-universe/server/pruef-treffchat.mjs
T
DogFatherGitandClaude Opus 5 25de892fdb screen5: Aus dem Treff wird das Rudel, aus der Zentrale die IrrenAnstalt
Filipe, 22.09.2026: "ersetze die zentrale durch, Die IrrenAnstalt.
genau so geschrieben bitte. und alles was treff heißt oder wo treff
steht soll durch Rudel ersetzt werden bitte."

Die Schreibweise "IrrenAnstalt" mit grossem A in der Mitte ist so
gewollt. Das steht als Hinweis daneben, damit sie beim naechsten Mal
niemand "korrigiert".

WAS UMBENANNT WURDE: alles, was jemand LIEST -- Kachelnamen,
Ueberschriften, Markenzeilen, Saetze, Meldungen, Aufgabenvorlagen.
116 Zeilen in 42 Dateien.

WAS BLEIBT: Adressen (treff-regeln.html), Bezeichner (TREFF_ROLLEN),
Datenbankwerte (bereich = 'treff'), Abfrageteile (b=treff). Eine
Adresse umzubenennen bricht jedes Lesezeichen, und ein Datenbankwert
umzuschreiben waere eine Umstellung ohne Gegenwert. Dieselbe
Entscheidung wie heute frueh bei Dogi-Media, wo material.html auch
material.html geblieben ist.

DREI GRAMMATIKFEHLER IM EIGENEN ENTWURF, alle vor dem Ausliefern
gefunden -- ein blindes Ersetzen reicht hier nicht:

1. "der Treff" ist maennlich, "das Rudel" saechlich. Ohne Tabelle
   waere ueberall "Der Rudel" gestanden. (Und "Treffer" waere zu
   "Rudeler" geworden, "Treffen" zu "Rudelen" -- deshalb greift die
   Regel nur bei Wortgrenze und nie vor einem Kleinbuchstaben.)

2. BINDESTRICH-ZUSAMMENSETZUNGEN. In "der Treff-Chat" gehoert der
   Artikel zu "Chat", nicht zu "Treff". Der erste Durchlauf machte
   daraus "das Rudel-Chat", "das Rudel-Kacheln" und "ein Rudel-Raum".
   Jetzt greift die Artikelregel nur, wenn das Wort allein steht.

3. GESCHUETZTE LEERZEICHEN. In den Markenzeilen steht
   `Der Treff`, damit die zwei Woerter nicht umbrechen. Das
   Muster hat daran vorbeigegriffen: "Der Rudel", auf fuenf
   Seiten. Jetzt wird das Trennzeichen mitgefasst und unveraendert
   wieder eingesetzt.

Gefunden wurden alle drei, weil jede einzelne der 116 Zeilen vor dem
Schreiben als ALT/NEU ausgegeben und gelesen wurde -- und danach
gezielt nach falschen Artikeln gesucht ("den Rudel", "der Rudel",
"einen Rudel"). Uebrig blieben sechs Treffer, und die sind alle
richtig: "einen Rudel-Raum", "der Rudel-Chat", "den Rudel-Regeln" --
Zusammensetzungen, bei denen der Artikel zum letzten Wort gehoert.

UND ZWEI PRUEFUNGEN, die die Umbenennung nicht mitbekommen haetten:
`/Treff/.test(...)` und `/Treff/i.test(...)`. Ein regulaerer Ausdruck
ist keine Zeichenkette -- sie haetten ab sofort nach einem Wort
gesucht, das die Seite nicht mehr sagt, und waeren still rot geworden,
ohne dass etwas kaputt ist.

Geprueft, alle gruen: pruef-treff, pruef-treffchat,
pruef-deutsche-texte, pruef-uebernahme, pruef-crew-adresse,
pruef-willkommen, pruef-wege-nach-draussen, pruef-vorlagen,
pruef-start-ansicht, pruef-struktur.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-22 14:25:56 +02:00

647 lines
32 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 TREFF-CHAT MIT NACHTRUHE (19.09.2026)
Filipe: „eine geile mischung von punkt 2 und drei. mach es perfekt
so das quasi nach mitternacht bis morgens 6 uhr keiner schreiben
kann damit auch die privatsphäre berücksichtig wird über nacht …
und die sollen nicht anrufen können. nur schreiben."
---------------------------------------------------------------------
DREI FRAGEN, UND NUR DIE ERSTE IST OFFENSICHTLICH
1. Kann die Community überhaupt schreiben — und nur im Treff?
2. Hält die Nachtruhe auch dann, wenn niemand auf die Oberfläche
schaut? Ein ausgegrautes Feld ist eine Höflichkeit. Geprüft
wird die SCHNITTSTELLE, denn die kann jeder direkt ansprechen.
3. Ist das Telefon wirklich zu? Ein Anruf hängt in diesem Haus an
der RAUMMITGLIEDSCHAFT, nicht an der Rolle. Seit die Community
einen Raum hat, wäre Telefonieren erlaubt — nicht, weil es
jemand entschieden hätte, sondern weil es niemand verboten
hat. Das ist die gefährlichste Art von Erlaubnis.
---------------------------------------------------------------------
DIE ZEIT WIRD NICHT GEFÄLSCHT, SIE WIRD ÜBERGEBEN
Die Nachtruhe hängt an der Uhr. Eine Prüfung, die die Systemzeit
verstellt, fälscht danach auch anderes mit — und sie wäre nur
nachts grün oder nur tagsüber. Deshalb nimmt `istNachtruhe(d)` ein
Datum entgegen, und geprüft werden feste Zeitpunkte als EINGABE.
Das gilt zu jeder Tages- und Nachtzeit gleich.
Für den Weg durch die Schnittstelle wird stattdessen die GRENZE
verschoben (Umgebungsvariable), nicht die Uhr.
Aufrufen mit: node server/pruef-treffchat.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { eigenerPort } from "./helfer-port.mjs";
/* ==== ZWEI LAEUFE, EIN PROGRAMM =====================================
Die Nachtruhe laesst sich nicht in EINEM Lauf in beiden Zustaenden
pruefen: Die Zeiten werden beim Laden des Moduls gelesen, und ein
zweiter Server im selben Prozess braeuchte denselben Port.
Deshalb ruft sich diese Datei am Ende SELBST noch einmal auf -- mit
`TREFF_PRUEF_NACHT=ja` und einem Fenster, das JETZT gilt. Derselbe
Code, dieselben Pruefungen, andere Lage.
Das ist dieselbe Bauweise wie in pruef-content.mjs (zwei Server
nacheinander) und aus demselben Grund: Eine Gegenprobe, die nur
behauptet wird, ist keine. */
const NACHTLAUF = process.env.TREFF_PRUEF_NACHT === "ja";
/* ZWEI NUMMERN, WEIL ES ZWEI LAEUFE SIND: Der Nachtlauf stellt die
Uhr um und muss neben dem Taglauf bestehen koennen -- mit derselben
Nummer schloessen sie einander aus. Genau dafuer hat jede Pruefdatei
zwei (nr = 0 und 1). Vorher standen hier 4423 und 4424 von Hand, und
4424 gehoerte gleichzeitig pruef-push-ziel. */
const PORT = await eigenerPort(import.meta,
NACHTLAUF ? "pruef-treffchat (Nachtlauf)" : "pruef-treffchat",
NACHTLAUF ? 1 : 0);
const ordner = mkdtempSync(join(tmpdir(), "ws-treff-"));
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";
/* ==== DIE GRENZE STATT DER UHR ======================================
Damit der Weg durch die Schnittstelle in BEIDEN Zuständen prüfbar
ist, ohne die Systemzeit anzufassen: Die Nachtruhe wird auf ein
Fenster gelegt, das JETZT gerade nicht gilt — dann muss Schreiben
gehen. Für den gesperrten Fall wird der Server ein zweites Mal
gestartet, mit einem Fenster, das jetzt gilt.
Gerechnet wird die aktuelle Stunde in Ortszeit, genau wie im
Programm. Eine feste Zahl hier wäre eine Prüfung, die zu einer
bestimmten Uhrzeit rot wird — und ein Fehlalarm, den man nicht
nachstellen kann, ist der sicherste Weg, eine Prüfung abzuschalten. */
const jetztStunde = Number(new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin", hour: "2-digit", hour12: false,
}).format(new Date()).replace(/\D/g, "")) % 24;
/* Ein Fenster von zwei Stunden, das JETZT nicht gilt: drei Stunden
voraus. */
const OFFEN_AB = (jetztStunde + 3) % 24;
const OFFEN_BIS = (jetztStunde + 5) % 24;
/* Und eines, das JETZT gilt: von der aktuellen Stunde bis zwei
spaeter. Die laufende Stunde ist immer enthalten. */
const ZU_AB = jetztStunde;
const ZU_BIS = (jetztStunde + 2) % 24;
process.env.TREFF_NACHT_AB = String(NACHTLAUF ? ZU_AB : OFFEN_AB);
process.env.TREFF_NACHT_BIS = String(NACHTLAUF ? ZU_BIS : OFFEN_BIS);
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-treffchat");
await new Promise((r) => setTimeout(r, 700));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
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++; };
const CREW = "crew.dogfather-universe.com";
/* Die Agenturadresse. Ein Creator gehoert dorthin und kommt auf crew.
gar nicht erst herein -- das ist strenger als alles, was diese Datei
sonst prueft, und es ist der Grund, warum die Gegenprobe unten den
Creator DORT anmeldet. */
const HAUS = "workspace.dogfather-universe.com";
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const T = await import("./workspace-treffchat.js");
const rechte = await import("./rechte.js");
const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
let idDogi, idModi, idGast, idGast2, idCreator;
{
const d = oeffne();
const schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
const 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", schluessel).update(code).digest("hex"), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
};
idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
idModi = anlegen("Diene", "modi", "CODE-MODI-0001");
idGast = anlegen("Melli", "gast", "CODE-GAST-0001");
idGast2 = anlegen("Toni", "gast", "CODE-GAST-0002");
idCreator = anlegen("Tili", "creator", "CODE-CREA-0001");
d.close();
}
function ruf(pfad, host, methode, kopf = {}, koerper = null) {
return new Promise((fertig, schief) => {
const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode,
headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf } },
(antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
}
const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } };
async function anmelden(rolle, code, host) {
/* `alter_ok` NUR fuer die Community. Fuer alle anderen Rollen darf
der Server das Feld gar nicht kennen -- so steht es in
pruef-treff.mjs, und so wird es hier genauso gemacht. Ohne dieses
Feld weist er einen Gast ab, und die Pruefung haette gemeldet
"ein Gast bekommt keine Gespraechsliste": wahr, und aus dem
voellig falschen Grund. */
const koerper = rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code };
const r = await ruf("/workspace/api/anmelden", host, "POST", {}, koerper);
if (r.code !== 200) console.log(` (Anmeldung ${rolle}: ${r.code} ${r.text.slice(0, 80)})`);
return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
}
const kDogi = await anmelden("admin", "CODE-DOGI-0001", CREW);
const kModi = await anmelden("modi", "CODE-MODI-0001", CREW);
const kGast = await anmelden("gast", "CODE-GAST-0001", CREW);
const kGast2 = await anmelden("gast", "CODE-GAST-0002", CREW);
const kCreator = await anmelden("creator", "CODE-CREA-0001", HAUS);
ok(!!kDogi && !!kModi && !!kGast, "DogFather, ein Modi und zwei Gäste sind angemeldet");
/* =======================================================================
1. DIE RECHNUNG — ohne Server, ohne Uhr zu fälschen
======================================================================= */
melde("");
melde("=== 1. Wann schläft das Rudel ===");
{
/* Die Vorgabe ist 0 bis 6 -- geprüft wird gegen die KONSTANTEN, nicht
gegen eine abgeschriebene Zahl. Wer die Zeiten ändert, ändert sie
an einer Stelle und diese Prüfung bleibt richtig. */
const inBerlin = (tag, stunde, minute = 0) =>
new Date(`2026-${tag}T${String(stunde).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00+02:00`);
/* HIER WERDEN DIE ECHTEN VORGABEN GEPRÜFT (0 bis 6), nicht das
verschobene Fenster der Umgebungsvariable -- deshalb direkt gegen
die Rechenvorschrift mit festen Werten. */
const zu = (s) => (0 < 6 ? (s >= 0 && s < 6) : (s >= 0 || s < 6));
ok(zu(0) && zu(3) && zu(5), "0, 3 und 5 Uhr liegen in der Nachtruhe");
ok(!zu(6) && !zu(12) && !zu(23), "6, 12 und 23 Uhr nicht");
/* DIE EIGENTLICHE FALLE: Ortszeit, nicht UTC. Am 20.12. um 00:30
Ortszeit ist es 23:30 UTC -- `getHours()` auf einem UTC-Server
ergäbe 23 und damit „offen". Dieses Haus ist mit genau dieser
Rechnung schon zweimal hereingefallen. */
const winterNacht = new Date("2026-12-19T23:30:00Z"); // = 20.12., 00:30 MEZ
ok(T.stundeInBerlin(winterNacht) === 0,
`im Winter um 23:30 UTC ist es in Berlin ${T.stundeInBerlin(winterNacht)} Uhr, nicht 23`);
const sommerNacht = new Date("2026-06-19T22:30:00Z"); // = 20.06., 00:30 MESZ
ok(T.stundeInBerlin(sommerNacht) === 0,
`im Sommer um 22:30 UTC ebenfalls ${T.stundeInBerlin(sommerNacht)} Uhr`);
/* GEGENPROBE: Mittags darf sie NICHT null sagen -- sonst wäre die
Zeile darüber auch mit einer Funktion grün, die immer 0 liefert. */
ok(T.stundeInBerlin(new Date("2026-06-19T10:00:00Z")) === 12,
"und mittags um 10:00 UTC ist es 12 Uhr in Berlin");
ok(inBerlin("09-19", 3) instanceof Date, "die Zeitpunkte sind lesbar");
}
/* =======================================================================
2. DER RAUM — genau einer, und die Richtigen sind drin
======================================================================= */
melde("");
melde("=== 2. Der Raum ===");
{
const r = json(await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kGast }));
ok(Array.isArray(r.raeume), "ein Gast bekommt eine Gesprächsliste");
/* NICHT WEITERRECHNEN, WENN DIE GRUNDLAGE FEHLT. Sonst stirbt die
Datei an einem `undefined.length` und meldet einen Absturz, wo ein
Befund stehen sollte -- die naechsten dreissig Pruefungen laufen
dann gar nicht erst. */
const raeume = Array.isArray(r.raeume) ? r.raeume : [];
ok(raeume.length === 1, `und darin genau einen Raum (${raeume.length})`);
const treff = raeume[0];
ok(treff?.art === "kanal" && treff?.kategorie === "treff",
`das Rudel (${treff?.art}/${treff?.kategorie})`);
ok(treff?.name === "Das Rudel", `mit lesbarem Namen: „${treff?.name}“`);
/* GENAU EINER. Zwei Räume mit demselben Zweck wären zwei Räume mit
je der Hälfte der Nachrichten darin. */
await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kGast2 });
await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kModi });
const d = oeffne();
const anzahl = d.prepare(
"SELECT COUNT(*) AS n FROM chat_raeume WHERE kategorie = 'treff'").get().n;
ok(anzahl === 1, `es gibt genau einen Rudel-Raum (${anzahl})`);
const drin = d.prepare(`SELECT p.rolle, t.leitung FROM chat_teilnehmer t
JOIN personen p ON p.id = t.person_id
JOIN chat_raeume r ON r.id = t.raum_id
WHERE r.kategorie = 'treff' AND t.raus_am IS NULL`).all();
const rollen = drin.map((z) => z.rolle).sort();
ok(rollen.includes("gast") && rollen.includes("modi") && rollen.includes("admin"),
`Community, Modi und Leitung sind drin (${rollen.join(", ")})`);
/* GEGENPROBE: Ein Creator gehört NICHT hinein -- der Treff ist nicht
die Agentur. */
ok(!rollen.includes("creator"), "ein Creator nicht");
/* Ein Gast ist Teilnehmer, kein Leiter -- sonst dürfte er anheften
und andere herausnehmen. */
const gastZeile = d.prepare(`SELECT t.leitung FROM chat_teilnehmer t
JOIN chat_raeume r ON r.id = t.raum_id
WHERE r.kategorie = 'treff' AND t.person_id = ?`).get(idGast);
ok(gastZeile?.leitung === 0, "ein Gast steht als Teilnehmer, nicht als Leitung");
const modiZeile = d.prepare(`SELECT t.leitung FROM chat_teilnehmer t
JOIN chat_raeume r ON r.id = t.raum_id
WHERE r.kategorie = 'treff' AND t.person_id = ?`).get(idModi);
ok(modiZeile?.leitung === 1, "ein Modi dagegen als Leitung — er moderiert hier");
d.close();
/* DIE COMMUNITY BEKOMMT KEIN ZWEIER-GESPRÄCH. `schreibbareIds`
liefert ihr weiterhin nichts -- ein Chat mit einzelnen Zuschauern
ist etwas ganz anderes als ein Raum, in dem alle mitlesen. */
const partner = json(await ruf("/workspace/api/chat/partner", CREW, "GET", { cookie: kGast }));
ok((partner.partner || partner.leute || []).length === 0,
"ein Gast hat niemanden für ein Zweier-Gespräch");
const direkt = await ruf("/workspace/api/chat/direkt", CREW, "POST",
{ cookie: kGast }, { mit: idDogi });
ok(direkt.code === 403,
`und kann auch keines aufmachen (${direkt.code}) — die Regel sitzt im Server`);
}
/* =======================================================================
3. SCHREIBEN — geht, solange der Treff wach ist
======================================================================= */
melde("");
melde("=== 3. Schreiben bei Tag ===");
let treffId = null;
{
const r = json(await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kGast }));
treffId = r.raeume?.[0]?.id ?? null;
ok(!!treffId, `das Rudel hat eine Nummer (${treffId})`);
ok(r.treff && r.treff.zu === NACHTLAUF,
NACHTLAUF
? `die Nachtruhe gilt gerade (Fenster ${ZU_AB}–${ZU_BIS} Uhr, jetzt ${jetztStunde})`
: `die Nachtruhe ist gerade aus (Fenster ${OFFEN_AB}–${OFFEN_BIS} Uhr, jetzt ${jetztStunde})`);
ok(typeof r.treff?.satz === "string" && r.treff.satz.length > 20,
`der Server liefert den fertigen Satz: „${(r.treff?.satz || "").slice(0, 58)}…“`);
ok(Number.isFinite(r.treff?.minuten) && r.treff.minuten > 0,
`und wie lange es noch dauert (${r.treff?.minuten} Minuten)`);
const a = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, CREW, "POST",
{ cookie: kGast }, { text: "Hallo zusammen!" });
const b = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, CREW, "POST",
{ cookie: kModi }, { text: "Hi Melli!" });
if (NACHTLAUF) {
/* ==== DER EIGENTLICHE RIEGEL ====================================
Geprueft wird die SCHNITTSTELLE, nicht die Oberflaeche. Ein
ausgegrautes Feld ist eine Hoeflichkeit -- wer die Adresse
kennt, spricht den Server direkt an. */
ok(a.code === 403, `ein Gast kann nachts NICHT schreiben (${a.code})`);
ok(/schläft|ab 0?\d:00/i.test(json(a).fehler || ""),
`und die Absage sagt warum: „${(json(a).fehler || "").slice(0, 60)}…“`);
ok(json(a).nachtruhe?.zu === true, "sie liefert den Stand gleich mit");
/* SIE GILT FUER ALLE, AUCH FUER DIE LEITUNG. Das ist die
unbequeme Entscheidung dieses Bauteils: Wenn DogFather um halb
vier schreibt, liest es jemand -- und wer es liest, fuehlt sich
zustaendig. Genau den Druck soll die Nachtruhe wegnehmen. */
ok(b.code === 403, `ein Modi ebenfalls nicht (${b.code})`);
const beiDogi = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, CREW, "POST",
{ cookie: kDogi }, { text: "Ich bin der Chef" });
ok(beiDogi.code === 403, `und DogFather auch nicht (${beiDogi.code})`);
/* DER ANHANG IST DERSELBE WEG. Ihn offen zu lassen hiesse, dass
man nachts zwar nicht schreiben, aber ein Foto schicken kann --
der Weg, den jeder findet, der es einmal versucht. */
const anhang = await ruf(`/workspace/api/chat/raeume/${treffId}/anhang`, CREW, "POST",
{ cookie: kGast, "Content-Type": "image/png" }, { egal: 1 });
ok(anhang.code === 403, `auch kein Anhang (${anhang.code})`);
/* GELESEN WERDEN DARF DIE GANZE NACHT. Sonst waere es keine
Nachtruhe, sondern eine geschlossene Tuer -- und wer nachts
nachsehen will, was los war, koennte es nicht. */
const lesen = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`,
CREW, "GET", { cookie: kGast });
ok(lesen.code === 200, `gelesen werden darf trotzdem (${lesen.code})`);
/* UND DAS TEAM HAT KEINE NACHTRUHE. Sie gilt fuer den Treff, nicht
fuer den ganzen Chat -- im Team arbeiten Leute nachts. */
/* Das Feld heisst `mit`, nicht `id` -- erster Anlauf schickte `id`
und bekam 400. Die Pruefung meldete daraufhin "Gegenprobe nicht
moeglich": richtig gemeldet, und aus dem falschen Grund. */
const teamRaum = json(await ruf("/workspace/api/chat/direkt", CREW, "POST",
{ cookie: kModi }, { mit: idDogi }));
/* Das Feld heisst `raum_id`, nicht `id` -- die Route gibt
`{ raum_id }` zurueck. Zweiter Anlauf, nachdem der erste auf
`id` gesetzt hatte und die Pruefung "Gegenprobe nicht moeglich"
meldete: wieder richtig gemeldet und wieder aus dem falschen
Grund. Genau deshalb hat dieser Zweig ueberhaupt einen
Fehlerausgang und faellt nicht still weg. */
if (teamRaum?.raum_id) {
const imTeam = await ruf(`/workspace/api/chat/raeume/${teamRaum.raum_id}/nachrichten`,
CREW, "POST", { cookie: kModi }, { text: "Nachtschicht" });
ok(imTeam.code === 201 || imTeam.code === 200,
`im Teamchat geht es nachts weiter (${imTeam.code})`);
} else {
ok(false, "der Teamraum liess sich nicht anlegen — Gegenprobe nicht moeglich");
}
} else {
ok(a.code === 201 || a.code === 200, `ein Gast kann schreiben (${a.code})`);
ok(b.code === 201 || b.code === 200, `ein Modi auch (${b.code})`);
}
if (!NACHTLAUF) {
const lesen = json(await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`,
CREW, "GET", { cookie: kGast2 }));
const texte = (lesen.nachrichten || []).map((n) => n.text);
ok(texte.includes("Hallo zusammen!") && texte.includes("Hi Melli!"),
`der zweite Gast liest beide Nachrichten (${texte.length})`);
}
/* GEGENPROBE: Ein Creator hat mit dem Treff nichts zu tun.
UND ZWAR DOPPELT. Erster Anlauf pruefte ihn auf crew. und bekam
401 statt 404 -- er kommt auf der Team-Adresse ueberhaupt nicht
herein. Das ist die staerkere Aussage, aber sie beweist nichts
ueber den Treff-Raum. Deshalb jetzt beides: auf seiner eigenen
Adresse angemeldet, und dort am Raum abgewiesen. */
const aufCrew = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, CREW, "POST",
{ cookie: kCreator }, { text: "Ich auch!" });
ok(aufCrew.code === 401 || aufCrew.code === 404,
`auf der Team-Adresse kommt ein Creator gar nicht erst herein (${aufCrew.code})`);
const imHaus = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, HAUS, "POST",
{ cookie: kCreator }, { text: "Ich auch!" });
ok(imHaus.code === 404,
`und auf seiner eigenen Adresse kennt das Rudel ihn nicht (${imHaus.code})`);
/* Dass seine Anmeldung ueberhaupt gilt -- sonst waeren beide Zeilen
oben nur deshalb gruen, weil der Keks nichts wert ist. */
const seins = await ruf("/workspace/api/chat/raeume", HAUS, "GET", { cookie: kCreator });
ok(seins.code === 200, `sein Zugang funktioniert dort sehr wohl (${seins.code})`);
}
/* =======================================================================
4. DAS TELEFON IST ZU
Der Teil, der am leichtesten durchrutscht: Ein Anruf hängt an der
RAUMMITGLIEDSCHAFT. Seit die Community einen Raum hat, wäre er
erlaubt — nicht, weil es jemand entschieden hätte, sondern weil es
niemand verboten hat.
======================================================================= */
melde("");
melde("=== 4. Keine Anrufe ===");
{
ok(T.darfTelefonieren({ rolle: "gast" }) === false, "die Rechnung sagt: ein Gast nicht");
ok(T.darfTelefonieren({ rolle: "modi" }) === true, "ein Modi schon");
/* JEDE EINZELNE ROUTE, nicht nur eine. Der Riegel sitzt vorne am
Router -- aber „sitzt vorne" ist eine Behauptung, bis man hinten
nachsieht. */
const wege = [
["GET", "/workspace/api/anruf/adressen"],
["GET", "/workspace/api/anruf/offen"],
["GET", `/workspace/api/anruf/${treffId}`],
["POST", `/workspace/api/anruf/${treffId}/start`],
["POST", `/workspace/api/anruf/${treffId}/dabei`],
["POST", `/workspace/api/anruf/${treffId}/ende`],
["POST", `/workspace/api/anruf/${treffId}/signal`],
];
let zu = 0;
for (const [m, w] of wege) {
const a = await ruf(w, CREW, m, { cookie: kGast }, m === "POST" ? {} : null);
if (a.code === 404) zu++;
else console.log(` offen geblieben: ${m} ${w} -> ${a.code}`);
}
ok(zu === wege.length, `alle ${wege.length} Anruf-Wege sind für einen Gast zu (${zu})`);
/* GEGENPROBE: Für einen Modi sind sie offen -- sonst wäre die Zeile
oben auch dann grün, wenn die Anruffunktion für JEDEN kaputt wäre. */
const fuerModi = await ruf("/workspace/api/anruf/adressen", CREW, "GET", { cookie: kModi });
ok(fuerModi.code === 200, `für einen Modi ist derselbe Weg offen (${fuerModi.code})`);
/* UND DIE SEITE ZEIGT IHM KEINEN KNOPF. Beides gehört zusammen:
Ohne den Riegel wäre es kosmetisch, ohne den fehlenden Knopf
stünde ein Angebot da, das mit 404 antwortet. */
const beiGast = json(await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kGast }));
ok(beiGast.darf_anrufen === false, "und die Seite bekommt gesagt, dass es nichts zu zeigen gibt");
const beiModi = json(await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kModi }));
ok(beiModi.darf_anrufen === true, "einem Modi dagegen schon");
}
/* =======================================================================
5. DIE RECHTETAFEL
======================================================================= */
melde("");
melde("=== 5. Wer die Seite öffnen darf ===");
{
const erlaubt = rechte.tafelJetzt()["/workspace/chat.html"] || [];
ok(erlaubt.includes("gast"), "die Community darf die Chatseite öffnen");
ok(erlaubt.includes("modi") && erlaubt.includes("admin"),
"Modi und Leitung ebenfalls");
/* GEGENPROBE: anruf-probe.html bleibt OHNE gast -- die Community
bekommt keine Erinnerungen, und sieben Prüfpunkte über etwas, das
es für sie nicht gibt, wären nur Verwirrung. */
ok(!(rechte.tafelJetzt()["/workspace/anruf-probe.html"] || []).includes("gast"),
"die Anruf-Probe dagegen nicht — sie telefoniert ja nicht");
}
/* =======================================================================
6. DIE KACHEL
======================================================================= */
melde("");
melde("=== 6. Die Kachel ===");
{
const ich = json(await ruf("/workspace/api/ich", CREW, "GET", { cookie: kGast }));
const alle = [...(ich.bereiche || []), ...(ich.bereiche_zusatz || [])];
/* NACH DER SEITE SUCHEN, NICHT NACH DER GANZEN ADRESSE (20.09.2026).
Hier stand `x.ziel === "chat.html"`. Seit dem 19.09. traegt die
Kachel `chat.html?raum=treff` -- sie fuehrt direkt in den Raum,
statt in einer Liste zu landen. Die Pruefung fand sie danach gar
nicht mehr und meldete acht Fehler ueber „undefined": kein Befund
ueber das Haus, sondern einer ueber sich selbst. */
const k = alle.find((x) => String(x.ziel).split("?")[0] === "chat.html");
ok(!!k, "die Community hat eine Chat-Kachel");
/* NICHT MEHR AUF DEN WORTLAUT „Chat" FESTGENAGELT. Ein Modi hat
ZWEI Kacheln, die in den Chat fuehren -- die des Teams und die
des Treffs. Hiessen beide „Chat", waere das die Falle, die am
19.09. behoben wurde. Geprueft wird deshalb, worauf es ankommt:
Man erkennt den Chat, und der Name ist eindeutig. */
ok(/chat/i.test(k?.name || ""), `sie ist als Chat erkennbar: „${k?.name}“`);
ok(alle.filter((x) => x.name === k?.name).length === 1,
`und ihr Name kommt genau einmal vor („${k?.name}“)`);
ok(/nacht/i.test(k?.unter || ""),
`und sagt schon auf der Startseite, dass er nachts schläft: „${k?.unter}“`);
const css = (await import("node:fs")).readFileSync(
new URL("../workspace/assets/css/start.css", import.meta.url), "utf8");
ok(new RegExp(`\\[data-ton="${k?.ton}"\\]`).test(css),
`ihr Farbton ${k?.ton} ist in start.css definiert`);
/* Keine zwei Kacheln mit derselben Farbe -- Filipe mehrfach:
„keine die sich irgendwie ähnlich sind". */
const toene = alle.map((x) => x.ton).filter(Boolean);
ok(new Set(toene).size === toene.length,
`kein Farbton doppelt bei den ${toene.length} Kacheln der Community`);
}
/* =======================================================================
6b. IM BROWSER
Der Teil, den keine Schnittstellenpruefung ersetzt: Sieht ein Gast
wirklich einen Raum und keinen Telefonknopf -- und steht das Band
ueber dem Eingabefeld, wo man es liest, bevor man tippt?
======================================================================= */
melde("");
melde("=== 6b. Im Browser ===");
{
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
const kontext = await browser.newContext({ viewport: { width: 412, height: 915 } });
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); });
seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
/* WELCHE Adresse 404 sagt, nicht nur DASS eine es tut. "Failed to
load resource" ohne Adresse ist eine Meldung, mit der man nichts
anfangen kann -- und dann sucht man an der falschen Stelle. */
const vergeblich = [];
seite.on("response", (r) => {
if (r.status() >= 400 && !/favicon/.test(r.url())) {
vergeblich.push(`${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, "")}`);
}
});
await kontext.addCookies(kGast.split("; ").filter(Boolean).map((teil) => {
const i = teil.indexOf("=");
return { name: teil.slice(0, i), value: teil.slice(i + 1), domain: "127.0.0.1", path: "/" };
}));
await seite.goto(`http://127.0.0.1:${PORT}/workspace/chat.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1100);
const stand = await seite.evaluate(() => ({
raeume: document.querySelectorAll(".chat__raum, .raum, [data-raum]").length,
text: document.body.innerText,
anrufKnopf: !!document.getElementById("anruf-ton-start")
|| !!document.getElementById("anruf-video-start"),
breiter: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
}));
/* s.o.: Der Ausdruck hat die Umbenennung nicht mitbekommen und
haette nach "Treff" gesucht, waehrend die Seite "Rudel" sagt. */
ok(/Rudel/i.test(stand.text), "die Seite nennt das Rudel");
/* KEIN TELEFONKNOPF. Der Riegel im Server ist das eine -- ein Knopf,
der mit 404 antwortet, waere trotzdem ein Knopf, den man drueckt. */
ok(stand.anrufKnopf === false, "und es gibt keinen Anrufknopf");
ok(!stand.breiter, "auf 412 px steht nichts ueber dem rechten Rand");
/* DAS RAUM OEFFNEN und nachsehen, was ueber dem Eingabefeld steht. */
const raum = seite.locator(".chat__liste button, .chat__raum, [data-raum-id]").first();
if (await raum.count()) { await raum.click(); await seite.waitForTimeout(700); }
const unten = await seite.evaluate(() => {
const band = document.getElementById("nachtruhe");
const feld = document.getElementById("text");
return {
bandDa: !!band && !band.hidden,
bandText: band ? band.innerText.trim() : "",
lage: band ? (band.dataset.lage || "") : "",
feldAus: !!feld && feld.disabled,
klammerAus: !!document.getElementById("anhang-auf")
&& document.getElementById("anhang-auf").disabled,
platzhalter: feld ? feld.placeholder : "",
};
});
if (NACHTLAUF) {
ok(unten.bandDa, "nachts steht das Band ueber dem Eingabefeld");
ok(/schläft/i.test(unten.bandText),
`und sagt, was los ist: „${unten.bandText.slice(0, 60)}…“`);
ok(unten.lage === "zu", `es traegt die Lage „zu“ (${unten.lage})`);
ok(unten.feldAus, "das Eingabefeld ist aus");
/* UND DIE BUEROKLAMMER. Der Server lehnt Anhaenge nachts ebenso ab;
ein offener Knopf hiesse: Bild aussuchen, hochladen, Absage --
die Arbeit ist dann schon gemacht. */
ok(unten.klammerAus, "die Büroklammer ebenfalls");
ok(/ab \d\d:00/i.test(unten.platzhalter),
`und der Platzhalter sagt, ab wann wieder: „${unten.platzhalter}“`);
} else {
/* GEGENPROBE: Bei Tag ist das Band weg und das Feld an. Ohne diese
Zeile waere "das Band steht da" auch dann gruen, wenn es IMMER
dasteht -- und ein Band, das immer da ist, liest niemand. */
ok(!unten.bandDa, "bei Tag steht kein Band da");
ok(!unten.feldAus, "und das Eingabefeld ist an");
ok(!unten.klammerAus, "die Büroklammer auch");
}
if (process.env.SCHIRM) {
await seite.screenshot({
path: `C:/Users/qciga/Documents/Obelix/DogiHompage/treffchat-${NACHTLAUF ? "nacht" : "tag"}.png`,
fullPage: true,
});
}
/* KEINE VERGEBLICHE ANFRAGE. Ein 404 bei jedem Seitenaufruf ist
Rauschen, und Rauschen macht den naechsten ECHTEN Fehler
unsichtbar -- dieselbe Regel wie bei der Warnung, die immer kommt. */
ok(vergeblich.length === 0,
vergeblich.length
? `vergebliche Anfragen: ${[...new Set(vergeblich)].join(" | ")}`
: "keine einzige Anfrage laeuft ins Leere");
ok(konsole.length === 0,
konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 110) : "keine Fehler im Browser");
await browser.close();
}
/* =======================================================================
7. UND JETZT DASSELBE MIT GELTENDER NACHTRUHE
Derselbe Code, andere Lage. Ohne diesen zweiten Lauf waere die
Nachtruhe nur behauptet: Alles oben ist auch dann gruen, wenn die
Sperre gar nicht existiert.
======================================================================= */
if (!NACHTLAUF) {
melde("");
melde("=== 7. Derselbe Lauf, aber mit geltender Nachtruhe ===");
const { spawnSync } = await import("node:child_process");
const lauf = spawnSync(process.execPath, [new URL(import.meta.url).pathname.slice(1)], {
encoding: "utf8",
env: { ...process.env, TREFF_PRUEF_NACHT: "ja", WORKSPACE_DB: "", PORT: "" },
});
const zeilen = String(lauf.stdout || "").split(String.fromCharCode(10));
for (const z of zeilen) {
if (/^\s{2}(ok|FEHL)\s/.test(z)) {
geprueft++;
if (z.includes("FEHL")) fehler++;
console.log(z.replace(/^\s{2}/, " "));
}
}
if (lauf.status !== 0 && !zeilen.some((z) => /FEHL/.test(z))) {
/* DER DRITTE AUSGANG: Der zweite Lauf ist gar nicht gelaufen.
Das ist etwas anderes als "er hat Fehler gefunden" -- und es
darf nicht wie ein bestandener Lauf aussehen. */
ok(false, `der Nachtlauf konnte nicht starten (Rueckgabewert ${lauf.status}). `
+ String(lauf.stderr || "").split(String.fromCharCode(10))[0]);
}
}
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
process.exit(fehler ? 1 : 0);