Gemessen: 165 Pruefdateien, 129 verschiedene Nummern -- NEUNZEHN
doppelt, vier davon dreifach. Niemand hatte das gewollt; jede neue
Pruefung wurde von einer vorhandenen abgeschrieben, und die Nummer kam
mit. Meine eigene Notiz sagte "sieben" -- auch eine Bestandsliste
altert.
Der Waechter faengt den Schaden ab, aber er kann nur melden, was schon
passiert ist: Zwei Pruefungen mit derselben Nummer koennen nie
gleichzeitig laufen, und ein liegengebliebener Prozess der einen laesst
die andere abbrechen mit einer Meldung, die wie ein Befund aussieht.
Genau das ist mir am 20.09. zweimal passiert.
Jetzt leitet jede Datei ihre Nummer aus ihrer STELLE IM ALPHABET ab
(eigenerPort in helfer-port.mjs), zwei je Datei. Nicht ueber eine
Pruefsumme: Bei 165 Namen in 4900 Nummern waeren nach dem
Geburtstagsproblem rund DREI Zusammenstoesse zu erwarten -- ein Hash
tauscht eine sichtbare Doppelung gegen eine unsichtbare. Die Stelle im
Alphabet ist eindeutig von der Bauart her.
--- ZWEI FEHLER AUF DEM WEG, BEIDE LEHRREICH -------------------------
1. DER ERSTE VERSUCH WAR GRUEN UND KAPUTT. Ersetzt wurde mit einem
Muster: "([^"]*4231[^"]*)". Das hielt
{ host: "127.0.0.1", port: 4231, path: "/404.html" }
fuer eine Zeichenkette -- ein Muster kann eine oeffnende nicht von
einer schliessenden Anfuehrung unterscheiden. Heraus kam
{ host: "127.0.0.1`, port: ${PORT}, path: `/404.html" }
also GUELTIGER Code ohne port-Feld. `node --check` sagte gruen fuer
alle 164 Dateien. Aufgefallen ist es erst, weil ich vier Vertreter
gegen eine vorher gemessene Grundlinie laufen liess: pruef-schranke
39/0 vorher, 38/1 nachher.
Alles zurueckgenommen und mit einem Zerleger neu gemacht, der weiss,
ob eine Stelle Code, Zeichenkette, Vorlage, Kommentar oder
regulaerer Ausdruck ist. In pruef-ics stand die Nummer in einem
regulaeren Ausdruck -- der wird jetzt gebaut statt hingeschrieben.
2. EIN MODUL, DAS BEIM IMPORTIEREN ARBEITET, IST EINE FALLE. Der
zweite Durchgang importierte den ersten, um seine Mechanik zu
benutzen -- und fuehrte dessen Hauptlauf gleich mit aus. Die
zweiten Nummern wurden dadurch als erste behandelt, zwei Aufrufe
bekamen dieselbe Nummer, und in pruef-content stand `const PORT`
zweimal.
--- WAS DAS DAUERHAFT HAELT -----------------------------------------
pruef-portnummern.mjs (neu, 8 Pruefungen) fragt nicht "welche Nummern
sind doppelt", sondern "wer traegt ueberhaupt noch eine von Hand ein"
und "wer startet einen Server, ohne seine Nummer abzuleiten". Die
zweite Frage hat sofort etwas gefunden, das in KEINER Doppelungsliste
stand: pruef-push-weg belegte 4341 und 4342, rief den Waechter aber
gar nicht auf -- dieselbe Nummer wie pruef-agentur. Eine Liste zeigt
nur, was auf ihr steht.
Mit Gegenprobe: Eine unbekannte Datei bekommt keine geratene Nummer,
sondern einen Abbruch, und eine dritte Nummer je Datei gibt es nicht.
--- NACHGEMESSEN ----------------------------------------------------
Sechzehn Pruefungen gegen ihre vorher gemessene Grundlinie, je eine
Vertreterin jeder umgestellten Bauweise (eine Nummer, zwei Nummern,
Nummer in einer Zeichenkette, in einer Vorlage, in einem regulaeren
Ausdruck, dynamische Einfuhr, Nachtlauf mit zwei Laeufen):
crew-adresse 132/0 · schranke 39/0 · content 45/0 · entwicklung 46/0
anruf 127/0 · ics 37/0 · push-weg 20/0 · arten 28/0 · video 67/0
kanalzeile 14/0 · agentur 62/0 · spicy 83/0 · creator-anlegen 50/0
push-ziel 10/0 · portnummern 8/0
Alle exakt wie vorher. Zwei waren schon vorher rot und sind es
unveraendert geblieben (crew-wand-bild 41/4, teilen 14/3) -- per
`git stash` belegt, nicht angenommen.
Co-Authored-By: Claude Opus 5 <[email protected]>
645 lines
32 KiB
JavaScript
645 lines
32 KiB
JavaScript
/* =====================================================================
|
||
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 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 || [])];
|
||
/* 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,
|
||
}));
|
||
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);
|