Files
dogfather-universe/server/pruef-chat-aufloesen.mjs
T
DogFatherGitandClaude Opus 5 47269bda21 Chat: auf eine Nachricht reagieren, ohne zu antworten
Filipe: "ich will dass man auf die nachrichten auch reagieren kann mit
einem emoji, die nachricht selbst ohne zu antworten."

Neben "antworten" steht jetzt "reagieren". Ein Klick klappt sechs
Zeichen auf, ein zweiter Klick setzt es -- und unter der Nachricht
steht, wer mit was reagiert hat.

EINE FESTE AUSWAHL, KEIN FREIES FELD (👍 ❤️ 😂 😮 🔥 🙏). Drei Gruende,
der erste ist der wichtigste: Eine feste Liste laesst sich pruefen --
was dort nicht drinsteht, kommt nicht in die Datenbank, kein beliebiger
Text, keine 4-KB-Zeichenketten. Zweitens sehen alle dasselbe. Drittens
kann man sechs Zeichen ueberfliegen; eine volle Tafel ist eine
Suchaufgabe, und dann tippt man doch wieder "ok".

DIE LISTE STEHT AUF DEM SERVER und wird an die Oberflaeche geliefert.
Stuende sie im Browser, gaebe es sie zweimal -- und die zweite Fassung
waere die, in der ein Zeichen fehlt, das der Server noch annimmt. Faellt
der Abruf aus, erscheint KEINE Auswahl statt einer mit erfundenen
Zeichen.

DER PRIMAERSCHLUESSEL BEANTWORTET "WIE OFT?": (nachricht, person,
zeichen). Eine Person darf verschiedene Zeichen geben, jedes aber nur
einmal -- und zwar durchgesetzt von der Datenbank, nicht von einer
Abfrage davor, die man vergessen kann. Derselbe Klick noch einmal nimmt
es zurueck; das erspart einen zweiten Weg, den man auch absichern
muesste.

KEIN ZAEHLER IN chat_nachrichten. Eine mitgefuehrte Zahl muesste bei
jedem Hin und Her stimmen und ist genau die Art Angabe, die irgendwann
nicht mehr stimmt und die niemand nachrechnet. Gezaehlt wird beim
Lesen -- in EINER Abfrage fuer alle 200 Nachrichten, nicht je Zeile
eine.

WER ES WAR, STEHT IM TITEL. "Drei Daumen" sagt weniger als "Ayla, Ben
und Cem", und es sind Leute aus demselben Raum.

KEINE BENACHRICHTIGUNG AUFS HANDY. Wer im Raum ist, sieht es sofort;
wer nicht, bekommt nichts. Ein Daumen ist keine Nachricht -- wer dafuer
geweckt wird, schaltet Meldungen ab.

Was NICHT geht: wer nicht im Raum ist (404), ein erfundenes Zeichen
(400), eine zurueckgenommene Nachricht (409). Und mit der Nachricht
verschwinden die Reaktionen (CASCADE).

pruef-chat-aufloesen 59 -> 85. Gemessen wird der BESTAND, nicht die
Antwort -- und im Browser der KNOPF, nicht nur das Recht: dass die
Auswahl aufklappt, sich nach dem Klick von selbst schliesst, die
Reaktion darunter erscheint, als die eigene erkennbar ist und ein Klick
darauf sie wieder wegnimmt.

EINE EIGENE PRUEFUNG WAR ZU SCHWACH: "bei Cem ist ueberall selbst=true"
war trivial wahr, weil es nur EINE Sorte gab. Jetzt bekommt eine andere
Person ein drittes Zeichen, und in derselben Antwort muss eines auf
true und eines auf false stehen. Eine Angabe, die nie `false` sein kann,
sagt nichts.

Im CSS keine vierte Abschrift: "reagieren" haengt an derselben Regel
wie "antworten" -- neben anheften und kopieren waere es die vierte fast
gleiche gewesen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 13:46:31 +02:00

705 lines
33 KiB
JavaScript

