Files
dogfather-universe/server/pruef-treffchat.mjs
T
DogFatherGitandClaude Opus 5 7be85c265b Der Treff bekommt einen Chat -- mit Nachtruhe, ohne Telefon
Filipe, auf die Frage ob die Community einen Chat bekommen soll:
"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
privatsphaere beruecksichtig wird ueber nacht ... und die sollen nicht
anrufen koennen. nur schreiben."

Zur Auswahl standen "gar kein Chat", "nur mit Oeffnungszeiten" und
"offen wie im Team". Seine Antwort ist eine vierte, die nicht auf dem
Zettel stand, und sie ist die bessere.

1. WARUM DIE NACHTRUHE MEHR IST ALS EINE EINSTELLUNG

Sein Grund war Privatsphaere -- und zwar die der Leute, die moderieren.
Die Forschung zu ehrenamtlichen Moderatoren sagt genau das: Die beiden
meistgenannten Gruende fuers Aufhoeren sind ZU WENIG ZEIT und STREIT IM
TEAM. Ein Chat, der nachts um drei weiterlaeuft, erzeugt beides.

SIE GILT FUER ALLE, AUCH FUER DIE LEITUNG. Das ist die unbequeme
Entscheidung: Der bequeme Weg waere, DogFather auszunehmen. Aber wenn er
um halb vier schreibt, liest es jemand -- und wer liest, fuehlt sich
zustaendig. Eine Nachtruhe, von der die Leitung ausgenommen ist, ist
keine, sondern eine Bitte.

MODERIEREN GEHT WEITER. Verbergen, loeschen, herausnehmen sind keine
Nachrichten. Wer nachts etwas Schlimmes sieht, kann es wegnehmen -- er
kann nur nicht darueber diskutieren. Und der vertrauliche Meldeweg
bleibt offen: Er ist ein Fall mit einer Zustaendigkeit, kein Raum mit
dreissig Leuten.

GERECHNET IN ORTSZEIT, nicht in UTC. `getHours()` auf einem UTC-Server
haette den Chat im Sommer von 2 bis 8 geschlossen -- zwei Stunden
daneben, und nur denen sichtbar, die um diese Zeit wach sind. Dieses
Haus ist mit der Rechnung schon zweimal hereingefallen.

DER RIEGEL SITZT IM SERVER, nicht im Browser. Ein ausgegrautes Feld ist
eine Hoeflichkeit; wer die Adresse kennt, spricht die Schnittstelle
direkt an. Nachrichten UND Anhaenge werden abgelehnt -- ein offener
Anhangweg waere der Umweg, den jeder findet, der es einmal versucht.

2. KEIN TELEFON -- UND WARUM DAS EINEN EIGENEN RIEGEL BRAUCHT

Ein Anruf haengt in diesem Haus an der RAUMMITGLIEDSCHAFT, nicht an der
Rolle. Solange die Community keinen Raum hatte, war die Frage muessig.
Seit sie einen hat, waere Telefonieren erlaubt -- nicht, weil es jemand
entschieden haette, sondern weil es niemand verboten hat.

Der Riegel steht ganz vorne am Anruf-Router, nicht in den neun Routen
dahinter. Alle sieben Wege sind geprueft, einzeln.

3. DER FUND, DER DIE GANZE NACHTRUHE UMGANGEN HAETTE

Ein Gast konnte ein PRIVATES ZWEIER-GESPRAECH mit DogFather aufmachen.
Die Route antwortete mit 200 und legte den Raum an.

`ohneAussen` nimmt die Community aus den Listen ANDERER heraus -- damit
kein Creator einen Zuschauer anschreibt. Die Gegenrichtung kam nie vor,
weil die Chatseite fuer 'gast' gar nicht offen war. Seit heute ist sie
offen, und damit war die Erlaubnis da.

Ein Zweier-Gespraech ist kein Kanal: keine Nachtruhe, kein Modi liest
mit, niemand koennte moderieren. Aus "die Community bekommt einen Chat"
waere ungewollt "jeder Zuschauer bekommt eine Standleitung zu
DogFather" geworden.

GEFUNDEN HAT ES DIE PRUEFUNG, NICHT DAS LESEN -- und zwar in dem Moment,
in dem ich zwei Dateien weiter selbst vor genau dieser Sorte Erlaubnis
gewarnt hatte.

