Chat: Gruender duerfen ihre Gruppe oder ihren Kanal aufloesen — und der Umschalter erklaert sich

Zwei Wuensche von Filipe, beide am Neu-Fenster.

1) "ich will eine bessere erklaerung dafuer bitte."
Neben dem Umschalter "Gespraech | Kanal" stand ein Satz ueber das
ANTIPPEN von Personen -- also ueber den naechsten Schritt, nicht ueber
die Wahl, die gerade ansteht. Wer die beiden Woerter zum ersten Mal
sieht, erfuhr nirgends, was sie bedeuten.

Der Unterschied ist nicht "wenige/viele Leute", sondern WONACH der Raum
benannt ist: ein Gespraech nach den Menschen darin, ein Kanal nach
einem Thema. Daran haengt alles Weitere -- dass es einen Kanal je
Zustaendigkeit nur einmal gibt (eindeutiger Index, nachgesehen), dass
sein Name festliegt, dass die Teamleitung immer dabei ist
(kanaeleAngleichen, nachgesehen) und dass Leute wechseln koennen, ohne
dass der Raum ein anderer wird. Genau das steht jetzt da, und nichts
davon ist behauptet.

2) "die person die ihn oeffnet soll auch das recht haben das zu
loeschen und so dass es dan fuer jeden geloescht ist. aber nur die
person die es gruendet."

Ein EIGENER Weg (/ganz), kein Zusatzfeld am bestehenden. Es gibt jetzt
zwei Loeschknoepfe nebeneinander, und sie tun etwas sehr Verschiedenes:
Wegraeumen ist nur bei mir, Aufloesen ist fuer alle und endgueltig. Ein
vergessenes Feld waere genau dieser Unterschied gewesen. Aus demselben
Grund ein anderes Zeichen und eine eigene Warnfarbe -- zwei gleich
aussehende Papierkoerbe waeren eine Falle.

Nur der Gruender, woertlich: nicht die Teamleitung, nicht DogFather,
nicht wer `leitung` in der Gruppe hat. Nicht bei Zweier-Gespraechen --
dort gibt es keinen Gruender, und "niemand nimmt einem anderen die
Unterhaltung weg" gilt weiter.

Reihenfolge beim Loeschen ist nicht beliebig: erst das Live-Ereignis
(chatEreignis liest die Teilnehmer aus der Tabelle -- danach waere die
Liste leer), dann die Zeilen in EINER Transaktion, dann die Anhaenge
von der Platte. Umgekehrt haetten wir bei einem Ruecklauf Nachrichten,
die auf geloeschte Dateien zeigen.

WAS DIE PRUEFUNG GEFUNDEN HAT, BEVOR ES JEMAND GEMERKT HAETTE: Der
Knopf blieb unsichtbar, obwohl das Recht stimmte. Die Oberflaeche holt
den offenen Raum aus dem Nachrichten-Weg, nicht aus der Raumliste --
zwei Wege, ein Raumobjekt, und nur einer kannte das neue Feld. Die
Regel steht jetzt in darfAufloesen() und wird von allen dreien
benutzt: Liste, Nachrichten-Weg und der Loeschweg selbst.

Gefunden hat das die Pruefung, weil sie den KNOPF misst und nicht das
Recht dahinter. Haette sie nur `darf_aufloesen` geprueft, waere sie
gruen gewesen und der Knopf nie erschienen.

NEU: server/pruef-chat-aufloesen.mjs (59 Pruefungen). Sie misst am
BESTAND, nicht an der Antwort: ob der Raum wirklich aus der Datenbank
weg ist, ob keine Teilnehmerzeile liegen blieb, ob der Anhang von der
Platte verschwand -- und mit Gegenprobe, dass der Anhang-Ordner selbst
stehen bleibt. Ohne die waere "Datei ist weg" auch dann gruen, wenn es
sie nie gab; genau das ist beim ersten Lauf passiert (der Upload lief
ins 415, weil ich ihn als Formular statt roh geschickt hatte).

Dazu: Wegraeumen ist NICHT Aufloesen (Ben raeumt weg, Cem hat alles
noch), ein Zweier-Gespraech laesst sich gar nicht aufloesen, ein
Aussenstehender bekommt 404 statt 403, ein zweiter Versuch findet
nichts, und die Zustaendigkeit eines aufgeloesten Kanals wird wieder
frei (sonst haette der eindeutige Index sie dauerhaft blockiert).

Nebenbei: Die drei Kopfknoepfe schoben sich jeder einzeln mit
`margin-left: auto` nach rechts. Bei zwei sichtbaren teilen sich zwei
auto-Raender den freien Platz und reissen sie auseinander -- und WELCHE
sichtbar sind, entscheidet der Server. Jetzt schiebt ein Behaelter
einmal, die Knoepfe stehen beieinander, egal wie viele es sind.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-11 15:22:09 +02:00
co-authored by Claude Opus 5
parent 14ed467928
commit f23260dcbb
28 changed files with 1156 additions and 303 deletions
+521
View File
@@ -0,0 +1,521 @@
/* 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)");
}
/* =======================================================================
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);
/* ---- 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);