Files
dogfather-universe/server/pruef-chat.mjs
T
DogFatherGitandClaude Opus 5 b625d901a5 Jeder darf seine eigene Chat-Nachricht bearbeiten
Filipe: "die nachrichten die man in den chat reinschreibt. jeder soll
seine eigene nachricht bearbeiten koennen. diese option soll jeder
fuer seine eigene nachrichten haben die er selber verfasst hat."

NUR DIE EIGENE -- OHNE AUSNAHME, auch nicht fuer DogFather und die
rechte Hand. Beim LOESCHEN gibt es diese Ausnahme seit dem 22.09.
(fuer den Notfall), und es waere naheliegend gewesen, sie
mitzunehmen. Das waere falsch: Eine fremde Nachricht zu entfernen
heisst "das soll hier nicht stehen". Eine fremde zu AENDERN heisst,
jemandem Worte in den Mund zu legen, die unter seinem Namen und
seinem Bild stehen bleiben. Nicht dieselbe Befugnis in groesser,
sondern eine andere. Genau das ist die wichtigste Pruefzeile:
DogFather darf loeschen und bekommt beim Bearbeiten 403/404.

AN DER NACHRICHT STEHT "BEARBEITET". Ein Text, der sich still
aendert, nachdem andere darauf geantwortet haben, ist ein
Vertrauensproblem und kein Komfort. Der Vermerk traegt die Zeit im
Titel. Derselbe Text setzt ihn NICHT -- sonst stuende er irgendwann
ueberall und waere nichts mehr wert.

ERWAEHNUNGEN BLEIBEN, WIE SIE BEIM SENDEN WAREN. Wer beim Bearbeiten
"@Anna" ergaenzt, spricht Anna damit nicht an. Sonst gaebe es nur
schlechte Wege: nachtraeglich benachrichtigen laesst sich beliebig
oft wiederholen, und still eintragen setzt jemanden auf eine Liste,
von der er nie erfaehrt. Ansprechen tut man mit einer neuen
Nachricht. (Falls das anders gewuenscht ist, ist es eine eigene
Entscheidung -- nicht etwas, das hier nebenbei mitpassiert.)

DER RAUM RUECKT NICHT NACH OBEN und niemand bekommt die Nachricht
als ungelesen: Eine Tippfehlerkorrektur ist keine Wortmeldung.
`letzte_am` wird deshalb nicht angefasst.

Leer geht nicht -- dafuer steht "loeschen" daneben, mit Rueckfrage.
Grenzen (4000 Zeichen) sind dieselben wie beim Senden; eine zweite
Rechnung waere die, die auseinanderlaeuft.

Das Feld sitzt AN der Nachricht, nicht im Schreibfeld unten: Wer
seinen Text zum Bearbeiten unten wiederfindet, schickt ihn beim
naechsten Enter als NEUE Nachricht ab und hat ihn zweimal im Raum.
Enter speichert, Shift+Enter macht eine Zeile, Escape bricht ab --
dieselben Tasten wie beim Schreiben. 16 px Schrift, sonst zoomt iOS
beim Hineintippen die ganze Seite heran.