4. ZWEI TOTE WEGE, EINER DAVON MEINER

pruef-community-sicht sucht nach sichtbaren Wegen, die ins Leere
fuehren. Sie fand zwei:

  a) "Bei dir klingelt nichts" -> anruf-probe.html. Der Hinweis von
     heute Vormittag, und die Seite steht einem Gast ausdruecklich NICHT
     offen. Mein Fehler, und einer, der jedem naechsten Hinweis genauso
     passiert waere.

     AN DER WURZEL BEHOBEN: Die Hinweise pruefen jetzt auch die ROLLE
     (`darfSeite`), nicht nur die Adresse. Das ist der 15.09. noch
     einmal, eine Ebene tiefer -- damals die Adresse, diesmal die Rolle,
     und dieselbe Lehre: Die Hinweise sind eine DRITTE Stelle neben
     Seiten und Schnittstellen.

  b) Der Verweis "Kalender" im Tagesblick. Steht fest im HTML, fuehrt
     fuer die Community ins Leere -- und zwar SCHON VOR den Aenderungen
     dieses Tages. Aufgefallen nur, weil (a) daneben stand. Der Weg
     wird jetzt abgeleitet: Wer die Kachel hat, hat den Weg.

5. DIE PRUEFUNG LAEUFT ZWEIMAL

Die Nachtruhe laesst sich nicht in EINEM Lauf in beiden Zustaenden
pruefen -- die Zeiten werden beim Laden gelesen, und ein zweiter Server
braeuchte denselben Port. pruef-treffchat ruft sich deshalb selbst noch
einmal auf, mit einem Fenster, das jetzt gilt. Dieselben Pruefungen,
andere Lage.

DIE UHR WIRD NICHT GEFAELSCHT. Verschoben wird die GRENZE, nicht die
Zeit -- eine Pruefung, die an der Systemzeit dreht, faelscht danach auch
anderes mit und ist nur nachts gruen.

6. WAS SICH NEBENBEI GEAENDERT HAT

- pruef-treff war SCHON VORHER ROT (8 Fehler): Sie erwartete sieben
  Kacheln fuer die Community, es waren gestern Nacht schon zehn. Feste
  Zahlen, Rechnung von gestern. Jetzt abgeleitet -- 72 Pruefungen statt
  66, alle gruen.
- Ein 404 bei jedem Seitenaufruf der Community: `anruf.js` holte die
  Verbindungsadressen, bevor es wusste, wer fragt. Der Browser
  protokolliert das, BEVOR JavaScript abfangen kann -- also darf die
  Anfrage gar nicht erst gestellt werden. `/api/ich` sagt es jetzt
  vorher. Ein Fehler, der immer kommt, macht den naechsten echten
  unsichtbar.
- Reaktionen (Daumen) bleiben nachts erlaubt. Ausdruecklich, mit
  Begruendung im Code: Ein Daumen ist keine Nachricht, er loest keine
  Meldung aus und kann keinen Streit anfangen. Eine Luecke ohne
  Begruendung wird beim naechsten Durchsehen entweder geschlossen oder
  uebersehen -- beides falsch.

GEMESSEN: pruef-treffchat 108/0 (neu, laeuft zweimal), pruef-treff 72/0
(vorher 66 mit 8 Fehlern), pruef-chat gruen, pruef-rechtetafel 19/0,
pruef-community-sicht gruen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 11:47:31 +02:00

626 lines
31 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 { portMussFreiSein } 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";
const PORT = await portMussFreiSein(NACHTLAUF ? 4424 : 4423,
NACHTLAUF ? "pruef-treffchat (Nachtlauf)" : "pruef-treffchat");
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 der Treff ===");
{
/* 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",
`den Treff (${treff?.art}/${treff?.kategorie})`);
ok(treff?.name === "Der Treff", `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 Treff-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, `der Treff 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 der Treff 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 || [])];
const k = alle.find((x) => x.ziel === "chat.html");
ok(!!k, "die Community hat eine Chat-Kachel");
ok(k?.name === "Chat", `sie heißt „${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,
}));
ok(/Treff/i.test(stand.text), "die Seite nennt den Treff");
/* 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);