/* ===================================================================== pruef-material.mjs — BILDER UND VIDEOS ZUM POSTEN, JEDES NUR EINMAL ===================================================================== Auftrag vom 22.09.2026: "sobald ein bild oder ein video was von uns gepostet wurde, runtergeladen wurde, soll direkt blockiert werden so dass man es nicht mehr runterladen kann ... damit nie ein bild zwei mal gepostet wird ... und die rechte hand und dogfather, nur die beiden sollen auch immer sehen koennen wer das bild oder video runtergeladen hat." --------------------------------------------------------------------- WAS HIER GEMESSEN WIRD — und warum gerade das: (1) DIE EINMALIGKEIT. Der entscheidende Nachweis ist NICHT der gleichzeitige Zugriff: Node arbeitet die beiden Anfragen ohnehin nacheinander ab, und `node:sqlite` ist synchron -- der parallele Fall kann hier gar nicht schiefgehen. Was schiefgehen KANN, ist die Reihenfolge "erst nachsehen, dann schreiben" ohne Bedingung im UPDATE. Genau das faengt der ZWEITE Versuch nach dem ersten ab: Er muss 409 bekommen, und der Vermerk darf sich nicht mehr aendern. Der gleichzeitige Fall steht trotzdem hier -- er kostet nichts und faellt auf, falls jemand spaeter ein `await` zwischen Lesen und Schreiben setzt. (2) DASS DIE DATEI DANACH WIRKLICH ZU IST. Ein Vermerk in der Datenbank ist keine Sperre, solange die Datei weiter ausgeliefert wird. Geprueft wird der Abruf, nicht das Feld. (3) DASS DIE VORSCHAU DIESELBE GRENZE HAT. Sonst waere sie der Weg, ein vergebenes Bild doch noch zu bekommen (Rechtsklick, speichern) -- und die ganze Einmaligkeit eine Behauptung. (4) WER WEN SIEHT — fuer alle fuenf Rollen, nicht nur fuer die zwei, die duerfen. Und nicht nur die Angabe `sieht_wer`, sondern das Feld selbst: Eine Liste, die "nein" sagt und die Namen trotzdem mitschickt, waere das Leck. (5) DASS DIE COMMUNITY NICHTS EINSTELLT — und das Team schon. Ohne die zweite Haelfte waere die erste auch dann gruen, wenn gar nichts mehr ginge. --------------------------------------------------------------------- WARUM node:http UND NICHT fetch: Das Team und die Community melden sich an verschiedenen Waenden an, und welche gilt, entscheidet der Host-Kopf. `fetch` in Node setzt den selbst und ignoriert einen gesetzten -- die Anmeldung ginge an die falsche Wand. ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { request as httpAnfrage } from "node:http"; import { eigenerPort } from "./helfer-port.mjs"; import { notbremse } from "./helfer-notbremse.mjs"; const PORT = await eigenerPort(import.meta, "pruef-material"); /* DATEN_ORDNER wird in workspace.js aus dem Pfad der Datenbank abgeleitet (`dirname(DB_PFAD)`) -- das Material landet damit von selbst im Wegwerfordner. Eine eigene Umgebungsvariable dafuer waere eine zweite Wahrheit, die beim naechsten Umbau auseinanderlaeuft. */ const ordner = mkdtempSync(join(tmpdir(), "ws-material-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = String(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"); /* 240 -> 480 s (22.09.2026): Seit Abschnitt 13 faehrt ein echter Browser mit. Eine Notbremse, die vor dem letzten Abschnitt zuschlaegt, macht aus einer langsamen Pruefung eine rote. */ notbremse(480_000, "pruef-material"); await new Promise((r) => setTimeout(r, 900)); process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); let fehler = 0, geprueft = 0; const melde = (t) => console.log(t); const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const CREW = "crew.dogfather-universe.com"; /* --------------------------------------------------------------------- PERSONEN --------------------------------------------------------------------- */ const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); const schluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", schluessel, jetzt); 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", schluessel).update(code).digest("hex"), jetzt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } anlegen("Filipe", "admin", "CODE-DOGI-0001"); anlegen("Rieke", "hand", "CODE-HAND-0001"); anlegen("Lino", "linke", "CODE-LINK-0001"); anlegen("Frida", "modi", "CODE-MODI-0001"); anlegen("Kessi", "gast", "CODE-GAST-0001"); anlegen("Sam", "gast", "CODE-GAST-0002"); d.close(); /* --------------------------------------------------------------------- DER DRAHT --------------------------------------------------------------------- */ function roh(pfad, wie = {}) { return new Promise((fertig, schief) => { const daten = wie.roh !== undefined ? wie.roh : (wie.body === undefined ? null : Buffer.from(JSON.stringify(wie.body))); const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: wie.methode || (daten ? "POST" : "GET"), headers: { Host: CREW, ...(daten ? { "Content-Type": wie.typ || "application/json", "Content-Length": daten.length, } : {}), ...(wie.kopf || {}), ...(wie.keks ? { Cookie: wie.keks } : {}), Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/`, }, }, (antwort) => { const stuecke = []; antwort.on("data", (s) => stuecke.push(s)); antwort.on("end", () => { const puffer = Buffer.concat(stuecke); const text = puffer.toString("utf8"); fertig({ code: antwort.statusCode, kopf: antwort.headers, bytes: puffer.length, text, json: (() => { try { return JSON.parse(text); } catch { return null; } })(), }); }); }); a.on("error", schief); if (daten) a.write(daten); a.end(); }); } async function anmelden(rolle, code) { /* Die Community bestaetigt beim ersten Mal ihr Alter (seit 15.09.). */ const r = await roh("/workspace/api/anmelden", { body: rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code }, }); const kekse = [].concat(r.kopf?.["set-cookie"] || []); return { keks: kekse.map((c) => c.split(";")[0]).join("; "), code: r.code }; } const chef = await anmelden("admin", "CODE-DOGI-0001"); const hand = await anmelden("hand", "CODE-HAND-0001"); const linke = await anmelden("linke", "CODE-LINK-0001"); const modi = await anmelden("modi", "CODE-MODI-0001"); const kessi = await anmelden("gast", "CODE-GAST-0001"); const sam = await anmelden("gast", "CODE-GAST-0002"); melde(""); melde("=== 0. Alle sechs kommen ueberhaupt herein ==="); for (const [wer, s] of [["DogFather", chef], ["rechte Hand", hand], ["linke Hand", linke], ["Modi", modi], ["Kessi", kessi], ["Sam", sam]]) { ok(s.code === 200 && s.keks.length > 0, `${wer} ist angemeldet (${s.code})`); } /* Ein echtes, winziges PNG (1x1). Der Server prueft die Art -- ein erfundener Byteblock haette den falschen Typ und die Pruefung waere aus dem falschen Grund gruen. */ const PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", "base64"); function einstellen(keks, name, text = "", fenster = {}) { return roh("/workspace/api/material", { methode: "POST", roh: PNG, typ: "image/png", keks, kopf: { "X-Datei-Name": encodeURIComponent(name), "X-Text": encodeURIComponent(text), /* NUR MITSCHICKEN, WENN ES ETWAS ZU SCHICKEN GIBT. Ein leerer Kopf ist etwas anderes als ein fehlender, und die Pruefung soll beides treffen koennen. */ ...(fenster.ab !== undefined ? { "X-Gilt-Ab": fenster.ab } : {}), ...(fenster.bis !== undefined ? { "X-Gilt-Bis": fenster.bis } : {}), }, }); } const nehmen = (id, keks) => roh(`/workspace/api/material/${id}/nehmen`, { methode: "POST", keks }); const liste = (keks) => roh("/workspace/api/material", { keks }); /* ===================================================================== 1. WER ETWAS BEREITSTELLEN DARF ===================================================================== */ melde(""); melde("=== 1. Wer etwas bereitstellen darf ==="); { for (const [wer, s] of [["DogFather", chef], ["die rechte Hand", hand], ["die linke Hand", linke], ["ein Modi", modi]]) { const a = await einstellen(s.keks, `Von ${wer}.png`, "Fuer TikTok"); ok(a.code === 201 && Number.isInteger(a.json?.id), `${wer} stellt etwas bereit (${a.code}, Nummer ${a.json?.id})`); } /* DIE GEGENPROBE, und sie ist Filipes ausdruecklicher Satz: "die community soll keine posten koennen in die kategorie". */ const g = await einstellen(kessi.keks, "Von der Community.png"); ok(g.code === 403, `die Community NICHT (${g.code})`); ok(g.json?.fehler === "nur_team_stellt_ein", `und bekommt einen Grund, zu dem es einen Satz gibt (${g.json?.fehler})`); /* Und es ist auch wirklich nichts angekommen -- eine 403, nach der die Datei trotzdem in der Liste steht, waere die schlimmere Sorte. */ const l = await liste(chef.keks); const vonKessi = (l.json?.material || []).filter((m) => m.von === "Kessi").length; ok(vonKessi === 0, `und es liegt auch nichts von ihr da (${vonKessi})`); } /* ===================================================================== 2. WAS NICHT HINEINGEHOERT ===================================================================== */ melde(""); melde("=== 2. Was nicht hineingehoert ==="); { const a = await roh("/workspace/api/material", { methode: "POST", roh: Buffer.from("MZ-Programm"), typ: "application/octet-stream", keks: chef.keks, kopf: { "X-Datei-Name": encodeURIComponent("schadprogramm.exe") }, }); ok(a.code === 400, `ein Programm wird abgelehnt (${a.code})`); ok(a.json?.fehler === "art_nicht_erlaubt", `mit Grund (${a.json?.fehler})`); const b = await roh("/workspace/api/material", { methode: "POST", roh: Buffer.alloc(0), typ: "image/png", keks: chef.keks, }); ok(b.code === 400 && b.json?.fehler === "datei_fehlt", `eine leere Datei ebenso (${b.code}, ${b.json?.fehler})`); } /* ===================================================================== 3. DER KERN: JEDES NUR EINMAL ===================================================================== */ melde(""); melde("=== 3. Jedes nur einmal ==="); { const a = await einstellen(chef.keks, "Umkaempft.png", "Wer zuerst kommt"); const id = a.json?.id; ok(Number.isInteger(id), `ein Stueck zum Wettlauf liegt bereit (${id})`); /* Beide Anfragen gehen los, bevor die erste beantwortet ist. */ const [x, y] = await Promise.all([nehmen(id, kessi.keks), nehmen(id, sam.keks)]); const gewonnen = [x, y].filter((r) => r.code === 200).length; const verloren = [x, y].filter((r) => r.code === 409).length; ok(gewonnen === 1, `genau einer bekommt es (${gewonnen} von 2)`); ok(verloren === 1, `und genau einer bekommt "schon vergeben" (${verloren})`); ok([x, y].find((r) => r.code === 409)?.json?.fehler === "schon_genommen", `mit Grund (${[x, y].find((r) => r.code === 409)?.json?.fehler})`); /* DER EIGENTLICHE NACHWEIS: Wer danach kommt, bekommt nichts -- und der Vermerk darf sich nicht mehr aendern. Ein Weg ohne Bedingung im UPDATE wuerde hier den zweiten Namen eintragen, und die Liste saehe danach voellig normal aus. */ const vorName = (await liste(chef.keks)).json?.material ?.find((m) => m.id === id)?.genommen_von; const z = await nehmen(id, modi.keks); ok(z.code === 409, `ein Dritter danach ebenso wenig (${z.code})`); const nachName = (await liste(chef.keks)).json?.material ?.find((m) => m.id === id)?.genommen_von; ok(!!vorName && vorName === nachName, `und der Vermerk bleibt stehen (${vorName} -> ${nachName})`); } /* ===================================================================== 4. DIE DATEI IST DANACH WIRKLICH ZU ===================================================================== */ melde(""); melde("=== 4. Die Datei ist danach wirklich zu ==="); { const a = await einstellen(chef.keks, "Abgeschlossen.png"); const id = a.json?.id; const vorher = await roh(`/workspace/api/material/${id}/datei`, { keks: kessi.keks }); ok(vorher.code === 403, `vor dem Nehmen bekommt sie niemand (${vorher.code})`); const genommen = await nehmen(id, kessi.keks); ok(genommen.code === 200, `Kessi nimmt es (${genommen.code})`); const seine = await roh(`/workspace/api/material/${id}/datei`, { keks: kessi.keks }); ok(seine.code === 200 && seine.bytes === PNG.length, `und bekommt die Datei (${seine.code}, ${seine.bytes} von ${PNG.length} Bytes)`); ok(/attachment/.test(String(seine.kopf?.["content-disposition"] || "")), "als Download, nicht zum Anschauen im Browser"); /* DER RIEGEL. Ein Vermerk in der Datenbank ist keine Sperre, solange die Datei weiter ausgeliefert wird. */ for (const [wer, s] of [["ein anderer aus der Community", sam], ["ein Modi", modi], ["die linke Hand", linke], ["sogar die rechte Hand", hand]]) { const r = await roh(`/workspace/api/material/${id}/datei`, { keks: s.keks }); ok(r.code === 403, `${wer} bekommt sie nicht (${r.code})`); } /* UND DIE VORSCHAU HAT DIESELBE GRENZE -- sonst waere sie der Weg, es doch noch zu bekommen. */ const schauFremd = await roh(`/workspace/api/material/${id}/vorschau`, { keks: sam.keks }); ok(schauFremd.code === 403, `auch die Vorschau ist fuer andere zu (${schauFremd.code})`); const schauSeine = await roh(`/workspace/api/material/${id}/vorschau`, { keks: kessi.keks }); ok(schauSeine.code === 200 && schauSeine.bytes === PNG.length, `wer es genommen hat, sieht es weiter (${schauSeine.code})`); } /* ===================================================================== 5. SOLANGE ES FREI IST, DARF JEDER HINSEHEN ===================================================================== Die Gegenprobe zu Abschnitt 4: Waere die Vorschau schlicht immer gesperrt, waeren alle Zeilen oben ebenfalls gruen -- und niemand koennte je entscheiden, ob ein Bild zu seinem Kanal passt. */ melde(""); melde("=== 5. Solange es frei ist, darf jeder hinsehen ==="); { const a = await einstellen(chef.keks, "Noch frei.png"); const id = a.json?.id; for (const [wer, s] of [["die Community", kessi], ["ein Modi", modi], ["die linke Hand", linke], ["die rechte Hand", hand]]) { const r = await roh(`/workspace/api/material/${id}/vorschau`, { keks: s.keks }); ok(r.code === 200 && r.bytes === PNG.length, `${wer} sieht die Vorschau (${r.code}, ${r.bytes} Bytes)`); } } /* ===================================================================== 6. WER SIEHT, WER ES GENOMMEN HAT ===================================================================== "die rechte hand und dogfather, NUR die beiden" -- gemessen fuer alle fuenf Rollen, und nicht nur an der Angabe `sieht_wer`, sondern am Feld selbst. */ melde(""); melde("=== 6. Wer sieht, wer es genommen hat ==="); { const soll = { admin: true, hand: true, linke: false, modi: false, gast: false }; const kekse = { admin: chef, hand, linke, modi, gast: kessi }; let vergebenGesamt = 0; for (const [rolle, darf] of Object.entries(soll)) { const a = await liste(kekse[rolle].keks); ok(a.code === 200, `${rolle}: die Liste kommt (${a.code})`); ok(a.json?.sieht_wer === darf, ` und "sieht_wer" ist ${darf} (${a.json?.sieht_wer})`); const vergeben = (a.json?.material || []).filter((m) => m.genommen); const mitNamen = vergeben.filter((m) => m.genommen_von !== undefined).length; vergebenGesamt = Math.max(vergebenGesamt, vergeben.length); ok(vergeben.length > 0 && (darf ? mitNamen === vergeben.length : mitNamen === 0), ` ${vergeben.length} vergeben, davon ${mitNamen} mit Namen`); } /* DIE ZAHL GEHOERT IN DIE BEDINGUNG: Ohne vergebene Stuecke waere "keine Namen dabei" trivial wahr. */ ok(vergebenGesamt >= 2, `es lagen ueberhaupt vergebene Stuecke vor (${vergebenGesamt})`); } /* ===================================================================== 7. JEDER SIEHT, WAS ER SELBST GENOMMEN HAT ===================================================================== */ melde(""); melde("=== 7. Jeder sieht, was er selbst genommen hat ==="); { /* SAM MUSS ERST WIRKLICH ETWAS HABEN. Im Wettlauf oben hat Kessi gewonnen -- ohne diesen Schritt vergliche die Gegenprobe unten zwei Nullen und waere gruen, ohne irgendetwas gesehen zu haben. Genau der gruene Haken ueber der Null. */ const fuerSam = await einstellen(chef.keks, "Fuer Sam.png"); const holt = await nehmen(fuerSam.json?.id, sam.keks); ok(holt.code === 200, `Sam nimmt sich eines (${holt.code})`); const meine = ((await liste(kessi.keks)).json?.material || []) .filter((m) => m.von_mir_genommen); ok(meine.length >= 1, `Kessi sieht ihre eigenen (${meine.length})`); const fremde = ((await liste(sam.keks)).json?.material || []) .filter((m) => m.von_mir_genommen); const ids = new Set(meine.map((m) => m.id)); const verwechselt = fremde.filter((m) => ids.has(m.id)).length; ok(verwechselt === 0, `und keines davon steht bei Sam als "von mir" (${verwechselt})`); /* Gegenprobe gegen die Liste des DogFathers: Sams eigene Sicht muss zu dem passen, was dort mit seinem Namen steht. */ const samHat = ((await liste(chef.keks)).json?.material || []) .filter((m) => m.genommen_von === "Sam").length; ok(samHat > 0 && fremde.length === samHat, `Sams eigene Sicht deckt sich mit der Liste des DogFathers (${fremde.length} / ${samHat})`); } /* ===================================================================== 8. DIE AGENTUR HAT HIER NICHTS ===================================================================== */ melde(""); melde("=== 8. Die Grenzen des Bereichs ==="); { const { darfSeite } = await import("./rechte.js"); for (const r of ["admin", "hand", "linke", "modi", "gast"]) { ok(darfSeite({ rolle: r, haus: "crew" }, "/workspace/material.html"), `${r} darf die Seite oeffnen`); } for (const r of ["creator", "manager", "scout", "spicy"]) { ok(!darfSeite({ rolle: r, haus: "agentur" }, "/workspace/material.html"), `${r} aus der Agentur nicht`); } /* Und die Kachel gibt es -- sonst waere die Seite da und niemand faende sie. Abgeleitet aus derselben Quelle wie die Startseite. */ const w = await import("./workspace.js"); for (const r of ["gast", "modi", "hand", "linke", "admin"]) { const treffer = [...(w.bereicheFuer({ rolle: r, haus: "crew" }) || [])] .filter((k) => k.ziel === "material.html"); /* HEISST SEIT DEM 22.09.2026 "Dogi-Media" (Filipe: "ersetz das wort material durch Dogi-Media"). Der DATEINAME bleibt material.html: Eine Adresse umzubenennen bricht jedes Lesezeichen und jeden Verweis, und der sichtbare Name ist das, was zaehlt -- dieselbe Entscheidung wie bei "Unsere Seiten" -> "Draussen". */ ok(treffer.length === 1 && treffer[0].name === "Dogi-Media", `${r} hat genau eine Kachel dorthin (${treffer.length}, "${treffer[0]?.name}")`); } } /* ===================================================================== 9. VON WANN BIS WANN (22.09.2026) ===================================================================== Filipe: "mach kategorien welche sind benutzt welche nicht welche sind noch offen, welche laufen von wan bis wann, das muss man auch machen koennen und detailliert perfektioniert." GERECHNET WIRD RELATIV ZU HEUTE, nie mit einem festen Datum. Ein Test, der "2026-10-01" als Zukunft annimmt, ist am 2. Oktober rot, ohne dass irgendetwas kaputt waere -- und ein Fehlalarm, den niemand nachstellen kann, schaltet den ganzen Satz ab. ===================================================================== */ melde(""); melde("=== 9. Der Zeitraum: gesetzt, gespeichert, gerechnet ==="); const { tagLokal } = await import("./helfer-tag.mjs"); const HEUTE = tagLokal(0); const GESTERN = tagLokal(-1); const VORGESTERN = tagLokal(-2); const MORGEN = tagLokal(1); const IN10 = tagLokal(10); { /* Vier Stuecke, eines je Zustand -- und zwar wirklich vier, nicht drei und eine Annahme. Die Zaehlung unten prueft das. */ const faelle = [ ["ohne-fenster.png", {}, "frei"], ["laeuft-schon.png", { ab: VORGESTERN, bis: IN10 }, "frei"], ["kommt-noch.png", { ab: MORGEN, bis: IN10 }, "spaeter"], ["ist-vorbei.png", { ab: VORGESTERN, bis: GESTERN }, "abgelaufen"], ]; for (const [name, f, erwartet] of faelle) { const r = await einstellen(chef.keks, name, "Probe", f); ok(r.code === 201 && Number.isInteger(r.json?.id), `"${name}" angelegt (${r.code})`); } const l = await liste(chef.keks); const finde = (n) => (l.json?.material || []).find((m) => m.name === n); for (const [name, f, erwartet] of faelle) { const m = finde(name); ok(!!m && m.zustand === erwartet, `"${name}" steht als "${erwartet}" da (${m?.zustand})`); ok(!!m && (m.gilt_ab || null) === (f.ab || null) && (m.gilt_bis || null) === (f.bis || null), `"${name}" gibt sein Fenster unveraendert zurueck ` + `(${m?.gilt_ab} / ${m?.gilt_bis})`); } /* DER HEUTIGE TAG KOMMT MIT UND IST EIN ECHTES DATUM. Genau hier lag der Fehler, der diese Pruefung ausgeloest hat: `tagLokal()` ohne Argument gab "NaN-NaN-NaN" zurueck. Beim Zeichenkettenvergleich gewinnt das grosse N gegen jede Ziffer -- also galt jedes Stueck mit Enddatum sofort als abgelaufen, und "Kommt noch" gab es nie. Kein Absturz, keine Meldung. Deshalb wird die FORM geprueft, nicht nur die Anwesenheit. */ ok(/^\d{4}-\d{2}-\d{2}$/.test(String(l.json?.heute || "")), `die Liste nennt den heutigen Tag als Datum ("${l.json?.heute}")`); ok(l.json?.heute === HEUTE, `und es ist wirklich heute (${l.json?.heute} vs ${HEUTE})`); /* GEGENPROBE: Wuerde "heute" nicht stimmen, muesste die Rechnung ein anderes Ergebnis liefern. Sonst misst der Test nur, dass irgendetwas zurueckkommt. */ const { zustandVon } = await import("./workspace-material.js"); ok(zustandVon({ gilt_ab: MORGEN }, HEUTE) === "spaeter" && zustandVon({ gilt_ab: MORGEN }, "NaN-NaN-NaN") !== "spaeter", "mit einem kaputten Tag faellt die Rechnung um -- die Probe kann also anschlagen"); /* ALLE VIER ZUSTAENDE KOMMEN AUCH WIRKLICH VOR. Ohne diese Zeile waeren drei gruene Haken auch dann gruen, wenn ein Zustand gar nicht mehr erreichbar ist. */ const NOETIG = ["frei", "spaeter", "abgelaufen"]; const gezaehlt = new Set((l.json?.material || []).map((m) => m.zustand)); const fehlend = NOETIG.filter((z) => !gezaehlt.has(z)); /* DIE ZAHL IM SATZ WIRD GEZAEHLT, NICHT GETIPPT. Der erste Entwurf schrieb "drei Zustaende sind besetzt" und zaehlte daneben vier auf -- ein Satz, der schon beim ersten Lesen nicht zu seiner eigenen Klammer passte. */ ok(fehlend.length === 0, `alle ${NOETIG.length} Zustaende aus diesem Abschnitt sind besetzt; ` + `gefunden: ${[...gezaehlt].sort().join(", ")}` + (fehlend.length ? ` -- es fehlt: ${fehlend.join(", ")}` : "")); } melde(""); melde("=== 9a. Die Umstellung merkt, wenn sie nicht durchkam ==="); { /* WARUM DAS HIER STEHT: Im ersten Entwurf setzte `tabellen()` den Vermerk "erledigt" UNBEDINGT -- auch wenn das ALTER scheiterte, dessen Fehler daneben still in die Konsole ging. Ein einziger misslungener Versuch haette die Umstellung fuer immer als getan vermerkt, und danach scheitert JEDE Liste an `m.gilt_ab`: 503 fuer alle, mit einer Meldung, die nicht sagt, woran es liegt. Geprueft wird an einer Wegwerfdatei, nicht am laufenden Haus -- eine Tabelle ohne die Spalten laesst sich sonst gar nicht herstellen, ohne etwas kaputtzumachen. */ const probe = new (await import("node:sqlite")).DatabaseSync(join(ordner, "probe-schema.db")); probe.exec("CREATE TABLE material (id INTEGER PRIMARY KEY, dateiname TEXT)"); const NOETIG = ["gilt_ab", "gilt_bis"]; const fehlt = () => { const da = new Set(probe.prepare("PRAGMA table_info(material)").all().map((c) => c.name)); return NOETIG.filter((n) => !da.has(n)); }; ok(fehlt().length === 2, `vor der Umstellung fehlen beide Spalten (${fehlt().join(", ")})`); probe.exec("ALTER TABLE material ADD COLUMN gilt_ab TEXT"); ok(fehlt().length === 1 && fehlt()[0] === "gilt_bis", `nach der halben Umstellung fehlt genau eine (${fehlt().join(", ")}) ` + `-- genau dieser Fall haette "erledigt" heissen koennen`); probe.exec("ALTER TABLE material ADD COLUMN gilt_bis TEXT"); ok(fehlt().length === 0, "nach der ganzen keine mehr"); probe.close(); /* UND AM LAUFENDEN HAUS: Die Spalten sind wirklich da. Die Zeile darueber beweist nur, dass die Rechnung stimmt -- nicht, dass sie hier angewandt wurde. */ const { DatabaseSync: DS } = await import("node:sqlite"); const echt = new DS(process.env.WORKSPACE_DB); const spalten = echt.prepare("PRAGMA table_info(material)").all().map((c) => c.name); echt.close(); ok(NOETIG.every((n) => spalten.includes(n)), `und in der laufenden Ablage stehen sie auch (${spalten.length} Spalten: ` + `${spalten.join(", ")})`); } melde(""); melde("=== 9b. Ein Fenster, das nicht geht ==="); { const verkehrt = await einstellen(chef.keks, "verkehrt.png", "", { ab: IN10, bis: GESTERN }); ok(verkehrt.code === 400 && verkehrt.json?.fehler === "fenster_verkehrt", `Ende vor Anfang wird abgelehnt (${verkehrt.code}/${verkehrt.json?.fehler})`); const quatsch = await einstellen(chef.keks, "quatsch.png", "", { ab: "morgen frueh" }); ok(quatsch.code === 400 && quatsch.json?.fehler === "datum_unbrauchbar", `ein Datum, das keines ist, wird abgelehnt (${quatsch.code}/${quatsch.json?.fehler})`); /* GEGENPROBE zu beiden: Dasselbe ohne Fenster muss durchgehen -- sonst koennte die Ablehnung auch an etwas ganz anderem liegen. */ const sauber = await einstellen(chef.keks, "sauber.png", "", { ab: HEUTE, bis: IN10 }); ok(sauber.code === 201, `dasselbe mit gueltigem Fenster geht (${sauber.code})`); /* UND NICHTS DAVON IST LIEGENGEBLIEBEN. Eine abgelehnte Anfrage darf keine halbe Datei im Ordner hinterlassen. */ const l = await liste(chef.keks); const namen = (l.json?.material || []).map((m) => m.name); ok(!namen.includes("verkehrt.png") && !namen.includes("quatsch.png"), "die abgelehnten Stuecke stehen nirgends in der Liste"); } /* ===================================================================== 10. BEARBEITEN -- wer darf, und was bleibt unberuehrt ===================================================================== Filipe: "wenn wir in der kategorie material was hochladen will ich dass die rechte und linke hand es auch bearbeiten und loeschen koennen. dogfather auch fals es ein fehler gab." ===================================================================== */ melde(""); melde("=== 10. Bearbeiten ==="); const aendern = (id, keks, was) => roh(`/workspace/api/material/${id}`, { methode: "PATCH", keks, body: was }); { /* Das Stueck gehoert der RECHTEN HAND -- damit ist jede Aenderung durch jemand anderen eine FREMDE Aenderung. Haette es DogFather eingestellt, waere sein eigener Zugriff kein Nachweis. */ const r = await einstellen(hand.keks, "fremdes-stueck.png", "Erster Text"); const id = r.json?.id; ok(Number.isInteger(id), `Stueck der rechten Hand liegt (${r.code})`); for (const [wer, s2] of [["DogFather", chef], ["rechte Hand", hand], ["linke Hand", linke]]) { const a = await aendern(id, s2.keks, { text: `Geaendert von ${wer}` }); ok(a.code === 200 && a.json?.text === `Geaendert von ${wer}`, `${wer} darf fremden Text aendern (${a.code})`); } for (const [wer, s2] of [["Modi", modi], ["Kessi (Community)", kessi]]) { const a = await aendern(id, s2.keks, { text: "heimlich" }); ok(a.code === 403 || a.code === 404, `${wer} darf nicht (${a.code}/${a.json?.fehler})`); } /* GEGENPROBE ZUR ABLEHNUNG: Der Text muss danach noch der von vorhin sein. Eine 403, nach der die Aenderung trotzdem drinsteht, waere der eigentliche Schaden -- und sie saehe von aussen richtig aus. */ const l = await liste(chef.keks); const m = (l.json?.material || []).find((x) => x.id === id); ok(m?.text === "Geaendert von linke Hand", `und der Text ist unveraendert geblieben ("${m?.text}")`); /* DAS FENSTER LAESST SICH NACHTRAEGLICH SETZEN UND WIEDER LEEREN. Das Leeren ist der Fall, den man vergisst: Wer ein Enddatum falsch getippt hat, muss es LOESCHEN koennen, nicht nur ueberschreiben. */ const setzen = await aendern(id, hand.keks, { gilt_ab: MORGEN, gilt_bis: IN10 }); ok(setzen.code === 200 && setzen.json?.zustand === "spaeter", `Fenster nachtraeglich gesetzt, Zustand "${setzen.json?.zustand}"`); const leeren = await aendern(id, hand.keks, { gilt_ab: "", gilt_bis: "" }); ok(leeren.code === 200 && !leeren.json?.gilt_ab && !leeren.json?.gilt_bis && leeren.json?.zustand === "frei", `und wieder geleert, Zustand "${leeren.json?.zustand}"`); const verkehrt = await aendern(id, hand.keks, { gilt_ab: IN10, gilt_bis: GESTERN }); ok(verkehrt.code === 400 && verkehrt.json?.fehler === "fenster_verkehrt", `ein verkehrtes Fenster wird auch beim Aendern abgelehnt (${verkehrt.code})`); /* WAS NICHT MITGESCHICKT WIRD, BLEIBT. Sonst loescht jeder, der nur den Text aendert, nebenbei das Fenster -- und merkt es nie. */ await aendern(id, hand.keks, { gilt_ab: MORGEN, gilt_bis: IN10 }); const nurText = await aendern(id, hand.keks, { text: "Nur der Text" }); ok(nurText.code === 200 && nurText.json?.gilt_ab === MORGEN && nurText.json?.gilt_bis === IN10, `eine Aenderung nur am Text laesst das Fenster stehen ` + `(${nurText.json?.gilt_ab} / ${nurText.json?.gilt_bis})`); } melde(""); melde("=== 10b. Was vergeben ist, bleibt, wie es ist ==="); { const r = await einstellen(chef.keks, "schon-weg.png", "Steht so da"); const id = r.json?.id; const n = await nehmen(id, modi.keks); ok(n.code === 200, `ein Modi hat es genommen (${n.code})`); const a = await aendern(id, chef.keks, { text: "nachtraeglich umgeschrieben" }); ok(a.code === 409 && a.json?.fehler === "schon_im_umlauf", `selbst DogFather aendert es nicht mehr (${a.code}/${a.json?.fehler})`); const l = await liste(chef.keks); const m = (l.json?.material || []).find((x) => x.id === id); ok(m?.text === "Steht so da", `der Text von damals steht noch ("${m?.text}")`); ok(m?.zustand === "benutzt", `und der Zustand heisst "benutzt" (${m?.zustand})`); /* Der vierte Zustand wird hier NEU erzeugt und nicht nur mitgezaehlt: Weiter oben gibt es schon vergebene Stuecke aus Abschnitt 6, aber dass AUSGERECHNET DIESES nach dem Nehmen "benutzt" heisst, ist die Aussage -- und die gilt nur fuer das Stueck von eben. */ ok((l.json?.material || []).filter((x) => x.zustand === "benutzt").length >= 1, "es gibt mindestens ein Stueck im Zustand \"benutzt\""); } /* ===================================================================== 11. ENTFERNEN -- dieselbe Runde, andere Tuer ===================================================================== */ melde(""); melde("=== 11. Entfernen ==="); const wegnehmen = (id, keks) => roh(`/workspace/api/material/${id}`, { methode: "DELETE", keks }); { for (const [wer, s2] of [["DogFather", chef], ["rechte Hand", hand], ["linke Hand", linke]]) { /* Jedes Mal ein frisches Stueck von jemand anderem -- sonst entfernte der Zweite nur noch eine Nummer, die es nicht gibt, und bekaeme 404 statt eines echten Nachweises. */ const r = await einstellen(hand.keks, `weg-${wer}.png`, ""); const id = r.json?.id; const w = await wegnehmen(id, s2.keks); ok(w.code === 200, `${wer} darf ein fremdes Stueck entfernen (${w.code})`); const l = await liste(chef.keks); ok(!(l.json?.material || []).some((x) => x.id === id), `und es ist danach wirklich weg`); } const r = await einstellen(hand.keks, "bleibt-liegen.png", ""); const id = r.json?.id; for (const [wer, s2] of [["Modi", modi], ["Kessi (Community)", kessi]]) { const w = await wegnehmen(id, s2.keks); ok(w.code === 403 || w.code === 404, `${wer} darf nicht entfernen (${w.code}/${w.json?.fehler})`); } const l = await liste(chef.keks); ok((l.json?.material || []).some((x) => x.id === id), "und das Stueck liegt danach immer noch da"); } /* ===================================================================== 12. WAS DIE LISTE UEBER DIE RECHTE SAGT ===================================================================== Die Oberflaeche rechnet NICHT nach, wer was darf -- sie liest `darf_ich` und `darf_verwalten`. Waeren die Angaben falsch, staenden Knoepfe da, die beim Druecken eine Absage bringen; oder sie fehlten dort, wo jemand darf. Beides ist schlimmer als ein Serverfehler, weil es aussieht, als sei alles in Ordnung. ===================================================================== */ melde(""); melde("=== 12. Was die Liste ueber die Rechte sagt ==="); { const r = await einstellen(hand.keks, "rechte-probe.png", ""); const id = r.json?.id; const darf = { admin: true, hand: true, linke: true }; for (const [wer, s2, rolle] of [["DogFather", chef, "admin"], ["rechte Hand", hand, "hand"], ["linke Hand", linke, "linke"], ["Modi", modi, "modi"], ["Kessi", kessi, "gast"]]) { const l = await liste(s2.keks); const soll = darf[rolle] === true; ok(l.json?.darf_verwalten === soll, `${wer}: darf_verwalten = ${l.json?.darf_verwalten} (erwartet ${soll})`); const m = (l.json?.material || []).find((x) => x.id === id); ok(!!m && m.darf_ich === soll, `${wer}: darf_ich am fremden Stueck = ${m?.darf_ich} (erwartet ${soll})`); } /* GEGENPROBE: Am EIGENEN Stueck muss `darf_ich` auch fuer jemanden wahr sein, der fremde nicht anfassen darf. Sonst koennte das Feld schlicht `darf_verwalten` wiederholen und niemandem auffallen. Ein Modi stellt nichts ein -- also nimmt die Probe die rechte Hand, deren eigenes Stueck das hier ist. */ const l = await liste(hand.keks); const m = (l.json?.material || []).find((x) => x.id === id); ok(m?.darf_ich === true, "die rechte Hand darf ihr eigenes Stueck (darf_ich = true)"); } /* ===================================================================== 13. AM ECHTEN BILDSCHIRM ===================================================================== Die zwoelf Abschnitte davor messen die Schnittstelle. Keiner von ihnen haette gemerkt, wenn der Knopf "Bearbeiten" gar nicht erscheint, das Formular hinter der Karte verschwindet oder die Datumsfelder am Handy aus dem Bild laufen. Genau daran ist die Teilnehmerwahl am 05.09. gescheitert: vierzig gruene Pruefungen, und auf dem Bildschirm klebten die Namen aneinander. Niemand hatte gefragt, ob das Ergebnis auch AUSSIEHT wie gedacht. WARUM EIN HTTPS-VORBAU: Team Dogi wohnt auf crew.dogfather-universe.com, und welche Wand gilt, entscheidet der Host-Kopf. Chromium setzt den selbst und hebt diese Domain ausserdem von sich aus auf https -- also bekommt sie hier einen echten, selbst unterschriebenen Vorbau davor. Derselbe Weg wie in pruef-willkommen. ===================================================================== */ melde(""); melde("=== 13. Am echten Bildschirm ==="); { const { execFileSync } = await import("node:child_process"); const { createServer: httpsServer } = await import("node:https"); const { readFileSync } = await import("node:fs"); const schluessel = join(ordner, "pruef.key"); const zertifikat = join(ordner, "pruef.crt"); execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", schluessel, "-out", zertifikat, "-days", "2", "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" }); const HTTPS_PORT = PORT + 1; const vorbau = httpsServer({ key: readFileSync(schluessel), cert: readFileSync(zertifikat), }, (von, nach) => { const weiter = httpAnfrage({ host: "127.0.0.1", port: PORT, path: von.url, method: von.method, headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` }, }, (antwort) => { nach.writeHead(antwort.statusCode || 502, antwort.headers); antwort.pipe(nach); }); von.pipe(weiter); }); await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r)); const SEITE = `https://${CREW}:${HTTPS_PORT}`; const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await pw.chromium.launch({ args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"], }); /* Ein sauberer Ausgangszustand fuer die Augen: ein Stueck mit laufendem Fenster, eines fuer spaeter, eines abgelaufen. Alles im Wegwerfordner -- an die echte Datenbank kommt hier nichts. */ await einstellen(chef.keks, "schau-laeuft.png", "Laeuft gerade", { ab: VORGESTERN, bis: IN10 }); await einstellen(chef.keks, "schau-spaeter.png", "Kommt noch", { ab: MORGEN, bis: IN10 }); await einstellen(chef.keks, "schau-vorbei.png", "War einmal", { ab: VORGESTERN, bis: GESTERN }); const meckern = []; async function seiteAls(rolle, code, breite) { const ctx = await browser.newContext({ viewport: { width: breite, height: 950 } }); const s2 = await ctx.newPage(); s2.on("pageerror", (e) => meckern.push(`${rolle}: Absturz ${String(e).slice(0, 120)}`)); s2.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(m.text())) { meckern.push(`${rolle}: Konsole ${m.text().slice(0, 120)}`); } }); await s2.goto(`${SEITE}/workspace/`, { waitUntil: "networkidle" }); await s2.click(`.rolle[data-rolle="${rolle}"]`); await s2.fill("#code", code); await s2.click("button[type=submit]"); await s2.waitForTimeout(900); await s2.goto(`${SEITE}/workspace/material.html`, { waitUntil: "networkidle" }); await s2.waitForTimeout(1200); return { ctx, s: s2 }; } try { /* ---- DogFather am Rechner ------------------------------------ */ const a = await seiteAls("admin", "CODE-DOGI-0001", 1280); try { const bild = await a.s.evaluate(() => { const knoepfe = [...document.querySelectorAll("#m-kat .schritt")]; const karte = document.querySelector(".m-karte"); const zeit = document.querySelector(".m-karte__zeit"); return { ueberschrift: document.querySelector("h1")?.textContent?.trim(), kategorien: knoepfe.map((b) => ({ wort: b.textContent.replace(/\d+$/, "").trim(), zahl: b.querySelector(".m-kat__zahl")?.textContent, gedrueckt: b.getAttribute("aria-pressed"), })), /* Sieht der Knopf nach Knopf aus? Ein `.schritt` ohne Polsterung waere nackter Text -- genau der Fehler von der Teilnehmerwahl. */ knopfHoehe: knoepfe[0] ? Math.round(knoepfe[0].getBoundingClientRect().height) : 0, verwalten: !!karte?.querySelector(".m-karte__verwalten"), verwaltenKnoepfe: [...(karte?.querySelectorAll(".m-karte__verwalten button") || [])] .map((b) => b.textContent.trim()), zeitMarke: zeit?.querySelector(".m-karte__zeit-marke")?.textContent?.trim() || null, zeitFarbe: zeit ? getComputedStyle(zeit.querySelector(".m-karte__zeit-marke")).color : null, zeitText: zeit?.textContent?.replace(/\s+/g, " ").trim() || null, fensterFelder: document.querySelectorAll(".m-neu__fenster input[type=date]").length, }; }); ok(bild.ueberschrift === "Dogi-Media", `die Seite heisst "${bild.ueberschrift}"`); ok(bild.kategorien.length === 5, `fuenf Kategorieknoepfe stehen da (${bild.kategorien.length}: ` + `${bild.kategorien.map((k) => `${k.wort} ${k.zahl}`).join(" | ")})`); ok(bild.kategorien[0]?.gedrueckt === "true", `"Alle" ist beim Aufschlagen gewaehlt (${bild.kategorien[0]?.gedrueckt})`); ok(bild.knopfHoehe >= 26, `und sie sehen aus wie Knoepfe, nicht wie Text (${bild.knopfHoehe} px hoch)`); ok(bild.fensterFelder === 2, `der Einstellkasten hat zwei Datumsfelder (${bild.fensterFelder})`); ok(bild.verwalten && bild.verwaltenKnoepfe.join("/") === "Bearbeiten/Entfernen", `DogFather sieht beide Verwaltungsknoepfe (${bild.verwaltenKnoepfe.join(", ")})`); ok(!!bild.zeitMarke && !!bild.zeitText, `der Zeitraum steht an der Karte ("${bild.zeitText}")`); ok(bild.zeitFarbe !== null && bild.zeitFarbe !== "rgb(0, 0, 0)", `und die Zustandsmarke hat ihre Farbe (${bild.zeitFarbe})`); /* ---- Der Filter filtert wirklich, und beide Listen ---- */ const vorher = await a.s.evaluate(() => ({ oben: document.querySelectorAll("#frei .m-karte").length, unten: document.querySelectorAll("#weg .m-karte").length, })); await a.s.click('#m-kat .schritt[data-wert="spaeter"]'); await a.s.waitForTimeout(400); const nachher = await a.s.evaluate(() => ({ oben: document.querySelectorAll("#frei .m-karte").length, unten: document.querySelectorAll("#weg .m-karte").length, gedrueckt: document.querySelector('#m-kat .schritt[data-wert="spaeter"]') ?.getAttribute("aria-pressed"), /* AM ZUSTAND GEMESSEN, NICHT AM DATEINAMEN. Der erste Entwurf prueft, ob jeder Name "spaeter" enthaelt -- und wurde rot, weil ein Stueck aus Abschnitt 10 voellig zu Recht dabei war: Dort hat die rechte Hand ihm ein Fenster in der Zukunft gegeben. Die Pruefung hatte recht behalten wollen, nicht gemessen. */ zustaende: [...document.querySelectorAll("#frei .m-karte")] .map((k) => k.querySelector(".m-karte__zeit")?.dataset.zustand || "ohne"), })); ok(nachher.gedrueckt === "true" && nachher.oben < vorher.oben && nachher.oben > 0, `"Kommt noch" filtert die obere Liste (${vorher.oben} -> ${nachher.oben})`); ok(nachher.unten === 0 && vorher.unten > 0, `und die untere gleich mit (${vorher.unten} -> ${nachher.unten}) ` + `-- ein Filter, der nur die halbe Seite betrifft, sieht kaputt aus`); ok(nachher.zustaende.length > 0 && nachher.zustaende.every((z) => z === "spaeter"), `uebrig bleibt nur, was spaeter gilt ` + `(${nachher.zustaende.length} Karten, alle: ${[...new Set(nachher.zustaende)].join(", ")})`); /* Noch einmal auf denselben Knopf heisst "alle". */ await a.s.click('#m-kat .schritt[data-wert="spaeter"]'); await a.s.waitForTimeout(400); const zurueck = await a.s.evaluate(() => document.querySelectorAll("#frei .m-karte").length); ok(zurueck === vorher.oben, `ein zweiter Druck fuehrt zurueck zu allen (${zurueck} von ${vorher.oben})`); /* ---- BEARBEITEN: Formular IN der Karte, vorbelegt ---- */ await a.s.click(".m-karte .m-karte__verwalten button"); await a.s.waitForTimeout(400); const form = await a.s.evaluate(() => { const k = document.querySelector(".m-karte"); const f = k?.querySelector(".m-bearb"); if (!f) return null; return { inKarte: !!f && k.contains(f), text: f.querySelector("textarea")?.value, kartenText: k.querySelector(".m-karte__text")?.textContent, datumsfelder: f.querySelectorAll("input[type=date]").length, ab: f.querySelectorAll("input[type=date]")[0]?.value, knoepfe: [...f.querySelectorAll("button")].map((b) => b.textContent.trim()), }; }); ok(!!form && form.inKarte, "das Formular steht in der Karte, nicht davor"); ok(!!form && form.text === form.kartenText, `das Textfeld ist mit dem bisherigen Text vorbelegt ("${form?.text}")`); ok(!!form && form.datumsfelder === 2 && !!form.ab, `und die zwei Datumsfelder auch (${form?.ab})`); ok(!!form && form.knoepfe.join("/") === "Speichern/Abbrechen", `Speichern und Abbrechen stehen da (${form?.knoepfe.join(", ")})`); /* GEGENPROBE ZUM VORBELEGEN: Genau das konnte `frageNach` nicht, und genau deshalb steht hier ein eigenes Formular. Ein leeres Feld waere kein Bearbeiten, sondern ein Neuschreiben. */ /* ---- Ein verkehrtes Fenster wird schon hier gesagt ---- */ const felder = ".m-karte .m-bearb input[type=date]"; await a.s.evaluate((wahl) => { const d2 = document.querySelectorAll(wahl); d2[0].value = "2030-01-01"; d2[1].value = "2029-01-01"; }, felder); await a.s.click(".m-karte .m-bearb button[type=submit]"); await a.s.waitForTimeout(500); const gemeckert = await a.s.evaluate(() => document.querySelector(".m-karte .m-bearb__fehler")?.textContent?.trim()); ok(!!gemeckert && gemeckert.length > 10, `ein verkehrtes Fenster wird im Formular gesagt ("${gemeckert}")`); /* ---- Speichern geht wirklich durch ---- */ await a.s.evaluate(() => { const f = document.querySelector(".m-karte .m-bearb"); f.querySelector("textarea").value = "Am Bildschirm geaendert"; for (const d2 of f.querySelectorAll("input[type=date]")) d2.value = ""; }); await a.s.click(".m-karte .m-bearb button[type=submit]"); await a.s.waitForTimeout(1200); const danach = await a.s.evaluate(() => ({ formWeg: !document.querySelector(".m-karte .m-bearb"), text: document.querySelector(".m-karte .m-karte__text")?.textContent, })); ok(danach.formWeg, "nach dem Speichern ist das Formular zu"); ok(danach.text === "Am Bildschirm geaendert", `und der neue Text steht auf der Karte ("${danach.text}")`); } finally { await a.ctx.close(); } /* ---- Ein Modi am Handy ---------------------------------------- ZWEI ANNAHMEN HIER WAREN FALSCH, und beide standen vier Zeilen weiter oben im selben Lauf richtig da: "ein Modi stellt etwas bereit (201, Nummer 4)" Ein Modi GEHOERT zum Team Dogi. Er darf einstellen, und an seinen EIGENEN Stuecken darf er selbstverstaendlich auch aendern. Die erste Fassung verlangte "kein einziger Verwaltungsknopf" und "kein Einstellkasten" -- das waere keine Pruefung gewesen, sondern der Wunsch, recht zu behalten. Was wirklich zaehlt, ist die Grenze: seine eigenen ja, fremde nein. Und die wird nicht geschaetzt, sondern gegen die Liste gerechnet, die der Server ihm selbst schickt. */ const b = await seiteAls("modi", "CODE-MODI-0001", 390); try { const seinStand = (await liste(modi.keks)).json || {}; const darfIds = new Set((seinStand.material || []) .filter((m) => m.darf_ich && !m.genommen).map((m) => String(m.id))); const fremdIds = new Set((seinStand.material || []) .filter((m) => !m.darf_ich && !m.genommen).map((m) => String(m.id))); const handy = await b.s.evaluate(() => ({ karten: document.querySelectorAll(".m-karte").length, mitVerwalten: [...document.querySelectorAll(".m-karte")] .filter((k) => k.querySelector(".m-karte__verwalten")) .map((k) => k.dataset.id), einstellen: !document.getElementById("neu-block").hidden, breite: document.documentElement.scrollWidth, sichtbar: document.documentElement.clientWidth, /* Jeder Kategorieknopf muss am Finger zu treffen sein. */ kleinsteKat: Math.min(...[...document.querySelectorAll("#m-kat .schritt")] .map((x) => Math.round(x.getBoundingClientRect().height))), })); ok(handy.karten > 0, `der Modi sieht Stuecke (${handy.karten})`); const gezeigt = new Set(handy.mitVerwalten); const zuViel = [...gezeigt].filter((id) => fremdIds.has(id)); const zuWenig = [...darfIds].filter((id) => !gezeigt.has(id)); /* DIE ANZAHL GEHOERT IN DIE BEDINGUNG: Ohne `darfIds.size > 0` waeren beide Listen leer und die Zeile gruen, ohne dass je ein Knopf geprueft wurde. */ ok(darfIds.size > 0 && fremdIds.size > 0 && zuViel.length === 0 && zuWenig.length === 0, `Verwaltungsknoepfe genau an seinen eigenen ` + `(${gezeigt.size} gezeigt, ${darfIds.size} erlaubt, ` + `${fremdIds.size} fremde daneben)` + (zuViel.length ? ` -- zu viel an: ${zuViel.join(", ")}` : "") + (zuWenig.length ? ` -- es fehlt an: ${zuWenig.join(", ")}` : "")); ok(handy.einstellen === true, "der Einstellkasten steht da -- ein Modi gehoert zum Team und stellt bereit"); ok(handy.breite <= handy.sichtbar + 1, `nichts laeuft seitlich aus dem Bild (${handy.breite} von ${handy.sichtbar} px)`); ok(handy.kleinsteKat >= 26, `die Kategorieknoepfe sind am Finger zu treffen (${handy.kleinsteKat} px)`); } finally { await b.ctx.close(); } /* ---- Und die Community: gar kein Kasten ----------------------- DAS IST DIE GEGENPROBE zur Zeile darueber. Waere der Kasten einfach immer da, waere "er steht da" genauso gruen. */ const c = await seiteAls("gast", "CODE-GAST-0001", 390); try { const gast = await c.s.evaluate(() => ({ karten: document.querySelectorAll(".m-karte").length, einstellen: !document.getElementById("neu-block").hidden, verwalten: document.querySelectorAll(".m-karte__verwalten").length, breite: document.documentElement.scrollWidth, sichtbar: document.documentElement.clientWidth, })); ok(gast.karten > 0, `die Community sieht die Stuecke (${gast.karten})`); ok(gast.einstellen === false, "aber keinen Einstellkasten -- ein Formular, das eine Absage bringt, ist schlimmer als keins"); ok(gast.verwalten === 0, `und keinen einzigen Verwaltungsknopf (${gast.verwalten})`); ok(gast.breite <= gast.sichtbar + 1, `auch bei ihr laeuft nichts aus dem Bild (${gast.breite} von ${gast.sichtbar} px)`); } finally { await c.ctx.close(); } ok(meckern.length === 0, meckern.length ? `der Browser meldet: ${meckern.slice(0, 3).join(" | ")}` : "kein Absturz und keine Fehlermeldung im Browser"); } finally { await browser.close(); await new Promise((r) => vorbau.close(r)); } } /* ===================================================================== ERGEBNIS ===================================================================== */ melde(""); /* Die Zahl gehoert in die Bedingung, nicht nur in den Text: Ein Lauf, der nichts geprueft hat, ist kein bestandener Lauf. */ /* Von 40 ueber 90 auf 145 angehoben (22.09.2026), als das Bearbeiten, das Zeitfenster und der Blick am echten Bildschirm dazukamen. Die Zahl ist keine Schaetzung: Sie steht knapp unter dem, was der Lauf danach tatsaechlich gemeldet hat (159). Bei 90 waere der ganze Browserabschnitt still ausfallen koennen, ohne dass die Zahl etwas gesagt haette -- und genau das soll sie fangen. */ if (geprueft < 150) { console.log(` FEHL nur ${geprueft} Pruefungen gelaufen -- erwartet waren 150+`); fehler++; } melde(fehler === 0 ? `Alles in Ordnung. ${geprueft} Pruefungen, 0 Fehler.` : `${fehler} FEHLER bei ${geprueft} Pruefungen.`); melde(""); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } process.exit(fehler ? 1 : 0);