Der Stift traegt sich in die Familie der Handgriffe ein, wie es der
Hinweis in chat.css ausdruecklich verlangt ("wer einen sechsten
Handgriff baut, traegt ihn hier ein und bekommt sein Zeichen").

EIN FEHLER, DEN NUR DAS BILD GEZEIGT HAT. Ich hatte im Code
behauptet, die Gespraechsliste aendere sich beim Bearbeiten nicht,
und darum auf das Nachladen verzichtet. Auf dem Bildschirmfoto stand
rechts "Treffen um 15 Uhr" und links in der Liste weiter "Du:
Treffen um 15 Urh" -- derselbe Satz, zweimal verschieden, auf einem
Schirm. Richtig ist: Die REIHENFOLGE aendert sich nicht, die
VORSCHAU sehr wohl. Beide Haelften waren fuer sich gemessen und
gruen; keine Zahl hat es gemerkt.

pruef-chat 17 neue Pruefungen: eigene geht, fremde nicht, DogFather
nicht, Vermerk kommt mit nach draussen (auch im SELECT -- genau das
hat am 03.10. bei den Anhaengen einen halben Tag gekostet), Raum
rueckt nicht, Vorschau zieht nach, leer/zu lang abgelehnt,
unveraendert ohne Vermerk, geloeschte nicht bearbeitbar, wer nicht
im Raum ist bekommt 404 statt 403.

pruef-chat-optik 71 -> 84: im echten Browser, mit zwei Sitzungen.
Darunter die Zeile, auf die es ankommt -- der neue Text steht bei
Patrick, OHNE Neuladen. Ein Bearbeiten, das nur der Schreibende
sieht, waere schlimmer als keins.

Dabei zwei eigene Messfehler behoben: Die Sitzungen von oben waren
laengst geschlossen (Playwright meldet nur "Target page has been
closed"), und beide klickten "das oberste Gespraech" statt
denselben Raum -- wodurch die Pruefung "an ihr steht KEIN
bearbeiten" gruen war, weil die Nachricht gar nicht da war. Sie
haengt jetzt daran, dass er sie wirklich sieht.

Datenbank vorher gesichert und zurueckgelesen (integrity_check,
324 Nachrichten, 20 Personen).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-04 00:06:29 +02:00

638 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.
/* CHAT — wer darf mit wem, und wer bekommt nichts zu sehen.
Wunsch Filipe, 06.09.2026. Auf Nachfrage entschieden: Zweier-
Gespräche UND Gruppen; Manager und Scouts dürfen sich auch ohne
gemeinsamen Creator schreiben.
WARUM DIESE PRÜFUNG ZUERST KAM, vor der Oberfläche: Ein Chat ist der
Ort, an dem Leute Dinge schreiben, die nicht jeder lesen soll. Ein
Fehler in der Sichtbarkeit ist hier teurer als überall sonst im
Haus -- eine falsche Zahl auf einer Kachel ärgert, ein mitgelesenes
Gespräch ist ein Vertrauensbruch.
GEMESSEN WIRD:
1. Die Regel selbst: Wer erscheint in wessen Partnerliste?
2. Kann man sie umgehen, indem man die Anfrage von Hand baut?
3. Kommt man an einen fremden Verlauf, wenn man die Raumnummer rät?
4. Ist eine Gruppe ein Schlupfloch zu jemandem, den man einzeln
nicht anschreiben dürfte?
5. Und die Alltagsfragen: Gibt es je Paar genau EIN Gespräch?
Zählt "ungelesen" richtig? Bleibt ein Zurücknehmen sichtbar?
GEGENPROBEN durchgehend: Jede Sperre wird auch von der erlaubten
Seite versucht. Eine Prüfung, die immer "verboten" sagt, beweist
nichts. */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-chat-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Chatpruefung");
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = `http://127.0.0.1:${PORT}`;
let fehler = 0;
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---- Ein Bestand mit ECHTER Struktur ------------------------------------
Zwei getrennte Ketten, damit "fremd" wirklich fremd ist:
Cigdem (Manager) → Patrick (Scout) → Luna (Creator)
Meral (Manager) → Sven (Scout) → Nora (Creator)
Dazu DogFather über allem.
Ohne die zweite Kette liesse sich nicht prüfen, ob ein Creator an
einen fremden Creator kommt -- es gäbe schlicht keinen. */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idCigdem = anlegen("Cigdem", "manager", "CODE-MANA-0001");
const idMeral = anlegen("Meral", "manager", "CODE-MANA-0002");
const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001");
const idSven = anlegen("Sven", "scout", "CODE-SCOU-0002");
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
const idNora = anlegen("Nora", "creator", "CODE-CREA-0002");
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idNora, idSven, jetzt);
d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idPat, idCigdem, jetzt);
d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idSven, idMeral, jetzt);
async function anmelden(rolle, code) {
const a = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code }) });
const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
return { ok: a.ok, keks };
}
const rufe = (keks, weg, koerper, methode = "GET") =>
fetch(BASIS + weg, {
method: methode,
headers: { "Content-Type": "application/json", Cookie: keks },
body: koerper === undefined ? undefined : JSON.stringify(koerper),
});
const holen = async (keks, weg) => (await rufe(keks, weg)).json();
const dogi = await anmelden("admin", "CODE-DOGI-0001");
const cigdem = await anmelden("manager", "CODE-MANA-0001");
const meral = await anmelden("manager", "CODE-MANA-0002");
const pat = await anmelden("scout", "CODE-SCOU-0001");
const luna = await anmelden("creator", "CODE-CREA-0001");
const nora = await anmelden("creator", "CODE-CREA-0002");
ok([dogi, cigdem, meral, pat, luna, nora].every((x) => x.ok && x.keks),
"alle sechs Rollen sind angemeldet");
/* =======================================================================
1. Die Regel: wer erscheint in wessen Liste?
======================================================================= */
console.log("\n=== Mit wem kann man schreiben? ===");
{
const namen = async (keks) => {
const { personen } = await holen(keks, "/workspace/api/chat/partner");
return (personen || []).map((p) => p.name).sort();
};
const beiLuna = await namen(luna.keks);
/* Ein Creator: sein Scout, dessen Manager, DogFather. Kein fremder
Creator, kein fremder Scout. */
ok(JSON.stringify(beiLuna) === JSON.stringify(["Cigdem", "Filipe", "Patrick"]),
`Creator Luna: ${beiLuna.join(", ")}`);
ok(!beiLuna.includes("Nora"),
"Luna sieht die andere Creatorin NICHT — Creator bleiben getrennt");
ok(!beiLuna.includes("Sven") && !beiLuna.includes("Meral"),
"und auch nicht den fremden Scout oder dessen Manager");
const beiPat = await namen(pat.keks);
/* Ein Scout: sein Creator, DogFather -- UND das ganze Team
(Entscheidung vom 06.09.2026: "für Absprachen im Team"). */
ok(beiPat.includes("Luna") && beiPat.includes("Filipe"),
`Scout Patrick erreicht seinen Creator und DogFather`);
ok(beiPat.includes("Meral") && beiPat.includes("Sven"),
`und das Team: ${beiPat.join(", ")}`);
ok(!beiPat.includes("Nora"),
"aber NICHT die fremde Creatorin — die Öffnung gilt nur fürs Team");
const beiDogi = await namen(dogi.keks);
ok(beiDogi.length === 6, `DogFather erreicht alle ${beiDogi.length} anderen`);
ok(!beiDogi.includes("Filipe"), "aber nicht sich selbst");
}
/* =======================================================================
2. Lässt sich die Regel von Hand umgehen?
======================================================================= */
console.log("\n=== Die Regel sitzt im Server, nicht in der Oberfläche ===");
{
/* Luna baut die Anfrage selbst und will Nora anschreiben. */
const a = await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idNora }, "POST");
ok(a.status === 403, `Luna → Nora von Hand: abgelehnt (${a.status})`);
const b = await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idSven }, "POST");
ok(b.status === 403, `Luna → fremder Scout: abgelehnt (${b.status})`);
/* GEGENPROBE: Zu ihrem eigenen Scout geht es. Ohne diesen Nachweis
bewiese "403" nur, dass die Route überhaupt ablehnt. */
const c = await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idPat }, "POST");
ok(c.ok, `Luna → ihr eigener Scout: geht (${c.status})`);
/* Und sich selbst anschreiben ergibt keinen Sinn. */
const e = await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idLuna }, "POST");
ok(e.status === 403, `sich selbst anschreiben: abgelehnt (${e.status})`);
}
/* =======================================================================
3. Ein fremder Verlauf, mit geratener Nummer
======================================================================= */
console.log("\n=== Kommt man an fremde Gespräche? ===");
let raumLunaPat = 0;
{
const { raum_id } = await (await rufe(luna.keks, "/workspace/api/chat/direkt",
{ mit: idPat }, "POST")).json();
raumLunaPat = raum_id;
ok(Number.isInteger(raumLunaPat) && raumLunaPat > 0, `Gespräch Luna↔Patrick ist #${raumLunaPat}`);
await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
{ text: "Vertraulich: mein Umsatz war 3200." }, "POST");
/* Nora rät die Nummer. */
const a = await rufe(nora.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`);
ok(a.status === 404, `Fremde liest Verlauf: ${a.status} (404, nicht 403 — die Nummer soll nichts verraten)`);
/* Und sie versucht hineinzuschreiben. */
const b = await rufe(nora.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
{ text: "Hallo?" }, "POST");
ok(b.status === 404, `Fremde schreibt hinein: ${b.status}`);
/* Auch als gelesen markieren geht nicht -- das wäre sonst ein Weg,
die Existenz eines Raums zu erfahren. */
const c = await rufe(nora.keks, `/workspace/api/chat/raeume/${raumLunaPat}/gelesen`,
{ bis: 999 }, "PUT");
ok(c.status === 404, `Fremde markiert als gelesen: ${c.status}`);
/* GEGENPROBE: Patrick, der drin ist, kommt heran. */
const e = await holen(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`);
ok(e.nachrichten?.length === 1 && /3200/.test(e.nachrichten[0].text),
`Patrick liest den Verlauf (${e.nachrichten?.length} Nachricht)`);
/* Selbst DogFather kommt nicht ungefragt in ein fremdes Gespräch.
Das ist Absicht: Ein Chat, in dem der Chef stillschweigend mitliest,
ist kein Chat. Er darf jederzeit selbst schreiben -- aber sichtbar. */
const f = await rufe(dogi.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`);
ok(f.status === 404,
`auch DogFather liest fremde Gespräche NICHT mit (${f.status}) — er ist kein Teilnehmer`);
}
/* =======================================================================
4. Genau EIN Gespräch je Paar
======================================================================= */
console.log("\n=== Ein Paar, ein Gespräch ===");
{
/* Von der anderen Seite geöffnet muss derselbe Raum kommen -- sonst
schreibt einer ins Leere, während der andere wartet. */
const { raum_id: vonPat } = await (await rufe(pat.keks, "/workspace/api/chat/direkt",
{ mit: idLuna }, "POST")).json();
ok(vonPat === raumLunaPat,
`Patrick öffnet dasselbe Gespräch (#${vonPat} = #${raumLunaPat}), keine zweite Kopie`);
/* Und mehrfaches Öffnen legt nichts Neues an. */
for (let i = 0; i < 3; i++) {
await rufe(luna.keks, "/workspace/api/chat/direkt", { mit: idPat }, "POST");
}
const p = new DatabaseSync(process.env.WORKSPACE_DB);
const n = p.prepare("SELECT COUNT(*) AS n FROM chat_raeume WHERE art = 'direkt'").get().n;
p.close();
ok(n === 1, `nach fünfmaligem Öffnen: ${n} Gespräch in der Datenbank`);
}
/* =======================================================================
5. Gruppen — und ob sie ein Schlupfloch sind
======================================================================= */
console.log("\n=== Gruppen ===");
{
/* DER PUNKT: Luna darf Nora nicht einzeln anschreiben. Kann sie es
über eine Gruppe? */
const a = await rufe(luna.keks, "/workspace/api/chat/gruppe",
{ name: "Umweg", mit: [idPat, idNora] }, "POST");
ok(a.status === 403,
`eine Gruppe ist KEIN Schlupfloch: ${a.status} (Nora war dabei)`);
/* GEGENPROBE: Ohne Nora geht dieselbe Gruppe. */
const b = await rufe(luna.keks, "/workspace/api/chat/gruppe",
{ name: "Luna, Patrick & Cigdem", mit: [idPat, idCigdem] }, "POST");
ok(b.status === 201, `mit erlaubten Leuten: angelegt (${b.status})`);
const { raum_id: gruppe } = await b.json();
const sicht = await holen(luna.keks, `/workspace/api/chat/raeume/${gruppe}/nachrichten`);
ok(sicht.raum?.teilnehmer?.length === 3,
`die Gruppe hat ${sicht.raum?.teilnehmer?.length} Mitglieder`);
ok(sicht.raum?.name === "Luna, Patrick & Cigdem",
`und ihren Namen: "${sicht.raum?.name}"`);
/* Wer sie angelegt hat, führt sie. */
const chefin = sicht.raum.teilnehmer.find((t) => t.leitung);
ok(chefin?.id === idLuna, `die Leitung liegt bei ${chefin?.name}`);
/* Ohne Namen keine Gruppe. */
const c = await rufe(luna.keks, "/workspace/api/chat/gruppe",
{ name: "", mit: [idPat] }, "POST");
ok(c.status === 400, `ohne Namen: abgelehnt (${c.status})`);
/* Und ohne Leute auch nicht. */
const e = await rufe(luna.keks, "/workspace/api/chat/gruppe",
{ name: "Allein", mit: [] }, "POST");
ok(e.status === 400, `ohne Mitglieder: abgelehnt (${e.status})`);
/* Nora sieht die Gruppe nicht in ihrer Liste. */
const { raeume } = await holen(nora.keks, "/workspace/api/chat/raeume");
ok(!(raeume || []).some((r) => r.id === gruppe),
`Nora sieht die fremde Gruppe nicht (${raeume?.length || 0} eigene Gespräche)`);
}
/* =======================================================================
6. Ungelesen — die Zahl, auf die man sich verlässt
======================================================================= */
console.log("\n=== Ungelesene Nachrichten ===");
{
const vorher = (await holen(pat.keks, "/workspace/api/chat/ungelesen")).ungelesen;
await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
{ text: "Erste" }, "POST");
await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
{ text: "Zweite" }, "POST");
const nachher = (await holen(pat.keks, "/workspace/api/chat/ungelesen")).ungelesen;
ok(nachher === vorher + 2, `Patrick: ${vorher} → ${nachher} ungelesen`);
/* Die EIGENEN zählen nicht mit -- sonst stünde nach jeder eigenen
Nachricht eine rote Zahl an der eigenen Kopfleiste. */
const beiLuna = (await holen(luna.keks, "/workspace/api/chat/ungelesen")).ungelesen;
ok(beiLuna === 0, `Luna hat ${beiLuna} ungelesene (ihre eigenen zählen nicht)`);
/* Lesen setzt sie zurück. */
const verlauf = await holen(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`);
const letzteId = verlauf.nachrichten.at(-1).id;
await rufe(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/gelesen`,
{ bis: letzteId }, "PUT");
const danach = (await holen(pat.keks, "/workspace/api/chat/ungelesen")).ungelesen;
ok(danach === 0, `nach dem Lesen: ${danach}`);
/* NUR VORWÄRTS: Ein verspäteter Aufruf mit einer alten Nummer darf
den Stand nicht zurückdrehen -- sonst stünde Gelesenes wieder als
ungelesen da. */
await rufe(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/gelesen`, { bis: 1 }, "PUT");
const zurueck = (await holen(pat.keks, "/workspace/api/chat/ungelesen")).ungelesen;
ok(zurueck === 0, `ein alter "gelesen bis 1" dreht nichts zurück (${zurueck})`);
}
/* =======================================================================
6b. EIGENE NACHRICHT BEARBEITEN (03.10.2026)
Filipe: "die nachrichten die man in den chat reinschreibt. jeder
soll seine eigene nachricht bearbeiten koennen."
DIE WICHTIGSTE ZEILE IST DIE GEGENPROBE MIT DOGFATHER. Er darf
JEDE Nachricht loeschen -- fuer den Notfall, so steht es seit dem
22.09. im Haus. Beim Bearbeiten darf er das ausdruecklich NICHT:
Eine fremde Nachricht zu entfernen heisst "das soll hier nicht
stehen"; sie zu AENDERN heisst, jemandem Worte in den Mund zu
legen, die unter seinem Namen und seinem Bild stehen bleiben.
Waere der Loeschweg hier blind mituebernommen worden, haette es
niemandem auffallen muessen -- es sieht ja aus wie dieselbe
Befugnis.
======================================================================= */
console.log("\n=== Eine eigene Nachricht bearbeiten ===");
{
const a = await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
{ text: "Ich kome moregn" }, "POST");
const { nachricht } = await a.json();
ok(!!nachricht?.id, `Luna schreibt etwas mit Tippfehlern (#${nachricht?.id})`);
/* Vorher merken: Rueckt der Raum beim Bearbeiten nach oben? Er
darf NICHT -- eine Tippfehlerkorrektur ist keine Wortmeldung. */
const vorher = await holen(luna.keks, "/workspace/api/chat/raeume");
const raumVorher = (vorher.raeume || []).find((r) => r.id === raumLunaPat);
ok(!!raumVorher?.letzte?.erstellt,
`der Raum steht in der Liste und hat einen Zeitpunkt (${raumVorher?.letzte?.erstellt || "keinen"})`);
const standVorher = raumVorher?.letzte?.erstellt;
/* ---- Die eigene: geht ------------------------------------------- */
const b = await rufe(luna.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`,
{ text: "Ich komme morgen" }, "PATCH");
ok(b.ok, `die eigene bearbeiten: geht (${b.status})`);
const bd = await b.json().catch(() => ({}));
ok(!!bd.bearbeitet_am, `und der Zeitpunkt kommt zurueck (${bd.bearbeitet_am || "nichts"})`);
/* ---- Steht es auch im Verlauf? ---------------------------------- */
const verlauf = await holen(luna.keks,
`/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`);
const drin = (verlauf.nachrichten || []).find((x) => x.id === nachricht.id);
ok(drin?.text === "Ich komme morgen",
`der neue Text steht im Verlauf ("${drin?.text}")`);
ok(!!drin?.bearbeitet_am,
`und der Vermerk "bearbeitet" geht mit nach draussen (${drin?.bearbeitet_am || "fehlt"})`);
/* DIE SPALTE MUSS AUCH IM SELECT STEHEN. Genau das hat am 03.10.
schon einmal einen halben Tag gekostet: `anhang_datei` fehlte dort,
die Abbildung lieferte brav `null`, und alles sah gebaut aus. */
/* ---- Der Raum ist NICHT nach oben gerueckt ---------------------- */
const nachher = await holen(luna.keks, "/workspace/api/chat/raeume");
const raumNachher = (nachher.raeume || []).find((r) => r.id === raumLunaPat);
ok(!!standVorher && standVorher === raumNachher?.letzte?.erstellt,
`der Raum rueckt nicht nach oben (${standVorher} -> ${raumNachher?.letzte?.erstellt})`);
/* Die Vorschau in der Liste zieht aber sehr wohl nach -- sie liest
dieselbe Nachricht. Stuende dort weiter der Tippfehler, waere die
Liste eine zweite, veraltete Wahrheit neben dem Verlauf. */
ok(raumNachher?.letzte?.text === "Ich komme morgen",
`aber die Vorschau zeigt den neuen Text ("${raumNachher?.letzte?.text}")`);
/* ---- GEGENPROBE: fremde Nachricht ------------------------------- */
const c = await rufe(pat.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`,
{ text: "Von Patrick umgeschrieben" }, "PATCH");
ok(c.status === 403, `Gegenprobe: Patrick aendert sie NICHT (${c.status})`);
/* ---- GEGENPROBE, DIE ZAEHLT: auch DogFather nicht --------------- */
const d = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`,
{ text: "Von DogFather umgeschrieben" }, "PATCH");
ok(d.status === 403 || d.status === 404,
`auch DogFather nicht -- loeschen darf er, umschreiben nicht (${d.status})`);
const nochda = await holen(luna.keks,
`/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`);
const unberuehrt = (nochda.nachrichten || []).find((x) => x.id === nachricht.id);
ok(unberuehrt?.text === "Ich komme morgen",
`und ihr Text ist unberuehrt ("${unberuehrt?.text}")`);
/* ---- Leer und zu lang ------------------------------------------- */
const leer = await rufe(luna.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`,
{ text: " " }, "PATCH");
ok(leer.status === 400, `leer geht nicht -- dafuer gibt es loeschen (${leer.status})`);
const lang = await rufe(luna.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`,
{ text: "x".repeat(4001) }, "PATCH");
ok(lang.status === 400, `zu lang geht nicht (${lang.status})`);
/* ---- Unveraendert setzt KEINEN Vermerk -------------------------- */
const zweite = await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
{ text: "Steht schon richtig" }, "POST");
const n2 = (await zweite.json()).nachricht;
const gleich = await rufe(luna.keks, `/workspace/api/chat/nachrichten/${n2.id}`,
{ text: "Steht schon richtig" }, "PATCH");
const gd = await gleich.json().catch(() => ({}));
ok(gleich.ok && gd.unveraendert === true && !gd.bearbeitet_am,
`derselbe Text setzt keinen Vermerk (${JSON.stringify(gd)})`);
const pruefN2 = (await holen(luna.keks,
`/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`)).nachrichten
.find((x) => x.id === n2.id);
ok(!pruefN2?.bearbeitet_am,
`und an ihr steht nichts -- sonst waere der Vermerk nichts mehr wert`);
/* ---- Eine geloeschte laesst sich nicht bearbeiten --------------- */
await rufe(luna.keks, `/workspace/api/chat/nachrichten/${n2.id}`, undefined, "DELETE");
const tot = await rufe(luna.keks, `/workspace/api/chat/nachrichten/${n2.id}`,
{ text: "Doch noch etwas" }, "PATCH");
ok(tot.status === 404,
`eine geloeschte Nachricht gibt es nicht mehr zu bearbeiten (${tot.status})`);
/* ---- Wer nicht im Raum ist, sieht sie gar nicht ----------------- */
const fremderRaum = await rufe(nora.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`,
{ text: "Von Nora" }, "PATCH");
ok(fremderRaum.status === 404,
`wer nicht im Raum ist, bekommt 404 und nicht 403 (${fremderRaum.status})`);
}
/* =======================================================================
7. Zurücknehmen
======================================================================= */
console.log("\n=== Eine Nachricht zurücknehmen ===");
{
const a = await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
{ text: "Das war ein Versehen" }, "POST");
const { nachricht } = await a.json();
/* Patrick darf sie NICHT zurücknehmen -- fremde Worte aus einem
Gespräch zu nehmen, in dem Absprachen stehen, wäre ein Eingriff in
die Geschichte. */
const b = await rufe(pat.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`, undefined, "DELETE");
ok(b.status === 403, `fremde Nachricht zurücknehmen: abgelehnt (${b.status})`);
/* Auch DogFather nicht. */
const c = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`, undefined, "DELETE");
ok(c.status === 404 || c.status === 403,
`auch DogFather nicht (${c.status})`);
/* Die eigene schon. */
const e = await rufe(luna.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`, undefined, "DELETE");
ok(e.ok, `die eigene: geht (${e.status})`);
/* UND SIE IST WIRKLICH WEG (22.09.2026 abends).
------------------------------------------------------------------
Hier stand das Gegenteil: „sie bleibt sichtbar -- als
zurueckgenommen, nicht als Loch." Das war die bewusste
Entscheidung bis heute Nachmittag.
Filipe: „Geloeschte Nachrichten verschwinden vollstaendig -- keine
Spur, kein ‚wurde geloescht'-Hinweis, bei niemandem, auch nicht
bei DogFather."
Die Zeile misst jetzt beides: dass sie fehlt UND dass der Rest des
Verlaufs noch da ist. Ohne den zweiten Teil waere ein Verlauf, der
ueberhaupt nichts mehr liefert, ebenfalls gruen. */
const verlauf = await holen(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`);
const weg = verlauf.nachrichten.find((n) => n.id === nachricht.id);
ok(!weg && verlauf.nachrichten.length > 0,
`sie steht in keinem Verlauf mehr, und die uebrigen`
+ ` ${verlauf.nachrichten.length} stehen noch da`);
ok(!verlauf.nachrichten.some((n) => n.zurueckgenommen),
"und es steht nirgends ein Hinweis, dass hier etwas stand");
ok(!JSON.stringify(verlauf).includes("Das war ein Versehen"),
"der Text ist wirklich weg, nicht nur ausgeblendet");
}
/* =======================================================================
8. Grenzen der Eingabe
======================================================================= */
console.log("\n=== Was nicht hineingeht ===");
{
for (const [was, koerper, status] of [
["leerer Text", { text: "" }, 400],
["nur Leerzeichen", { text: " " }, 400],
["ohne Feld", {}, 400],
["zu lang (4001)", { text: "z".repeat(4001) }, 400],
]) {
const a = await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
koerper, "POST");
ok(a.status === status, `${was}: ${a.status}`);
}
/* GEGENPROBE: 4000 Zeichen gehen. */
const a = await rufe(luna.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`,
{ text: "z".repeat(4000) }, "POST");
ok(a.status === 201, `genau 4000 Zeichen: ${a.status} — die Grenze ist nicht zu streng`);
}
/* =======================================================================
9. Ohne Anmeldung gar nichts
======================================================================= */
console.log("\n=== Ohne Anmeldung ===");
{
for (const [was, weg, methode] of [
["Partnerliste", "/workspace/api/chat/partner", "GET"],
["Gesprächsliste", "/workspace/api/chat/raeume", "GET"],
["Verlauf", `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`, "GET"],
["Ungelesen", "/workspace/api/chat/ungelesen", "GET"],
["Strom", "/workspace/api/chat/strom", "GET"],
]) {
const a = await fetch(BASIS + weg, { method: methode });
ok(a.status === 401, `${was}: ${a.status}`);
}
}
d.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
/* =====================================================================
GESPRAECHE OBEN ANHEFTEN (25.09.2026)
=====================================================================
Filipe: „ich will dass man auch individuel jeder fuer sich auch in
der liste chats fixieren kann. auch mehrere nicht nur eins."
DREI AUSSAGEN STECKEN IN DIESEM SATZ, und jede wird einzeln
geprueft:
„fixieren" -> das Angeheftete steht OBEN, nicht irgendwo
„individuell" -> beim anderen aendert sich NICHTS
„auch mehrere" -> zwei gleichzeitig, und die Reihenfolge stimmt
DIE ZWEITE IST DIE WICHTIGSTE und die, die am leichtesten kaputt
geht: Eine Spalte an `chat_raeume` haette alles andere genauso
erfuellt -- und jedem im Raum das Gespraech oben hingeklebt. Man
haette es erst gemerkt, wenn sich jemand beschwert.
GEGENPROBEN: fremder Raum (404) und das Loesen (steht wieder
unten). Ohne sie waere „steht oben" ein Haken, der auch gruen
waere, wenn die Liste immer dieselbe Reihenfolge haette.
===================================================================== */
console.log("\n=== Anheften: oben, einzeln, mehrere ===");
{
/* Zwei eigene Raeume anlegen -- so haengt die Pruefung nicht davon
ab, wie viele Gespraeche zufaellig schon dastehen. Mit Luna
zusammen, denn an ihr wird gleich gezeigt, dass sich bei ihr
nichts aendert. */
const machGruppe = async (name) => {
const a = await rufe(dogi.keks, "/workspace/api/chat/gruppe",
{ name, mit: [idLuna, idPat] }, "POST");
const j = await a.json().catch(() => ({}));
return j.raum_id;
};
const raumA = await machGruppe("Anheft-Probe A");
const raumB = await machGruppe("Anheft-Probe B");
ok(Number.isInteger(raumA) && Number.isInteger(raumB) && raumA !== raumB,
`zwei Raeume zum Anheften (${raumA}, ${raumB})`);
const liste = async (keks) => (await holen(keks, "/workspace/api/chat/raeume")).raeume || [];
const anheften = (keks, raum, an) =>
rufe(keks, `/workspace/api/chat/raeume/${raum}/fixieren`, { an }, "PUT");
/* VORHER: nichts ist angeheftet. Ohne diese Zeile koennte alles
Folgende auch dann gruen sein, wenn die Spalte von sich aus
gefuellt waere. */
const vorher = await liste(dogi.keks);
ok(vorher.length >= 2 && vorher.every((r) => r.fixiert === false),
`vorher ist nichts angeheftet (${vorher.length} Gespraeche)`);
/* ---- 1. „fixieren": oben ---------------------------------------- */
const wo = vorher.findIndex((r) => r.id === raumA);
const a1 = await anheften(dogi.keks, raumA, true);
const j1 = await a1.json().catch(() => ({}));
ok(a1.status === 200 && j1.fixiert === true,
`Raum A laesst sich anheften (HTTP ${a1.status}, fixiert ${j1.fixiert})`);
const nachA = await liste(dogi.keks);
ok(nachA[0]?.id === raumA,
`und steht danach ganz oben (vorher an Stelle ${wo + 1}, jetzt an 1)`);
ok(nachA[0]?.fixiert === true, " und traegt die Marke dafuer");
/* ---- 2. „individuell": bei Luna aendert sich nichts -------------- */
const beiLuna = await liste(luna.keks);
const lunaA = beiLuna.find((r) => r.id === raumA);
ok(!!lunaA && lunaA.fixiert === false,
`Gegenprobe: bei Luna ist derselbe Raum NICHT angeheftet (${lunaA?.fixiert})`);
/* UND ER STEHT BEI IHR AUCH NICHT OBEN. Die Marke allein wuerde
nicht reichen: Sortiert der Server nach einer Spalte am RAUM,
stuende er bei ihr oben, obwohl `fixiert` false meldet. */
ok(beiLuna.length < 2 || beiLuna[0].id !== raumA
|| beiLuna.filter((r) => r.fixiert).length > 0,
` und rutscht bei ihr auch nicht nach oben (oben steht ${beiLuna[0]?.id})`);
/* ---- 3. „auch mehrere" ------------------------------------------ */
await anheften(dogi.keks, raumB, true);
const nachB = await liste(dogi.keks);
ok(nachB.filter((r) => r.fixiert).length === 2,
`zwei gleichzeitig angeheftet (${nachB.filter((r) => r.fixiert).length})`);
ok(nachB[0]?.id === raumB && nachB[1]?.id === raumA,
`und das zuletzt angeheftete steht oben (${nachB[0]?.id}, dann ${nachB[1]?.id})`);
ok(nachB.slice(2).every((r) => r.fixiert === false),
" darunter geht die gewohnte Reihenfolge weiter");
/* ---- 4. Loesen --------------------------------------------------- */
const a2 = await anheften(dogi.keks, raumB, false);
ok(a2.status === 200, `es laesst sich wieder loesen (HTTP ${a2.status})`);
const nachLos = await liste(dogi.keks);
ok(nachLos[0]?.id === raumA && nachLos.filter((r) => r.fixiert).length === 1,
`danach haengt nur noch A oben (oben ${nachLos[0]?.id}, `
+ `${nachLos.filter((r) => r.fixiert).length} angeheftet)`);
/* ---- 5. Ein fremder Raum ---------------------------------------- */
/* Nora sitzt in keinem der beiden. Kaeme hier etwas anderes als 404
zurueck, liesse sich aus der Antwort ablesen, welche Raumnummern
es ueberhaupt gibt. */
const fremd = await anheften(nora.keks, raumA, true);
ok(fremd.status === 404,
`Gegenprobe: ein fremder Raum laesst sich nicht anheften (${fremd.status})`);
const beiNora = await liste(nora.keks);
ok(beiNora.every((r) => r.fixiert === false),
" und bei ihr haengt danach auch nichts");
}
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
/* HIER IST process.exit() RICHTIG -- anders als in pruef-push-weg.
Diese Pruefung startet einen Express-Server, und der laeuft von
selbst weiter: offene Verbindungen, ein Zeitgeber fuer die
Sicherung, bei dieser Pruefung zusaetzlich die offenen
Ereignisstroeme des Chats. Mit `exitCode` allein wartet Node ewig
darauf, dass all das zu Ende geht -- der Lauf lief in die
Zeitueberschreitung, obwohl alle Pruefungen schon durch waren.
Der Absturz, der in pruef-push-weg gegen exit() sprach, kam von
fetch-Verbindungen mitten im Schliessen. Die gibt es hier nicht
mehr: Die Browser sind zu, bevor diese Zeile laeuft. */
process.exit(fehler ? 1 : 0);