/* EINE GRUPPE ODER EINEN KANAL AUFLÖSEN — FÜR ALLE (11.09.2026)
Filipe: "wenn ein gruppenchat oder kanal geöffnet wird, die person
die ihn öffnet soll auch das recht haben das zu löschen und so dass
es dan für jeden gelöscht ist. aber nur die person die es gründet."
---------------------------------------------------------------------
WARUM DIESE DATEI EXISTIERT
Es gibt jetzt ZWEI Löschknöpfe nebeneinander, und sie tun etwas sehr
Verschiedenes:
DELETE /raeume/:id -> Wegräumen. Nur bei mir. Umkehrbar in
dem Sinn, dass die anderen alles haben.
DELETE /raeume/:id/ganz -> Auflösen. Für alle. Endgültig.
Eine Verwechslung der beiden ist der teuerste Fehler, den dieser
Chat machen kann -- ein ganzer Verlauf ist weg, bei allen, ohne
Sicherung. Deshalb prüft diese Datei nicht nur, dass das Auflösen
funktioniert, sondern vor allem, was es NICHT tut und wer es NICHT
darf.
GEMESSEN WIRD AM BESTAND, NICHT AN DER ANTWORT. Ein "ok: true" sagt
nur, dass der Server nicht gestolpert ist. Ob der Raum wirklich weg
ist, steht in der Datenbank -- und ob der Anhang weg ist, auf der
Platte. Beides wird hier nachgesehen.
Aufruf: node server/pruef-chat-aufloesen.mjs
===================================================================== */
import { mkdtempSync, rmSync, existsSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const HIER_BILD = dirname(fileURLToPath(import.meta.url));
const ordner = mkdtempSync(join(tmpdir(), "ws-aufloesen-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { portMussFreiSein } = await import("./helfer-port.mjs");
await portMussFreiSein(4391, "die Aufloesen-Pruefung");
process.env.PORT = "4391";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const BASIS = "http://127.0.0.1:4391";
await import("./index.js");
/* Eigene Zuhoerer neben denen aus index.js -- sonst wird ein Absturz
dieser Datei nur notiert und der Lauf haengt (siehe die
ausfuehrliche Begruendung in pruef-chat-kanaele.mjs). */
process.on("uncaughtException", (f) => {
console.error(""); console.error("ABBRUCH durch einen Absturz:", f?.stack || f);
process.exit(1);
});
process.on("unhandledRejection", (f) => {
console.error(""); console.error("ABBRUCH durch ein unbehandeltes Versprechen:", f?.stack || f);
process.exit(1);
});
const notbremse = setTimeout(() => {
console.error(""); console.error("ABBRUCH: Die Pruefung laeuft seit 120 s.");
process.exit(1);
}, 120000);
notbremse.unref?.();
let fehler = 0;
let geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---- Bestand ---------------------------------------------------------- */
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;
}
anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idHand = anlegen("Vanessa", "hand", "CODE-HAND-0001");
const idEins = anlegen("Ayla", "modi", "CODE-TEAM-0001");
const idZwei = anlegen("Ben", "modi", "CODE-TEAM-0002");
const idDrei = anlegen("Cem", "modi", "CODE-TEAM-0003");
/* Anmeldung an der Crew-Wand: Modis und die rechte Hand kommen an der
Agenturwand nicht herein, und `fetch` laesst einen selbst gesetzten
Host-Kopf fallen -- deshalb node:http. */
const { request: httpAnfrage } = await import("node:http");
function anmelden(rolle, code, wand = "crew.dogfather-universe.com") {
return new Promise((fertig) => {
const rumpf = JSON.stringify({ rolle, code });
const a = httpAnfrage({
host: "127.0.0.1", port: 4391, path: "/workspace/api/anmelden", method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(rumpf),
Host: wand,
},
}, (antwort) => {
antwort.resume();
antwort.on("end", () => fertig({
ok: antwort.statusCode === 200,
keks: (antwort.headers["set-cookie"] || []).map((k) => k.split(";")[0]).join("; "),
}));
});
a.on("error", () => fertig({ ok: false, keks: "" }));
a.end(rumpf);
});
}
const rufe = (keks, weg, koerper, methode = "GET") =>
fetch(BASIS + weg, {
method: methode,
headers: { "Content-Type": "application/json", Cookie: keks, Origin: BASIS },
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 hand = await anmelden("hand", "CODE-HAND-0001");
const eins = await anmelden("modi", "CODE-TEAM-0001");
const zwei = await anmelden("modi", "CODE-TEAM-0002");
const drei = await anmelden("modi", "CODE-TEAM-0003");
ok([dogi, hand, eins, zwei, drei].every((x) => x.ok && x.keks),
"alle fuenf sind angemeldet");
const raumVon = (keks, id) => holen(keks, "/workspace/api/chat/raeume")
.then((a) => (a.raeume || []).find((r) => r.id === id) || null);
const stehtDa = (id) => !!d.prepare("SELECT 1 FROM chat_raeume WHERE id = ?").get(id);
/* =======================================================================
1. Eine Gruppe, gegruendet von Ayla
======================================================================= */
console.log("");
console.log("=== Wer darf aufloesen? ===");
const gruppe = await (await rufe(eins.keks, "/workspace/api/chat/gruppe",
{ mit: [idZwei, idDrei], name: "Clipping Mo-Fr" }, "POST")).json();
ok(Number.isInteger(gruppe.raum_id), `Ayla legt eine Gruppe an (#${gruppe.raum_id})`);
/* Zwei Nachrichten hinein, damit es auch etwas zu verlieren gibt. */
for (const [k, t] of [[eins, "Moin"], [zwei, "Bin dabei"]]) {
await rufe(k.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/nachrichten`,
{ text: t }, "POST");
}
const wieViele = d.prepare(
"SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?").get(gruppe.raum_id).n;
ok(wieViele === 2, `zwei Nachrichten stehen darin (${wieViele})`);
/* ---- Das Recht steht in der Liste, nicht im Browser ------------------- */
{
const beiAyla = await raumVon(eins.keks, gruppe.raum_id);
const beiBen = await raumVon(zwei.keks, gruppe.raum_id);
ok(beiAyla?.darf_aufloesen === true, "Ayla bekommt das Recht mitgeliefert");
ok(beiBen?.darf_aufloesen === false,
"Ben nicht -- er ist dabei, aber er hat sie nicht angelegt");
}
/* ---- Und wer es nicht hat, kommt auch nicht durch --------------------- */
{
const a = await rufe(zwei.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`,
undefined, "DELETE");
const d2 = await a.json().catch(() => ({}));
ok(a.status === 403 && d2.fehler === "nur_gruender",
`Ben wird abgewiesen (HTTP ${a.status}, ${d2.fehler})`);
ok(stehtDa(gruppe.raum_id), "und die Gruppe steht noch");
}
{
/* Die rechte Hand darf ALLES sehen -- aufloesen darf sie trotzdem
nicht. Das ist der Fall, den man beim Bauen am ehesten aufweicht
("sie ist ja die Leitung"), und genau deshalb steht er hier. */
const a = await rufe(hand.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`,
undefined, "DELETE");
ok(a.status === 403 || a.status === 404,
`auch die rechte Hand nicht (HTTP ${a.status})`);
ok(stehtDa(gruppe.raum_id), "und die Gruppe steht immer noch");
}
/* =======================================================================
2. Wegraeumen ist NICHT aufloesen
======================================================================= */
console.log("");
console.log("=== Der Unterschied zum Knopf daneben ===");
{
const a = await rufe(zwei.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}`,
undefined, "DELETE");
ok(a.status === 200, `Ben raeumt sie bei sich weg (HTTP ${a.status})`);
ok(stehtDa(gruppe.raum_id), "der Raum bleibt in der Datenbank");
const beiBen = await raumVon(zwei.keks, gruppe.raum_id);
ok(beiBen === null, "bei Ben steht sie nicht mehr in der Liste");
const beiCem = await raumVon(drei.keks, gruppe.raum_id);
ok(beiCem !== null, "bei Cem dagegen schon -- niemand nimmt einem anderen etwas weg");
const uebrig = d.prepare(
"SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?").get(gruppe.raum_id).n;
ok(uebrig === 2, `und beide Nachrichten sind noch da (${uebrig})`);
}
/* =======================================================================
3. Ein Zweier-Gespraech laesst sich gar nicht aufloesen
======================================================================= */
console.log("");
console.log("=== Zweier-Gespraeche bleiben unantastbar ===");
{
const direkt = await (await rufe(eins.keks, "/workspace/api/chat/direkt",
{ mit: idZwei }, "POST")).json();
ok(Number.isInteger(direkt.raum_id), `Ayla schreibt Ben direkt (#${direkt.raum_id})`);
const liste = await raumVon(eins.keks, direkt.raum_id);
ok(liste?.darf_aufloesen === false,
"auch die Anlegerin bekommt dort kein Aufloesen-Recht");
const a = await rufe(eins.keks, `/workspace/api/chat/raeume/${direkt.raum_id}/ganz`,
undefined, "DELETE");
const d2 = await a.json().catch(() => ({}));
ok(a.status === 403 && d2.fehler === "nur_gruppe_oder_kanal",
`und der Weg weist sie ab (HTTP ${a.status}, ${d2.fehler})`);
ok(stehtDa(direkt.raum_id), "das Gespraech steht weiterhin");
}
/* =======================================================================
4. Fremde kommen nicht einmal in die Naehe
======================================================================= */
console.log("");
console.log("=== Wer nicht dabei ist ===");
{
/* Cem ist in der Gruppe, Ayla hat sie gegruendet. Ein VIERTER, der
nicht dabei ist, muss 404 bekommen -- nicht 403. 403 waere die
Auskunft, dass es diesen Raum gibt. */
const vier = anlegen("Dilan", "modi", "CODE-TEAM-0004");
const vierK = await anmelden("modi", "CODE-TEAM-0004");
ok(vierK.ok && Number.isInteger(vier), "ein Vierter ist angemeldet");
const a = await rufe(vierK.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`,
undefined, "DELETE");
ok(a.status === 404, `er bekommt 404, nicht 403 (HTTP ${a.status})`);
}
/* =======================================================================
5. Und jetzt wirklich: die Gruenderin loest auf
======================================================================= */
console.log("");
console.log("=== Aufloesen, gemessen am Bestand ===");
{
const vorher = {
raum: stehtDa(gruppe.raum_id),
nachrichten: d.prepare("SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?")
.get(gruppe.raum_id).n,
leute: d.prepare("SELECT COUNT(*) AS n FROM chat_teilnehmer WHERE raum_id = ?")
.get(gruppe.raum_id).n,
};
ok(vorher.raum && vorher.nachrichten === 2 && vorher.leute === 3,
`vorher: Raum da, ${vorher.nachrichten} Nachrichten, ${vorher.leute} Beteiligte`);
const a = await rufe(eins.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`,
undefined, "DELETE");
const antwort = await a.json().catch(() => ({}));
ok(a.status === 200 && antwort.ok === true,
`Ayla loest auf (HTTP ${a.status})`);
ok(antwort.nachrichten === 2 && antwort.beteiligte === 3,
`die Antwort nennt, was wegfiel (${antwort.nachrichten} Nachrichten, `
+ `${antwort.beteiligte} Beteiligte)`);
ok(!stehtDa(gruppe.raum_id), "der Raum ist aus der Datenbank verschwunden");
const restN = d.prepare("SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?")
.get(gruppe.raum_id).n;
const restT = d.prepare("SELECT COUNT(*) AS n FROM chat_teilnehmer WHERE raum_id = ?")
.get(gruppe.raum_id).n;
ok(restN === 0, `keine Nachricht blieb liegen (${restN})`);
ok(restT === 0, `keine Teilnehmerzeile blieb liegen (${restT})`);
/* UND BEI DEN ANDEREN. Das ist der eigentliche Satz aus Filipes
Wunsch: "so dass es dan fuer jeden geloescht ist." */
const beiCem = await raumVon(drei.keks, gruppe.raum_id);
ok(beiCem === null, "bei Cem ist sie ebenfalls weg -- fuer jeden");
/* GEGENPROBE: Ein zweiter Versuch findet nichts mehr. Ohne sie
koennte der Weg beim zweiten Aufruf stillschweigend "ok" sagen. */
const nochmal = await rufe(eins.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`,
undefined, "DELETE");
ok(nochmal.status === 404, `ein zweiter Versuch findet nichts (HTTP ${nochmal.status})`);
}
/* =======================================================================
6. Ein Kanal -- und seine Zustaendigkeit wird wieder frei
======================================================================= */
console.log("");
console.log("=== Kanaele ===");
{
const frei = await holen(hand.keks, "/workspace/api/chat/raeume");
const kat = (frei.kanal_frei || [])[0]?.wert;
ok(!!kat, `es gibt eine freie Zustaendigkeit (${kat || "keine"})`);
const kanal = await (await rufe(hand.keks, "/workspace/api/chat/kanal",
{ kategorie: kat }, "POST")).json();
ok(Number.isInteger(kanal.raum_id), `die rechte Hand legt den Kanal an (#${kanal.raum_id})`);
/* DogFather kommt in jeden Kanal -- aufloesen darf er trotzdem
nicht, denn angelegt hat ihn die rechte Hand. */
const beiDogi = await raumVon(dogi.keks, kanal.raum_id);
ok(beiDogi !== null, "DogFather sieht den Kanal");
ok(beiDogi?.darf_aufloesen === false,
"und darf ihn NICHT aufloesen -- er hat ihn nicht angelegt");
const versuch = await rufe(dogi.keks, `/workspace/api/chat/raeume/${kanal.raum_id}/ganz`,
undefined, "DELETE");
ok(versuch.status === 403, `der Weg bestaetigt das (HTTP ${versuch.status})`);
const beiHand = await raumVon(hand.keks, kanal.raum_id);
ok(beiHand?.darf_aufloesen === true, "die Gruenderin dagegen darf");
const weg = await rufe(hand.keks, `/workspace/api/chat/raeume/${kanal.raum_id}/ganz`,
undefined, "DELETE");
ok(weg.status === 200, `sie loest ihn auf (HTTP ${weg.status})`);
ok(!stehtDa(kanal.raum_id), "der Kanal ist weg");
/* Und die Zustaendigkeit steht wieder zur Verfuegung. Ohne diese
Zeile koennte der eindeutige Index sie dauerhaft blockieren -- ein
Kanal, den es nicht gibt und den man nicht anlegen kann. */
const danach = await holen(hand.keks, "/workspace/api/chat/raeume");
ok((danach.kanal_frei || []).some((k) => k.wert === kat),
`die Zustaendigkeit "${kat}" ist wieder frei`);
const neu = await (await rufe(hand.keks, "/workspace/api/chat/kanal",
{ kategorie: kat }, "POST")).json();
ok(Number.isInteger(neu.raum_id), `und laesst sich neu anlegen (#${neu.raum_id})`);
}
/* =======================================================================
7. Anhaenge verschwinden von der Platte
======================================================================= */
console.log("");
console.log("=== Anhaenge ===");
{
const g = await (await rufe(eins.keks, "/workspace/api/chat/gruppe",
{ mit: [idZwei], name: "Mit Anhang" }, "POST")).json();
ok(Number.isInteger(g.raum_id), `eine Gruppe mit Anhang (#${g.raum_id})`);
/* Ein winziges PNG, damit es wirklich durch die Bildpruefung geht. */
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
+ "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64");
/* ROH, NICHT ALS FORMULAR. Der Weg nimmt den Dateiinhalt direkt
entgegen (express.raw) und erkennt den Typ an den ersten Bytes --
ein multipart-Umschlag kommt dort als Unsinn an und wird mit 415
abgewiesen. Beim ersten Anlauf stand hier FormData, und die
Pruefung mass daraufhin ein Loeschen von nichts. */
const hoch = await fetch(BASIS + `/workspace/api/chat/raeume/${g.raum_id}/anhang`, {
method: "POST",
headers: {
Cookie: eins.keks, Origin: BASIS,
"Content-Type": "application/octet-stream",
"x-dateiname": "punkt.png",
},
body: png });
ok(hoch.status === 200 || hoch.status === 201,
`der Anhang ist oben (HTTP ${hoch.status})`);
const datei = d.prepare(
`SELECT anhang_datei FROM chat_nachrichten
WHERE raum_id = ? AND anhang_datei IS NOT NULL`).get(g.raum_id)?.anhang_datei;
ok(!!datei, `die Datei steht in der Tabelle (${datei || "keine"})`);
const { DATEN_ORDNER } = await import("./workspace.js");
const pfad = join(DATEN_ORDNER, "chat-anhaenge", datei || "-");
ok(existsSync(pfad), "und liegt auf der Platte");
const weg = await rufe(eins.keks, `/workspace/api/chat/raeume/${g.raum_id}/ganz`,
undefined, "DELETE");
ok(weg.status === 200, `die Gruppe wird aufgeloest (HTTP ${weg.status})`);
ok(!existsSync(pfad), "und die Datei ist von der Platte verschwunden");
/* GEGENPROBE ZUM PFAD: Der Ordner selbst bleibt stehen. Ein
`existsSync` auf einen Pfad, den es nie gab, waere sonst auch
"weg" -- und die Zeile darueber bewiese nichts. */
ok(existsSync(join(DATEN_ORDNER, "chat-anhaenge")),
"der Anhang-Ordner selbst steht noch (die Pruefung misst die Datei, nicht den Ordner)");
}
/* =======================================================================
7b. REAKTIONEN — auf die Nachricht, ohne zu antworten (14.09.2026)
=======================================================================
Filipe: "ich will dass man auf die nachrichten auch reagieren kann
mit einem emoji, die nachricht selbst ohne zu antworten."
GEMESSEN WIRD DER BESTAND, nicht die Antwort. Ein "ok" sagt nur,
dass der Server nicht gestolpert ist; ob die Reaktion wirklich steht
(und beim zweiten Klick wirklich weg ist), steht in der Datenbank.
======================================================================= */
console.log("");
console.log("=== Reaktionen ===");
{
const g = await (await rufe(eins.keks, "/workspace/api/chat/gruppe",
{ mit: [idZwei, idDrei], name: "Reaktionsrunde" }, "POST")).json();
const n = await (await rufe(eins.keks,
`/workspace/api/chat/raeume/${g.raum_id}/nachrichten`, { text: "Moin" }, "POST")).json();
const nid = n.nachricht?.id;
ok(Number.isInteger(nid), `eine Nachricht zum Reagieren (#${nid})`);
const auswahl = await holen(eins.keks, "/workspace/api/chat/reaktionen/auswahl");
ok(Array.isArray(auswahl.zeichen) && auswahl.zeichen.length >= 4,
`die Auswahl kommt vom Server (${(auswahl.zeichen || []).length} Zeichen)`);
const daumen = auswahl.zeichen[0];
const herz = auswahl.zeichen[1];
const zaehle = () => d.prepare(
"SELECT COUNT(*) AS n FROM chat_reaktionen WHERE nachricht_id = ?").get(nid).n;
/* ---- Setzen ---------------------------------------------------- */
const a1 = await (await rufe(zwei.keks,
`/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: daumen }, "PUT")).json();
ok(zaehle() === 1, `Ben setzt ein Zeichen -- es steht in der Datenbank (${zaehle()})`);
ok(a1.reaktionen?.[0]?.anzahl === 1 && a1.reaktionen?.[0]?.selbst === true,
"die Antwort nennt Anzahl und dass es seines ist");
/* ---- Derselbe Mensch, ZWEITES Zeichen -- das geht --------------- */
await rufe(zwei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`,
{ zeichen: herz }, "PUT");
ok(zaehle() === 2, `dasselbe Mensch darf ein ZWEITES Zeichen geben (${zaehle()})`);
/* ---- Derselbe Mensch, DASSELBE Zeichen -- nimmt zurueck --------- */
const a3 = await (await rufe(zwei.keks,
`/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: daumen }, "PUT")).json();
ok(zaehle() === 1, `zweimal dasselbe nimmt es wieder weg (${zaehle()})`);
ok(a3.gesetzt === false, "und die Antwort sagt, dass es weg ist");
/* ---- Mehrere Menschen, dasselbe Zeichen ------------------------- */
await rufe(drei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`,
{ zeichen: herz }, "PUT");
const stand = await (await rufe(eins.keks,
`/workspace/api/chat/nachrichten/${nid}/reaktion`, { zeichen: herz }, "PUT")).json();
const herzEintrag = (stand.reaktionen || []).find((r) => r.zeichen === herz);
ok(herzEintrag?.anzahl === 3, `drei Menschen, ein Zeichen, Anzahl 3 (${herzEintrag?.anzahl})`);
ok(Array.isArray(herzEintrag?.wer) && herzEintrag.wer.length === 3,
`und die Namen stehen dabei (${(herzEintrag?.wer || []).join(", ")})`);
/* ---- Was NICHT geht --------------------------------------------- */
const fremd = await rufe(hand.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`,
{ zeichen: daumen }, "PUT");
ok(fremd.status === 404,
`wer nicht im Raum ist, kommt nicht heran (HTTP ${fremd.status})`);
const unsinn = await rufe(zwei.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`,
{ zeichen: "<script>alert(1)</script>" }, "PUT");
ok(unsinn.status === 400,
`ein erfundenes Zeichen wird abgewiesen (HTTP ${unsinn.status})`);
ok(zaehle() === 3, `und aendert nichts am Bestand (${zaehle()})`);
/* ---- Eine zurueckgenommene Nachricht ----------------------------- */
const weg = await (await rufe(eins.keks,
`/workspace/api/chat/raeume/${g.raum_id}/nachrichten`, { text: "Weg gleich" }, "POST")).json();
await rufe(eins.keks, `/workspace/api/chat/nachrichten/${weg.nachricht.id}`,
undefined, "DELETE");
const drauf = await rufe(zwei.keks,
`/workspace/api/chat/nachrichten/${weg.nachricht.id}/reaktion`, { zeichen: daumen }, "PUT");
ok(drauf.status === 409,
`auf eine zurueckgenommene Nachricht geht es nicht (HTTP ${drauf.status})`);
/* ---- Und sie ueberleben das Neuladen ----------------------------- */
const verlauf = await holen(zwei.keks,
`/workspace/api/chat/raeume/${g.raum_id}/nachrichten`);
const wieder = (verlauf.nachrichten || []).find((x) => x.id === nid);
ok(Array.isArray(wieder?.reaktionen) && wieder.reaktionen.length === 1,
`beim Neuladen sind sie wieder da (${(wieder?.reaktionen || []).length} Sorte)`);
ok(wieder?.reaktionen?.[0]?.selbst === true,
"und Ben sieht, dass eines davon seines ist");
/* GEGENPROBE, DIE WIRKLICH UNTERSCHEIDET.
Der erste Entwurf pruefte "bei Cem ist ueberall selbst=true" -- und
das war trivial wahr, weil es zu dem Zeitpunkt nur EINE Sorte gab.
Eine Angabe, die nie `false` sein kann, sagt nichts.
Also bekommt Ayla ein drittes Zeichen, das Cem NICHT gegeben hat.
Jetzt muss bei Cem eines auf `true` und eines auf `false` stehen --
in derselben Antwort. */
const lachen = auswahl.zeichen[2];
await rufe(eins.keks, `/workspace/api/chat/nachrichten/${nid}/reaktion`,
{ zeichen: lachen }, "PUT");
const beiDrei = await holen(drei.keks,
`/workspace/api/chat/raeume/${g.raum_id}/nachrichten`);
const seins = (beiDrei.nachrichten || []).find((x) => x.id === nid);
const meins = (seins?.reaktionen || []).find((r) => r.zeichen === herz);
const fremdes = (seins?.reaktionen || []).find((r) => r.zeichen === lachen);
ok(meins?.selbst === true && fremdes?.selbst === false,
`bei Cem: sein Herz ist "selbst", Aylas Lachen nicht `
+ `(${meins?.selbst} / ${fremdes?.selbst})`);
/* ---- Die Nachricht loeschen nimmt die Reaktionen mit ------------- */
const vorherAnzahl = zaehle();
ok(vorherAnzahl > 0, `vorher stehen ${vorherAnzahl} Reaktionen da`);
d.prepare("DELETE FROM chat_nachrichten WHERE id = ?").run(nid);
ok(zaehle() === 0,
`mit der Nachricht verschwinden die Reaktionen (${zaehle()} uebrig)`);
}
/* =======================================================================
8. Und wie es auf dem Bildschirm aussieht
=======================================================================
Filipe: "ich will eine bessere erklaerung dafuer bitte." -- gemeint
war der Umschalter "Gespraech | Kanal" im Neu-Fenster. Daneben stand
ein Satz ueber das ANTIPPEN von Personen, also ueber den naechsten
Schritt, nicht ueber die Wahl, die gerade ansteht.
EIN TEXT, DEN NIEMAND PRUEFT, VERSCHWINDET beim naechsten Umbau, und
es faellt keinem auf, weil ein fehlender Satz nichts kaputtmacht.
DIE ANMELDUNG LAEUFT UEBER DEN KEKS, NICHT UEBER DAS FORMULAR: Die
Crew-Rollen kommen an der Agenturwand nicht herein, und ein Browser
laesst den Host-Kopf nicht setzen (Chrome verbietet es). Der Keks
aus der Anmeldung oben gilt fuer 127.0.0.1 -- also wird er direkt
in den Browser gelegt. Das umgeht keine Regel, es umgeht ein
Werkzeug-Hindernis. */
console.log("");
console.log("=== Das Neu-Fenster ===");
{
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const [kname, kwert] = hand.keks.split("=");
await ctx.addCookies([{ name: kname, value: kwert, domain: "127.0.0.1", path: "/" }]);
const seite = await ctx.newPage();
const skriptfehler = [];
seite.on("pageerror", (e) => skriptfehler.push(String(e).slice(0, 140)));
await seite.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(1400);
ok(skriptfehler.length === 0, `kein Skriptfehler (${skriptfehler.join(" | ") || "keiner"})`);
await seite.click("#neu-oeffnen");
await seite.waitForSelector("#neu-dialog[open]", { timeout: 5000 });
await seite.waitForTimeout(400);
const lage = await seite.evaluate(() => {
const e = document.getElementById("neu-erklaerung");
const u = document.getElementById("neu-unter");
return {
umschalter: document.getElementById("neu-art")?.hidden === false,
/* Ohne Leerraum-Muster: Ein regulaerer Ausdruck stand hier mit
einem verschluckten Backslash da ("/s+/g") und hat jedes "s"
durch ein Leerzeichen ersetzt -- aus "Gespraech" wurde
"Ge praech", und die Pruefung fand das Wort nicht mehr.
Zum Suchen von Woertern braucht es keine Normalisierung. */
erklaerung: e && !e.hidden ? e.textContent.trim() : "",
unter: u ? u.textContent.trim() : "",
};
});
ok(lage.umschalter === true, "die rechte Hand sieht den Umschalter");
ok(lage.erklaerung.length > 60,
`und darunter eine Erklaerung (${lage.erklaerung.length} Zeichen)`);
/* Sie muss BEIDE Woerter erklaeren -- eine Erklaerung, die nur eines
der beiden nennt, laesst genau die Frage offen, die Filipe hatte. */
ok(/Gespräch/.test(lage.erklaerung) && /Kanal/.test(lage.erklaerung),
"sie nennt beide Arten");
/* Und sie muss den UNTERSCHIED benennen, nicht zweimal dasselbe:
Leute gegen Thema. */
ok(/Leute/.test(lage.erklaerung) && /Thema/.test(lage.erklaerung),
"und sagt, worin sie sich unterscheiden (Leute / Thema)");
ok(/einmal/.test(lage.erklaerung),
"dass es einen Kanal je Zustaendigkeit nur einmal gibt");
ok(!/antippen/i.test(lage.erklaerung),
"sie erklaert die WAHL, nicht den naechsten Schritt");
/* Umschalten auf Kanal: Der Satz darunter wechselt mit. */
await seite.click('#neu-art [data-art="kanal"]');
await seite.waitForTimeout(250);
const unterKanal = await seite.$eval("#neu-unter", (e) => e.textContent.trim());
ok(unterKanal !== lage.unter && /Zuständigkeit/.test(unterKanal),
`im Kanal-Modus steht etwas anderes darunter ("${unterKanal.slice(0, 48)}…")`);
if (process.env.SCHIRM) {
await seite.screenshot({ path: join(HIER_BILD, "pruef-chat-neu.png") });
}
await seite.click("#neu-zu");
await seite.waitForTimeout(250);
/* ---- REAGIEREN AUF DEM BILDSCHIRM (14.09.2026) ------------------
Das Recht und der Weg sind oben geprueft. Ob die Oberflaeche
beides verbindet, ist eine eigene Frage -- genau daran ist am
11.09. der Aufloesen-Knopf gescheitert: Recht richtig, Knopf
unsichtbar. */
const rRaum = await (await rufe(hand.keks, "/workspace/api/chat/gruppe",
{ mit: [idEins], name: "Reagieren sichtbar" }, "POST")).json();
await rufe(hand.keks, `/workspace/api/chat/raeume/${rRaum.raum_id}/nachrichten`,
{ text: "Reagier mal drauf" }, "POST");
await seite.goto(BASIS + `/workspace/chat.html?raum=${rRaum.raum_id}`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(1500);
const knopfDa = await seite.$$eval(".chat-nachricht__reag-knopf", (b) => b.length);
ok(knopfDa > 0, `der Knopf "reagieren" steht an der Nachricht (${knopfDa})`);
await seite.click(".chat-nachricht__reag-knopf");
await seite.waitForTimeout(600);
const tafel = await seite.$$eval(".reagieren-tafel__zeichen",
(b) => b.map((x) => x.textContent.trim()));
ok(tafel.length >= 4, `die Auswahl klappt auf (${tafel.length} Zeichen)`);
const tafelLage = await seite.evaluate(() => {
const t = document.querySelector(".reagieren-tafel");
const z = document.querySelector(".reagieren-tafel__zeichen");
const k = (e) => { if (!e) return null; const r = e.getBoundingClientRect();
const st = getComputedStyle(e);
return { b: Math.round(r.width), h: Math.round(r.height), d: st.display,
sicht: st.visibility, o: st.opacity, eltern: e.parentElement?.className }; };
return { tafel: k(t), zeichen: k(z) };
});
ok(tafelLage.zeichen && tafelLage.zeichen.b > 0 && tafelLage.zeichen.h > 0,
`die Zeichen haben eine Groesse (${JSON.stringify(tafelLage)})`);
if (process.env.SCHIRM) {
await seite.locator(".chat-nachricht").last().screenshot(
{ path: join(HIER_BILD, "pruef-chat-auswahl.png") });
}
await seite.click(".reagieren-tafel__zeichen");
await seite.waitForTimeout(900);
const stueck = await seite.evaluate(() => {
const b = document.querySelector(".reaktionen__stueck");
return b ? { text: b.textContent.trim(), selbst: b.dataset.selbst,
titel: b.title } : null;
});
ok(!!stueck, `unter der Nachricht steht die Reaktion ("${stueck?.text || "nichts"}")`);
ok(stueck?.selbst === "ja", "und sie ist als die eigene gekennzeichnet");
ok((stueck?.titel || "").includes("Vanessa"),
`im Titel steht, wer reagiert hat ("${(stueck?.titel || "").slice(0, 40)}…")`);
ok((await seite.$$(".reagieren-tafel")).length === 0,
"die Auswahl schliesst sich nach dem Klick von selbst");
if (process.env.SCHIRM) {
await seite.locator(".chat-nachricht").last().screenshot(
{ path: join(HIER_BILD, "pruef-chat-reaktion.png") });
}
/* ZWEITER KLICK NIMMT SIE ZURUECK -- an derselben Stelle, wo sie
steht. Ohne diese Zeile hiesse alles oben nur, dass man etwas
hinzufuegen kann. */
await seite.click(".reaktionen__stueck");
await seite.waitForTimeout(900);
ok((await seite.$$(".reaktionen__stueck")).length === 0,
"ein Klick darauf nimmt sie wieder weg");
/* ---- UND DER KNOPF SELBST -----------------------------------------
Bis hierher ist nur das RECHT geprueft (darf_aufloesen) und der
Weg dahinter. Ob die Oberflaeche beides verbindet, war unbewiesen
-- und genau dort sitzt der Fehler, den man am ehesten macht:
ein Knopf, der immer dasteht, oder einer, der nie erscheint.
Beide Faelle in einem Raum zu messen geht nicht; es braucht zwei.
Deshalb legt die rechte Hand hier eine eigene Gruppe an (sie darf)
und oeffnet danach ein Zweier-Gespraech (sie darf nicht). */
const eigene = await (await rufe(hand.keks, "/workspace/api/chat/gruppe",
{ mit: [idEins, idDrei], name: "Leitungsrunde" }, "POST")).json();
ok(Number.isInteger(eigene.raum_id),
`die rechte Hand legt eine eigene Gruppe an (#${eigene.raum_id})`);
await seite.goto(BASIS + `/workspace/chat.html?raum=${eigene.raum_id}`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(1500);
const beiEigener = await seite.evaluate(() => ({
aufloesen: document.getElementById("raum-aufloesen")?.hidden,
wegraeumen: document.getElementById("gespraech-weg")?.hidden,
titel: document.getElementById("verlauf-titel")?.textContent.trim(),
}));
ok(beiEigener.titel === "Leitungsrunde",
`die Gruppe ist offen ("${beiEigener.titel}")`);
ok(beiEigener.aufloesen === false,
"in ihrer eigenen Gruppe steht der Aufloesen-Knopf da");
ok(beiEigener.wegraeumen === false,
"und der Wegraeumen-Knopf ebenfalls -- beide Wege bleiben offen");
if (process.env.SCHIRM) {
await seite.locator(".chat__kopf--verlauf").screenshot(
{ path: join(HIER_BILD, "pruef-chat-kopf.png") });
}
const fremd = await (await rufe(hand.keks, "/workspace/api/chat/direkt",
{ mit: idEins }, "POST")).json();
await seite.goto(BASIS + `/workspace/chat.html?raum=${fremd.raum_id}`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(1500);
const beiDirekt = await seite.evaluate(() => ({
aufloesen: document.getElementById("raum-aufloesen")?.hidden,
wegraeumen: document.getElementById("gespraech-weg")?.hidden,
}));
ok(beiDirekt.aufloesen === true,
"im Zweier-Gespraech ist er weg");
ok(beiDirekt.wegraeumen === false,
"der Wegraeumen-Knopf bleibt aber -- die beiden sind nicht dasselbe");
await browser.close();
}
/* ---- Ende ------------------------------------------------------------- */
d.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log("");
console.log(`${geprueft} Pruefungen`);
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
process.exit(fehler ? 1 : 0);