BEIM AUSLIEFERN AUFGEFALLEN, NICHT BEIM BAUEN. Nach Neustart und `git pull` standen die zwei neuen Spalten noch nicht in der echten Ablage -- die Umstellung laeuft erst beim ersten angemeldeten Zugriff. Das ist in Ordnung. Beim Nachsehen, WARUM sie noch nicht da waren, fiel aber der eigentliche Mangel auf: `bereit = true` stand UNBEDINGT hinter der Schleife, und der Fehler des `ALTER TABLE` daneben ging still in die Konsole. Ein einziger misslungener Versuch -- Datei gesperrt, Platte voll -- haette die Umstellung damit fuer immer als erledigt vermerkt. Danach scheitert JEDE Liste an `SELECT ... m.gilt_ab`, und zwar fuer alle, mit einem 503 und dem Satz "gerade nicht verfuegbar", der nirgends sagt, woran es liegt. Bis zum naechsten Neustart. Jetzt wird nach der Schleife nachgezaehlt. Fehlt etwas, wird geworfen statt notiert, und der Vermerk bleibt aus -- der naechste Aufruf versucht es also wieder. Ein Aufruf, der mit einer klaren Meldung scheitert, ist besser als hundert, die raetseln. AUF EINER KOPIE DURCHGESPIELT, nicht an den echten Daten erlebt: Sicherung der laufenden Ablage gezogen (`.backup`, integrity_check ok, Zeilenzahlen gegen live geprueft), darauf beide ALTER ausgefuehrt. Die vorhandene Zeile ueberlebt mit leerem Fenster, die Listenabfrage des Servers laeuft wortgleich durch, integrity_check danach ok. pruef-material 159/0 (von 155). Neu ist eine Gegenprobe, die den halb-umgestellten Zustand herstellt -- genau den, der vorher "erledigt" geheissen haette -- und eine Zeile, die die Spalten in der laufenden Ablage nachzaehlt statt sie anzunehmen. Co-Authored-By: Claude Opus 5 <[email protected]>
1095 lines
52 KiB
JavaScript
1095 lines
52 KiB
JavaScript
/* =====================================================================
|
|
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);
|