Die Grundlinie der Fingerwache sinkt von 17 auf 6.
WAS UMGESTELLT WURDE
pruef-alter, pruef-countdown, pruef-gespraech, pruef-treffchat,
pruef-modi-verborgen, pruef-nachwuchs, pruef-personen-liste,
pruef-serien, pruef-team, pruef-kalender.
Alle zehn legen ein eigenes Handyfenster an; dort fehlte nur
`hasTouch`. Ohne ihn meldet der Browser einen feinen Zeiger, und
keine Regel aus `@media (pointer: coarse)` greift -- dort stehen die
44-Pixel-Beruehrziele.
JEDE EINZELN GELAUFEN, ALLE GRUEN
Diese Seiten halten die Fingerregeln also schon ein. Es hatte nur
nie jemand nachgesehen.
Vorher geprueft, wie gross jede ist: 1 bis 7 Seitenaufrufe, 1 bis 5
Fenster -- echte Einzelpruefungen. (Zum Vergleich: die Pruefung von
gestern Nacht hatte 244 Seitenaufrufe, und genau das hatte ich
vorher nicht nachgesehen.)
DIE ZEITBOMBE IN pruef-kalender
Beim Umstellen fiel sie rot aus -- zweimal, an einer Stelle, die mit
dem Finger nichts zu tun hat:
FEHL im naechsten Monat ist kein Tag mehr "heute"
Gegen den ausgelieferten Stand gemessen: identisch rot. Nicht
meine Aenderung, sondern der Kalender.
Heute ist der 29. September. Das Oktober-Raster zeigt in seiner
ersten Zeile die letzten Septembertage mit, und einer davon IST
heute. Die Anwendung macht dabei alles richtig: `kalender.js` setzt
die Marke an `tag === daten.heute`, also am DATUM und nicht an der
Rasterposition. Genau das sollte die Pruefung beweisen -- und schlug
an, weil die Anwendung es tat.
Geschrieben wurde sie an einem Tag in der Monatsmitte. Von da an war
sie richtig, bis der Kalender sie einholte. Dasselbe Muster wie
`gate-oeffnung.mjs` am 06.09.2026 im Shop: Ein Test, der die
Wanduhr als Annahme benutzt, misst irgendwann das Gegenteil.
Gefragt wird jetzt, was gemeint war: Ein EIGENER Tag des
Folgemonats darf nie „heute" sein; ein mitgezeigter Randtag
(`data-fremd="ja"`) dagegen sehr wohl, und zwar genau dann, wenn er
es ist. Das gilt an jedem Tag des Jahres. Die Meldung nennt jetzt
beide Zahlen:
ok im naechsten Monat ist kein EIGENER Tag mehr "heute"
(0 eigene, 1 mitgezeigte Randtage)
WAS UEBRIG BLEIBT: SECHS SCHWERE FAELLE
pruef-befinden, pruef-notizen, pruef-teamlage-karten,
pruef-terminregel, pruef-werdegang, pruef-grosscheck.
Dort schaltet `setViewportSize` mitten im Ablauf auf Handyformat um
-- und der Finger laesst sich nachtraeglich nicht setzen, er gehoert
zum Kontext. Jeder Fall braucht einen eigenen Kontext samt
Anmeldung: Umbau, nicht Einzeiler.
`pruef-grosscheck` bleibt unangetastet, bis Filipe einen Lauf
freigibt. Eine Aenderung, die ich nicht pruefen darf, liefere ich
nicht aus.
GEPRUEFT
alle zehn einzeln: EXIT 0, keine FEHL-Zeile
pruef-kalender EXIT 0 (vorher 2 Fehlschlaege, auch auf HEAD)
pruef-fingermass 3 / 0, Grundlinie 6
651 lines
32 KiB
JavaScript
651 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 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();
|
||
/* MIT FINGER. Ohne `hasTouch` meldet der Browser einen feinen
|
||
Zeiger, und keine Regel aus `@media (pointer: coarse)` greift --
|
||
dort stehen die 44-Pixel-Beruehrziele. Siehe pruef-fingermass.mjs. */
|
||
const kontext = await browser.newContext({ viewport: { width: 412, height: 915 },
|
||
hasTouch: true, isMobile: true });
|
||
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);
|