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]>
705 lines
33 KiB
JavaScript
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);
|