Filipes Kurznotiz vom 28.09.2026, von links nach rechts:
Kachel sichtbar -> geschlossen -> Vorbereitung -> Wartebereich/
Chat -> Countdown -> LIVE -> Reaction + Gaeste + Chat + PayPal
-> Ende
DREI STAENDE, NICHT SIEBEN
„Wartebereich" und „Countdown" sind keine eigenen Zustaende, sondern
das, was „Vorbereitung" auf dem Bildschirm TUT. Drei Staende, die
sich gegenseitig ausschliessen, sind pruefbar; sieben, von denen sich
vier ueberlappen, sind es nicht.
DAS VIDEO LAEUFT NICHT UEBER DIESEN SERVER
Naheliegend waere: Der Host spielt ab, alle sehen seinen Bildschirm.
Das waere aus zwei Gruenden falsch. Rechtlich ist ein
weitergesendetes YouTube-Video eine oeffentliche Wiedergabe -- genau
die Sache, fuer die Kanaele gesperrt werden. Und technisch kostet es
Bandbreite und Qualitaet.
Jeder Zuschauer laedt das Video deshalb SELBST. Uebertragen wird nur
der Spielstand: Kennung, laeuft/pausiert, Sekunde. Das sind ein paar
Byte, jeder sieht es in voller Qualitaet, und alle sind auf derselben
Sekunde. Nachgefuehrt wird erst ab anderthalb Sekunden Abweichung --
ein Player, dem man jede Sekunde eine neue Position gibt, ruckelt
sichtbar.
DIE KAMERAS LAUFEN DIREKT VON MENSCH ZU MENSCH
Ueber denselben Weg wie die Anrufe im Haus (seit 18.09.), nur mit
mehr Empfaengern. Das hat eine Grenze, und sie ist gerechnet, nicht
geraten: Bei 360p und rund 350 kbit/s sind zwoelf Zuschauer etwa
4 Mbit/s Upload beim Host. Darueber schaltet die Sendung von selbst
auf Ton um -- wer keine Kamera mehr bekommt, hoert alles, sieht das
Video und kann schreiben. Ehrlicher als eine Verbindung, die stockt,
und sichtbar im Regiepult.
Heute sind es elf Menschen im ganzen Haus (gemessen: 1 admin, 1 hand,
1 linke, 4 modi, 4 gast). Die Grenze ist weit weg -- sie steht
trotzdem drin, weil sie sonst erst auffaellt, wenn es zu spaet ist.
DIE SEITE IST ANDERS GEBAUT ALS JEDE ANDERE IM HAUS
Ueberall sonst: Kacheln, Karten, Listen -- man liest, entscheidet,
geht wieder. Hier sitzt man. Eine Stunde, mit anderen, auf EINE
Sache schauend. Deshalb kein Raster, sondern ein SAAL: grosse Flaeche
fuer das Video, Kamerabilder als schwebende Fenster darueber, der
Chat als Schiene daneben. Die Seite scrollt nicht -- ein Video, das
beim Tippen im Chat nach oben rutscht, ist der schnellste Weg, dass
jemand aufhoert zu schreiben.
Fuer den Host ein REGIEPULT: vier senkrechte Regler nebeneinander wie
an einem Mischpult, darueber die Sendung, daneben Gaeste und
Anordnung, unten drei grosse Knoepfe. Es SCHIEBT den Saal, es deckt
ihn nicht zu.
Die Kachel traegt ihren Zustand als Farbe: grau geschlossen,
bernstein in Vorbereitung, rot auf Sendung. Keine Ton-Nummer -- der
Farbraum ist bei 46 voll, und sie braucht auch keine.
PAYPAL: EINE QUELLE
Der Knopf nimmt den Weg, der auf der Unterstuetzen-Seite hinterlegt
ist -- derselbe Eintrag, dieselbe Pflege. Ist dort nichts eingetragen
oder steht er auf unsichtbar, erscheint hier kein Knopf. Eine
geratene Adresse ist an dieser Stelle die gefaehrlichste aller
Abkuerzungen.
=======================================================================
ACHT FEHLER, DIE OHNE MESSUNG LIVE GEGANGEN WAEREN
=======================================================================
1. `data-live` WAR SCHON VERGEBEN. Die Draussen-Kachel bekommt es,
sobald Filipe auf Twitch sendet. Meine Regel haette ihr waehrend
jedes Streams die Farbe genommen -- genau dann, wenn sie wichtig
ist. Heisst jetzt `data-sendung`, und pruef-reaktion haelt beides
auseinander.
2. DIE INHALTSRICHTLINIE HAETTE YOUTUBE LAUTLOS GESPERRT. Die Datei
warnt an genau dieser Stelle selbst davor: Am 27.08.2026 hat
`frame-src 'none'` den Musik-Knopf stillgelegt -- der Knopf
reagierte, das Feld ging auf, und wo die Player sein sollten,
blieb es leer. Hier waere das Ergebnis eine schwarze Leinwand vor
Publikum gewesen. youtube-nocookie.com fuer den Rahmen (setzt keine
Werbekennungen), www.youtube.com fuer die Einbett-API,
i.ytimg.com fuer die Vorschaubilder.
3. KAMERA UND MIKROFON WAREN GESPERRT. Dieselbe Falle, vor der
index.js selbst warnt -- und die am 18.09. schon einmal zugeschlagen
hat. Die Ausnahme ist jetzt eine benannte MENGE statt eines zweiten
Sonderfalls, und pruef-kamera-richtlinie.mjs haelt sie GEGEN DEN
QUELLTEXT: Welche Seite laedt ein Skript, das getUserMedia
aufruft? Genau die muss drinstehen -- und keine andere. Eine
Liste, die abgeleitet wird, kann nicht veralten.
4. ZWEI ANRUFE AN DIESELBE PERSON. Zwischen `await kameraHolen()` und
dem Anlegen der Verbindung laeuft alles andere weiter; jeder Takt
sagte wieder „den kenne ich noch nicht". Der Empfaenger antwortete
auf beide Angebote, und die zweite Antwort traf eine Verbindung,
die laengst stand.
5. DAS ANGEBOT GING HINAUS, BEVOR DER EMPFAENGER ZUHOEREN KONNTE.
Gemessen:
[spur] an [3] reaktion_signal | offen: [2,1]
...
[spur] Strom auf fuer 3 Lenny
Die Anmeldung ist ein gewoehnlicher Abruf und sofort durch, der
Ereignisstrom eine stehende Verbindung. Der Host erfaehrt vom
Neuankoemmling also zuverlaessig, BEVOR der zuhoeren kann.
Die Richtung ist jetzt umgedreht: Wer bereit ist, BITTET um den
Anruf -- er ist der Einzige, der das sicher weiss. Dazu ein
eigener, schneller Takt (2,5 s) und eine Ruecknahme, wenn ein
Angebot bei niemandem ankommt.
6. EIN VIDEO MIT TON STARTET NICHT VON ALLEIN. `videoWidth` war 640,
das Bild kam also an -- und das Fenster blieb schwarz. Kein
Fehler, keine Meldung, es passiert einfach nichts. Die Kameras
starten jetzt stumm (stumm darf losgehen), ein Knopf schaltet den
Ton frei, und die erste Beruehrung der Seite tut es ohnehin.
7. DIE LADE AM HANDY GING NICHT AUF. Gemessen: ein 390x775 grosser
Saal mit 219 px Video und 556 px Leere darunter. Statt den Knopf
zu reparieren, ist die Lade weg -- unter Kopfleiste und Video
bleiben auf einem Telefon rund 550 px, das ist mehr Chat, als eine
Lade je zeigen wuerde. Ein Zustand weniger ist besser als ein
Zustand, der funktioniert.
8. `sendBeacon` KANN NUR POST. Beim Schliessen des Fensters wird ein
gewoehnlicher Abruf abgebrochen; mein DELETE waere nie angekommen,
und jeder haette zwei Minuten lang als anwesend gegolten.
Dazu drei Funde der Hauspruefungen, alle von mir verursacht:
17 Schriftgroessen unter der Lesbarkeitsgrenze von 11,5 px, elf
Maschinenworte ohne deutschen Satz, und ein Aufbewahrungseintrag ohne
Rechtsgrundlage.
=======================================================================
GEMESSEN
pruef-reaktion 74 Punkte, 0 Fehler (11 Abschnitte)
pruef-kamera-richtlinie 10 Punkte, 0 Fehler (neu, abgeleitet)
mess-reaktion beide Kameras kommen an, 640 px, laufen --
beim Zuschauer UND beim Host. Diese Messung
hat einen Rueckgabewert: Alles andere kann
gruen sein, und trotzdem sitzt jeder vor
einem schwarzen Rechteck.
pruef-handy 180 (vorher 177), pruef-notizen 79,
pruef-aufbewahrung 45, pruef-meldungen 8, pruef-css-klassen 33,
pruef-struktur 35, pruef-crew-adresse 161,
pruef-haus-trennung 100, pruef-start-ansicht 160 -- alle 0 Fehler.
579 lines
26 KiB
JavaScript
579 lines
26 KiB
JavaScript
/* =====================================================================
|
|
DIE REACTION (28.09.2026)
|
|
|
|
Geprueft wird der ganze Weg aus Filipes Kurznotiz:
|
|
|
|
Kachel sichtbar -> geschlossen -> Vorbereitung ->
|
|
Wartebereich/Chat -> Countdown -> LIVE ->
|
|
Reaction + Gaeste + Chat + PayPal -> Ende
|
|
|
|
---------------------------------------------------------------------
|
|
WARUM DIESE PRUEFUNG UEBER DIE HAUSADRESSE SPRICHT
|
|
|
|
Die Community meldet sich ueber den STILLEN ZUGANG an -- ein Modi
|
|
oder ein Zuschauer tippt auf irgendeine Kachel und gibt seinen Code
|
|
ein, der Code allein entscheidet. Dieser Weg ist auf einer
|
|
Pruefadresse (127.0.0.1) absichtlich abgeschaltet: Er wuerde dort
|
|
eine messbare Zeitspur hinterlassen, und genau die soll er nicht.
|
|
|
|
Folge: Ohne Host-Kopfzeile koennte sich hier kein einziger
|
|
Zuschauer anmelden -- und eine Pruefung, die ihren Hauptdarsteller
|
|
nicht hereinbekommt, prueft nichts. `fetch` kann die Kopfzeile
|
|
nicht setzen, deshalb node:http.
|
|
|
|
Aufrufen mit: node server/pruef-reaktion.mjs
|
|
===================================================================== */
|
|
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { request } from "node:http";
|
|
|
|
const HIER = dirname(fileURLToPath(import.meta.url));
|
|
const ordner = mkdtempSync(join(tmpdir(), "ws-reakt-"));
|
|
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
|
|
|
import { notbremse } from "./helfer-notbremse.mjs";
|
|
const { eigenerPort } = await import("./helfer-port.mjs");
|
|
const PORT = await eigenerPort(import.meta, "die Reaction-Pruefung");
|
|
|
|
process.env.PORT = `${PORT}`;
|
|
process.env.SITE_ACCESS_SECRET = "lokaler-test";
|
|
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
|
|
|
|
const express = (await import("express")).default;
|
|
const ec = express.response.cookie;
|
|
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
|
|
await import("./index.js");
|
|
notbremse(260_000, "pruef-reaktion");
|
|
await new Promise((r) => setTimeout(r, 900));
|
|
|
|
let fehler = 0, geprueft = 0;
|
|
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
|
const melde = (t) => console.log(t);
|
|
process.on("exit", () => { try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } });
|
|
|
|
const CREW = "crew.dogfather-universe.com";
|
|
const AGENTUR = "workspace.dogfather-universe.com";
|
|
|
|
/* ---------- Zugaenge anlegen -------------------------------------------- */
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
|
|
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
|
const nun = new Date().toISOString();
|
|
const kennSchluessel = randomBytes(32).toString("hex");
|
|
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
|
|
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
|
|
.run("code_kennung_schluessel", kennSchluessel, nun);
|
|
|
|
/* 200 Tage alt: Der Treff hat Stufen, und wer neu ist, darf weniger.
|
|
Fuer die Reaction spielt das keine Rolle -- aber ein Zugang von
|
|
heute waere ein Sonderfall, den niemand im Blick hat. */
|
|
const ALT = new Date(Date.now() - 200 * 86400000).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,"
|
|
+ " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)")
|
|
.run(name, rolle, hash, salt, 32768,
|
|
createHmac("sha256", kennSchluessel).update(code).digest("hex"), ALT);
|
|
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
|
}
|
|
const ROLLEN = [["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"],
|
|
["modi", "Miss"], ["gast", "Nachtfalter"], ["gast2", "Lenny"], ["creator", "Luna"]];
|
|
const CODES = {}; const IDS = {};
|
|
for (const [i, [rolle, name]] of ROLLEN.entries()) {
|
|
CODES[rolle] = `CODE-R${String(i).padStart(2, "0")}-000001`;
|
|
IDS[rolle] = anlegen(name, rolle === "gast2" ? "gast" : rolle, CODES[rolle]);
|
|
}
|
|
d.close();
|
|
|
|
/* ---------- Sprechen mit Hausadresse ------------------------------------ */
|
|
function roh(host, pfad, { method = "GET", keks = "", rumpf = null, origin } = {}) {
|
|
return new Promise((fertig, schief) => {
|
|
const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf));
|
|
const a = request({
|
|
host: "127.0.0.1", port: PORT, path: pfad, method,
|
|
headers: {
|
|
Host: host,
|
|
Origin: origin === undefined ? `https://${host}` : origin,
|
|
...(keks ? { Cookie: keks } : {}),
|
|
...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
|
|
},
|
|
}, (antwort) => {
|
|
const teile = [];
|
|
antwort.on("data", (s) => teile.push(s));
|
|
antwort.on("end", () => {
|
|
const text = Buffer.concat(teile).toString("utf8");
|
|
let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ }
|
|
fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers });
|
|
});
|
|
});
|
|
a.on("error", schief);
|
|
if (daten) a.write(daten);
|
|
a.end();
|
|
});
|
|
}
|
|
|
|
async function anmelden(rolle, host) {
|
|
const a = await roh(host, "/workspace/api/anmelden", {
|
|
method: "POST", rumpf: { rolle: rolle === "gast2" ? "gast" : rolle,
|
|
code: CODES[rolle], alter_ok: true },
|
|
});
|
|
const keks = [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
|
|
return a.code === 200 && keks ? keks : null;
|
|
}
|
|
|
|
const k = {};
|
|
for (const [rolle] of ROLLEN) k[rolle] = await anmelden(rolle, CREW);
|
|
|
|
/* =======================================================================
|
|
1. ALLE KOMMEN HEREIN — auch die Community
|
|
======================================================================= */
|
|
melde("=== 1. Wer die Reaction sieht ===");
|
|
{
|
|
const fehlend = ROLLEN.map(([r]) => r).filter((r) => r !== "creator" && !k[r]);
|
|
ok(fehlend.length === 0,
|
|
`alle Rollen des Hauses sind angemeldet (${fehlend.length ? "fehlt: " + fehlend.join(", ") : "5 von 5"})`);
|
|
|
|
for (const rolle of ["admin", "hand", "linke", "modi", "gast"]) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion", { keks: k[rolle] });
|
|
ok(a.code === 200 && a.j?.stand === "zu",
|
|
`${rolle}: sieht die Lage und sie ist „zu" (${a.code}/${a.j?.stand})`);
|
|
}
|
|
const s = await roh(CREW, "/workspace/reaktion.html", { keks: k.gast });
|
|
ok(s.code === 200, `die Seite steht der Community offen (${s.code})`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
2. DAS ANDERE HAUS SIEHT NICHTS
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 2. Die Agentur kennt sie nicht ===");
|
|
{
|
|
const kA = await anmelden("admin", AGENTUR);
|
|
ok(!!kA, "DogFather meldet sich auch auf der Agenturadresse an");
|
|
const a = await roh(AGENTUR, "/workspace/api/reaktion", { keks: kA });
|
|
ok(a.code === 404, `und bekommt dort 404 (${a.code})`);
|
|
const s = await roh(AGENTUR, "/workspace/reaktion.html", { keks: kA });
|
|
ok(s.code === 404, `auch die Seite gibt es dort nicht (${s.code})`);
|
|
|
|
/* Das andere Ende derselben Regel: Auf einer Pruefadresse hat
|
|
niemand ein Haus, und die Schranke darf das nicht mit dem
|
|
fremden Haus verwechseln. Genau dieser Fehler ist am 27.09.
|
|
beim Notizblock passiert. */
|
|
const kP = await anmelden("admin", "127.0.0.1");
|
|
const p = await roh("127.0.0.1", "/workspace/api/reaktion", { keks: kP });
|
|
ok(p.code === 200, `auf der Pruefadresse ist sie offen (${p.code}) -- ohne Haus wird nicht gesperrt`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
3. NUR DER HOST STELLT EIN
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 3. Wer die Sendung fuehrt ===");
|
|
{
|
|
for (const rolle of ["hand", "linke", "modi", "gast"]) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k[rolle], rumpf: { titel: "Nicht du" },
|
|
});
|
|
ok(a.code === 403, `${rolle}: darf nicht einstellen (${a.code})`);
|
|
}
|
|
const a = await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin,
|
|
rumpf: { titel: "Wir schauen den Trailer", video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" },
|
|
});
|
|
ok(a.code === 200 && a.j?.video === "dQw4w9WgXcQ",
|
|
`DogFather schon -- und aus der Adresse wird die Kennung (${a.j?.video})`);
|
|
}
|
|
|
|
/* ---- Die Kennung kommt aus jeder Form, die man wirklich einfuegt ---- */
|
|
{
|
|
const { videoKennung } = await import("./workspace-reaktion.js");
|
|
const faelle = [
|
|
["https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s", "dQw4w9WgXcQ"],
|
|
["https://youtu.be/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
|
|
["https://www.youtube.com/embed/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
|
|
["https://www.youtube.com/shorts/dQw4w9WgXcQ", "dQw4w9WgXcQ"],
|
|
["dQw4w9WgXcQ", "dQw4w9WgXcQ"],
|
|
];
|
|
const sitzt = faelle.filter(([roh2, soll]) => videoKennung(roh2) === soll).length;
|
|
ok(sitzt === faelle.length, `alle ${faelle.length} Formen der Adresse ergeben die Kennung`);
|
|
/* Gegenprobe: Was keine Kennung ist, darf auch keine werden -- sonst
|
|
stuende vor Publikum ein schwarzes Rechteck. */
|
|
const mist = ["", "https://vimeo.com/12345", "kein-video", "https://www.youtube.com/watch?v=zu_kurz"];
|
|
ok(mist.every((m) => videoKennung(m) === ""),
|
|
"und was keine ist, wird auch keine (Gegenprobe)");
|
|
const b = await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin, rumpf: { video: "https://vimeo.com/12345" },
|
|
});
|
|
ok(b.code === 400 && b.j?.fehler === "video_unklar",
|
|
`eine fremde Adresse wird abgelehnt (${b.code}/${b.j?.fehler})`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
4. DER ABLAUF
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 4. zu -> vorbereitung -> live -> zu ===");
|
|
{
|
|
const a = await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" },
|
|
});
|
|
ok(a.code === 200 && a.j?.stand === "vorbereitung", `Vorbereitung geht (${a.j?.stand})`);
|
|
|
|
const b = await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "erfunden" },
|
|
});
|
|
ok(b.code === 400, `ein erfundener Stand wird abgelehnt (${b.code})`);
|
|
|
|
/* Im Wartebereich darf schon geschrieben werden -- das ist der
|
|
halbe Sinn der Vorbereitung. */
|
|
const c = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast, rumpf: { text: "Bin schon da!" },
|
|
});
|
|
ok(c.code === 201, `im Wartebereich ist der Chat offen (${c.code})`);
|
|
|
|
const e = await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "live" },
|
|
});
|
|
ok(e.code === 200 && e.j?.stand === "live" && e.j?.gestartet_am,
|
|
`auf Sendung -- mit Startzeit (${e.j?.stand})`);
|
|
}
|
|
|
|
/* ---- Ohne Video kein Live ---- */
|
|
{
|
|
await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "zu" } });
|
|
await roh(CREW, "/workspace/api/reaktion", { method: "PUT", keks: k.admin, rumpf: { titel: "Ohne", video: "" } });
|
|
const a = await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "live" },
|
|
});
|
|
ok(a.code === 400 && a.j?.fehler === "kein_video",
|
|
`ohne Video laesst sich nicht senden (${a.code}/${a.j?.fehler})`);
|
|
/* und danach wieder aufbauen */
|
|
await roh(CREW, "/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin,
|
|
rumpf: { titel: "Wir schauen den Trailer", video: "dQw4w9WgXcQ" },
|
|
});
|
|
await roh(CREW, "/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "live" } });
|
|
}
|
|
|
|
/* =======================================================================
|
|
5. WER DABEI IST
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 5. Dabei sein ===");
|
|
{
|
|
for (const rolle of ["gast", "gast2", "modi"]) {
|
|
await roh(CREW, "/workspace/api/reaktion/dabei", { method: "POST", keks: k[rolle] });
|
|
}
|
|
const a = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
ok(a.j?.dabei === 3, `drei sind dabei (${a.j?.dabei})`);
|
|
|
|
ok(Array.isArray(a.j?.zuschauer) && a.j.zuschauer.length === 3,
|
|
"DogFather sieht, WER dabei ist");
|
|
|
|
const z = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
|
|
ok(z.j?.zuschauer === null,
|
|
"ein Zuschauer bekommt die Namensliste NICHT");
|
|
ok(z.j?.dabei === 3, `die Zahl sieht er aber (${z.j?.dabei})`);
|
|
|
|
/* Jeder Dabeisitzende hat einen Kameraplatz bekommen -- es sind
|
|
erst drei, und zwoelf waeren erlaubt. */
|
|
ok(a.j.zuschauer.every((x) => x.sicht === true),
|
|
"alle drei haben einen Kameraplatz (bei 12 Plaetzen)");
|
|
|
|
/* Abmelden: der Weg, den `sendBeacon` beim Schliessen benutzt --
|
|
POST, nicht DELETE. Beides muss gehen. */
|
|
const w1 = await roh(CREW, "/workspace/api/reaktion/dabei-weg", { method: "POST", keks: k.modi });
|
|
ok(w1.code === 200, `abmelden per POST geht (fuer sendBeacon) (${w1.code})`);
|
|
const n1 = await roh(CREW, "/workspace/api/reaktion", { keks: k.admin });
|
|
ok(n1.j?.dabei === 2, `danach sind es zwei (${n1.j?.dabei})`);
|
|
await roh(CREW, "/workspace/api/reaktion/dabei", { method: "POST", keks: k.modi });
|
|
}
|
|
|
|
/* =======================================================================
|
|
6. GAESTE
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 6. Ein bis zwei Gaeste ===");
|
|
{
|
|
const { GAESTE_MAX } = await import("./reaktion-tabellen.js");
|
|
ok(GAESTE_MAX === 2, `zwei Plaetze, wie in der Notiz (${GAESTE_MAX})`);
|
|
|
|
const a = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast },
|
|
});
|
|
ok(a.code === 201 && a.j?.gaeste?.length === 1, `Gast 1 dazu (${a.code})`);
|
|
ok(a.j.gaeste[0].platz === 1, `er bekommt Platz 1 (${a.j?.gaeste?.[0]?.platz})`);
|
|
|
|
const b = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 },
|
|
});
|
|
ok(b.code === 201 && b.j?.gaeste?.length === 2, `Gast 2 dazu (${b.code})`);
|
|
|
|
const c = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.modi },
|
|
});
|
|
ok(c.code === 409 && c.j?.fehler === "gaeste_voll",
|
|
`ein dritter passt nicht (${c.code}/${c.j?.fehler})`);
|
|
|
|
/* EIN GAST BEKOMMT DIE NAMENSLISTE -- und das ist kein Zufall:
|
|
Ohne sie wuesste er nicht, wen er anrufen soll, und sein Bild
|
|
kaeme bei keinem Zuschauer an. */
|
|
const g = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast });
|
|
ok(Array.isArray(g.j?.zuschauer),
|
|
"wer auf der Buehne steht, bekommt die Liste (sonst sieht ihn niemand)");
|
|
ok(g.j?.ich?.gast === true, "und er weiss, dass er Gast ist");
|
|
|
|
/* Stummschalten gilt fuer alle -- das steht am Server, nicht im
|
|
Browser des Hosts. */
|
|
const s = await roh(CREW, `/workspace/api/reaktion/gast/${IDS.gast}/stumm`, {
|
|
method: "PUT", keks: k.admin, rumpf: { stumm: true },
|
|
});
|
|
ok(s.code === 200 && s.j?.gaeste?.find((x) => x.id === IDS.gast)?.stumm === true,
|
|
"stummschalten wirkt");
|
|
const s2 = await roh(CREW, "/workspace/api/reaktion", { keks: k.gast2 });
|
|
ok(s2.j?.gaeste?.find((x) => x.id === IDS.gast)?.stumm === true,
|
|
"und alle anderen sehen es auch (nicht nur der Host)");
|
|
|
|
/* Ein Gast kann sich nicht selbst holen oder entfernen. */
|
|
const f = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.gast, rumpf: { person_id: IDS.modi },
|
|
});
|
|
ok(f.code === 403, `ein Gast holt niemanden dazu (${f.code})`);
|
|
|
|
const w = await roh(CREW, `/workspace/api/reaktion/gast/${IDS.gast2}`, {
|
|
method: "DELETE", keks: k.admin,
|
|
});
|
|
ok(w.code === 200 && w.j?.gaeste?.length === 1, `entfernen geht (${w.code})`);
|
|
|
|
/* Der frei gewordene Platz wird WIEDERVERWENDET. Mit „Anzahl + 1"
|
|
bliebe Platz 1 fuer immer leer, sobald einmal jemand gegangen
|
|
ist -- und das faellt erst im dritten Stream auf. */
|
|
const neu = await roh(CREW, "/workspace/api/reaktion/gast", {
|
|
method: "POST", keks: k.admin, rumpf: { person_id: IDS.modi },
|
|
});
|
|
const plaetze = (neu.j?.gaeste || []).map((x) => x.platz).sort();
|
|
ok(plaetze.join(",") === "1,2",
|
|
`der frei gewordene Platz wird wiederverwendet (${plaetze.join(",")})`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
7. DER LIVE-CHAT UND SEINE MODERATION
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 7. Chat und Moderation ===");
|
|
let fremderBeitrag = 0;
|
|
{
|
|
const a = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast2, rumpf: { text: "Das ist gut!" },
|
|
});
|
|
ok(a.code === 201 && a.j?.id > 0, `schreiben geht (${a.code})`);
|
|
fremderBeitrag = a.j.id;
|
|
|
|
const leer = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast2, rumpf: { text: " " },
|
|
});
|
|
ok(leer.code === 400, `ein leerer Beitrag wird abgelehnt (${leer.code})`);
|
|
|
|
const liste = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.gast });
|
|
ok(liste.code === 200 && liste.j.beitraege.some((b) => b.id === fremderBeitrag),
|
|
"und steht in der Liste");
|
|
|
|
/* Ein Zuschauer nimmt einen FREMDEN Beitrag nicht weg. */
|
|
const nein = await roh(CREW, `/workspace/api/reaktion/chat/${fremderBeitrag}`, {
|
|
method: "DELETE", keks: k.gast,
|
|
});
|
|
ok(nein.code === 403, `ein Zuschauer nimmt fremde Beitraege nicht weg (${nein.code})`);
|
|
|
|
/* Sein eigener geht. */
|
|
const eigen = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast, rumpf: { text: "ups" },
|
|
});
|
|
const weg = await roh(CREW, `/workspace/api/reaktion/chat/${eigen.j.id}`, {
|
|
method: "DELETE", keks: k.gast,
|
|
});
|
|
ok(weg.code === 200, `den eigenen schon (${weg.code})`);
|
|
|
|
/* Ein Modi moderiert. */
|
|
const mod = await roh(CREW, `/workspace/api/reaktion/chat/${fremderBeitrag}`, {
|
|
method: "DELETE", keks: k.modi,
|
|
});
|
|
ok(mod.code === 200, `ein Modi moderiert (${mod.code})`);
|
|
|
|
const nach = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.gast });
|
|
ok(!nach.j.beitraege.some((b) => b.id === fremderBeitrag),
|
|
"und der Beitrag ist aus der Liste");
|
|
|
|
/* WEGGENOMMEN UND NICHT GELOESCHT -- wer moderiert, soll spaeter
|
|
sagen koennen, was er weggenommen hat. */
|
|
const { DatabaseSync: DB2 } = await import("node:sqlite");
|
|
const d2 = new DB2(process.env.WORKSPACE_DB, { readOnly: true });
|
|
const zeile = d2.prepare("SELECT weg_am, weg_von, text FROM reaktion_chat WHERE id = ?")
|
|
.get(fremderBeitrag);
|
|
d2.close();
|
|
ok(!!zeile && !!zeile.weg_am && zeile.weg_von === IDS.modi && zeile.text === "Das ist gut!",
|
|
"er steht noch da -- mit Zeitpunkt und Namen dessen, der ihn genommen hat");
|
|
}
|
|
|
|
/* ---- Die Schreibbremse ---- */
|
|
{
|
|
const { SCHREIB_BREMSE } = await import("./reaktion-tabellen.js");
|
|
let gebremst = 0;
|
|
for (let i = 0; i < SCHREIB_BREMSE + 6; i++) {
|
|
const a = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.linke, rumpf: { text: "spam " + i },
|
|
});
|
|
if (a.code === 429) gebremst++;
|
|
}
|
|
ok(gebremst > 0, `die Schreibbremse greift (${gebremst} von ${SCHREIB_BREMSE + 6} abgewiesen)`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
8. DIE VERBINDUNGSDATEN GEHEN NUR AN LEUTE, DIE DABEI SIND
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 8. Kameras: der Hinweg ===");
|
|
{
|
|
const a = await roh(CREW, "/workspace/api/reaktion/signal", {
|
|
method: "POST", keks: k.admin, rumpf: { an: IDS.gast, daten: { typ: "angebot" } },
|
|
});
|
|
ok(a.code === 200, `an jemanden, der dabei ist, geht es (${a.code})`);
|
|
|
|
/* Die Creatorin ist nicht dabei -- und im anderen Haus. An sie darf
|
|
nichts gehen, sonst waere die Reaction ein Weg, jemanden
|
|
anzuklingeln. */
|
|
const b = await roh(CREW, "/workspace/api/reaktion/signal", {
|
|
method: "POST", keks: k.admin, rumpf: { an: IDS.creator, daten: { typ: "angebot" } },
|
|
});
|
|
ok(b.code === 409 && b.j?.fehler === "nicht_dabei",
|
|
`an jemanden, der nicht dabei ist, nicht (${b.code}/${b.j?.fehler})`);
|
|
|
|
const c = await roh(CREW, "/workspace/api/reaktion/signal", {
|
|
method: "POST", keks: k.admin, rumpf: { an: IDS.gast },
|
|
});
|
|
ok(c.code === 400, `ohne Inhalt wird abgelehnt (${c.code})`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
9. DAS ENDE RAEUMT AUF
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 9. Beenden ===");
|
|
{
|
|
const vorher = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.admin });
|
|
ok(vorher.j.beitraege.length > 0, `vor dem Ende steht etwas im Chat (${vorher.j.beitraege.length})`);
|
|
|
|
const a = await roh(CREW, "/workspace/api/reaktion/stand", {
|
|
method: "POST", keks: k.admin, rumpf: { stand: "zu" },
|
|
});
|
|
ok(a.code === 200 && a.j?.stand === "zu", `beenden geht (${a.j?.stand})`);
|
|
ok(a.j.gaeste.length === 0, "die Gaeste sind entlassen");
|
|
ok(a.j.dabei === 0, "und niemand sitzt mehr im Saal");
|
|
|
|
const nach = await roh(CREW, "/workspace/api/reaktion/chat", { keks: k.admin });
|
|
ok(nach.j.beitraege.length === 0,
|
|
"der Live-Chat dieser Sendung ist abgeraeumt -- die naechste faengt nicht mit den Rufen der letzten an");
|
|
|
|
/* Was gelaufen ist, steht im Verlauf. */
|
|
const { DatabaseSync: DB3 } = await import("node:sqlite");
|
|
const d3 = new DB3(process.env.WORKSPACE_DB, { readOnly: true });
|
|
const v = d3.prepare("SELECT * FROM reaktion_verlauf ORDER BY id DESC LIMIT 1").get();
|
|
d3.close();
|
|
ok(!!v && v.titel === "Wir schauen den Trailer" && v.beitraege > 0,
|
|
`die Sendung steht im Verlauf (${v?.titel}, ${v?.beitraege} Beitraege)`);
|
|
|
|
/* Bei „zu" ist der Chat zu -- sonst redeten die Leute in einen
|
|
leeren Saal. */
|
|
const c = await roh(CREW, "/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast, rumpf: { text: "Hallo?" },
|
|
});
|
|
ok(c.code === 409 && c.j?.fehler === "geschlossen",
|
|
`und schreiben geht nicht mehr (${c.code}/${c.j?.fehler})`);
|
|
}
|
|
|
|
/* =======================================================================
|
|
10. DIE KACHEL, DIE RECHTE UND DIE INHALTSRICHTLINIE
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 10. Was um die Seite herum stimmen muss ===");
|
|
{
|
|
const { SEITEN } = await import("./rechte.js");
|
|
const erlaubt = SEITEN["/workspace/reaktion.html"] || [];
|
|
ok(erlaubt.includes("gast"),
|
|
`die Community steht in der Rechtetafel (${erlaubt.join(", ")})`);
|
|
|
|
/* Die Kachel. Gesucht wird sie am ZIEL und nicht am Namen -- Namen
|
|
haben sich in diesem Haus schon zweimal geaendert. */
|
|
const w = await import("./workspace.js");
|
|
const alle = w.bereicheFuer({ id: IDS.gast, rolle: "gast", name: "Nachtfalter" }, CREW);
|
|
const kachel = (alle || []).find((b) => b.ziel === "reaktion.html");
|
|
ok(!!kachel, "die Community hat die Reaction-Kachel");
|
|
ok(kachel?.live === true,
|
|
"sie traegt das Merkmal `live` statt einer Ton-Nummer (der Farbraum ist bei 46 voll)");
|
|
ok(!kachel?.ton, "und ausdruecklich KEINE Ton-Nummer");
|
|
|
|
/* DAS MERKMAL HEISST NICHT `data-live`. Das ist seit dem 21.09.
|
|
vergeben -- die Draussen-Kachel bekommt es, sobald Filipe auf
|
|
Twitch sendet. Haetten beide denselben Namen, waere der
|
|
Draussen-Kachel waehrend jedes Streams die Farbe abhanden
|
|
gekommen. */
|
|
const startJs = readFileSync(join(HIER, "..", "workspace", "assets", "js", "start.js"), "utf8");
|
|
ok(/dataset\.sendung = 'ja'/.test(startJs),
|
|
"die Wand setzt `data-sendung` (und nicht `data-live`, das ist vergeben)");
|
|
const startCss = readFileSync(join(HIER, "..", "workspace", "assets", "css", "start.css"), "utf8");
|
|
ok(!/\.kachel\[data-live="ja"\]\s*\{\s*--ton/.test(startCss),
|
|
"und kein Stil greift der Draussen-Kachel ihre Farbe ab");
|
|
|
|
/* DIE INHALTSRICHTLINIE. Ohne YouTube darin startet die Sendung,
|
|
alle sind da, und die Leinwand bleibt schwarz -- ohne
|
|
Fehlermeldung, nur ein Eintrag in der Browser-Konsole. Genau so
|
|
ist am 27.08.2026 der Musik-Knopf ausgefallen. */
|
|
const seite = await roh(CREW, "/workspace/reaktion.html", { keks: k.gast });
|
|
const regel = String(seite.kopf["content-security-policy"] || "");
|
|
ok(/frame-src[^;]*youtube-nocookie\.com/.test(regel),
|
|
"der YouTube-Rahmen ist erlaubt");
|
|
ok(/script-src[^;]*www\.youtube\.com/.test(regel),
|
|
"und die Einbett-API darf geladen werden");
|
|
ok(/img-src[^;]*ytimg\.com/.test(regel),
|
|
"und die Vorschaubilder auch");
|
|
}
|
|
|
|
/* =======================================================================
|
|
11. KEIN WEG HEISST ZWEIMAL GLEICH
|
|
======================================================================= */
|
|
melde("");
|
|
melde("=== 11. Keine doppelten Wege ===");
|
|
{
|
|
/* Beim Notizblock hat genau das eine Stunde gekostet:
|
|
`DELETE /workspace/api/notizen/:id` gab es schon woanders, und
|
|
express nimmt schweigend die erste passende Route. */
|
|
const { default: app } = await import("./index.js");
|
|
const wege = [];
|
|
const sammeln = (stapel, vorn = "") => {
|
|
for (const s of stapel) {
|
|
if (s.route) {
|
|
for (const m of Object.keys(s.route.methods)) {
|
|
wege.push(`${m.toUpperCase()} ${vorn}${s.route.path}`);
|
|
}
|
|
} else if (s.handle?.stack) {
|
|
sammeln(s.handle.stack, vorn);
|
|
}
|
|
}
|
|
};
|
|
sammeln(app._router.stack);
|
|
ok(wege.length > 300, `${wege.length} Wege im Baum gefunden`);
|
|
const doppelt = wege.filter((x, i) => wege.indexOf(x) !== i);
|
|
ok(doppelt.length === 0,
|
|
doppelt.length ? `DOPPELT: ${[...new Set(doppelt)].join(", ")}` : "kein Weg kommt zweimal vor");
|
|
const reaktionswege = wege.filter((x) => x.includes("/api/reaktion")).length;
|
|
ok(reaktionswege >= 11, `die Reaction bringt ${reaktionswege} eigene Wege mit`);
|
|
}
|
|
|
|
melde("");
|
|
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
|
|
melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
|
|
process.exit(fehler === 0 ? 0 : 1);
|