/* ===================================================================== Prüfung: aussuchen ODER selbst eintragen (02.09.2026). Wunsch: "ich will da auch sachen selber noch eintragen können, also aussuchen und selbst eintragen." — an allen fünf Stellen mit Personenauswahl. Der gefährliche Teil ist NICHT, ob sich ein Name eintippen lässt. Gefährlich ist, was danach mit dem Eintrag passiert: Er gehört zu keiner Person mehr, und die halbe Anwendung sucht Einträge über genau diese Verknüpfung. Ein frei benannter Eintrag könnte deshalb lautlos verschwinden — aus der Liste, aus der Suche, aus der Übersicht. Niemand bekäme eine Fehlermeldung; es wäre einfach nichts mehr da. Deshalb prüft diese Datei vor allem eins: DASS NICHTS VERSCHWINDET. Dazu kommt die Wiederentdeckung eines Fehlers, der schon vorher da war: Legte eine Managerin eine Aufgabe ohne Creator an, sah sie sie selbst nicht mehr. Auch das steht hier — samt Gegenprobe. ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-frei-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = "4192"; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1"; 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"); await new Promise((r) => setTimeout(r, 700)); const BASIS = "http://127.0.0.1:4192"; setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 240_000).unref?.(); let fehler = 0; let geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); function anlegen(name, rolle, code) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, jetzt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); const idCigdem = anlegen("Cigdem", "manager", "CODE-CIGDEM-01"); const idBen = anlegen("Ben", "scout", "CODE-BEN-00001"); const idTili = anlegen("Tili", "creator", "CODE-TILI-0001"); /* Cigdem betreut niemanden -- genau der Fall, in dem der alte Fehler zuschlug. */ d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)") .run(idTili, idBen, jetzt); const zahl = (sql, ...w) => d.prepare(sql).get(...w)?.n ?? 0; const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); async function anmelden(rolle, code, breite = 1440) { const kontext = await browser.newContext({ viewport: { width: breite, height: 1000 } }); const seite = await kontext.newPage(); await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); await seite.click(`.rolle[data-rolle="${rolle}"]`); await seite.fill("#code", code); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 15000 }); return seite; } const api = (seite, pfad, optionen) => seite.evaluate(async ([p, o]) => { const a = await fetch(p, { credentials: "same-origin", ...(o || {}), headers: o?.koerper ? { "Content-Type": "application/json" } : (o?.kopf || undefined), body: o?.koerper ? JSON.stringify(o.koerper) : undefined, }); return { status: a.status, daten: await a.json().catch(() => ({})) }; }, [pfad, optionen]); const seiteDogi = await anmelden("admin", "CODE-DOGI-0001"); const seiteCigdem = await anmelden("manager", "CODE-CIGDEM-01"); /* =================================================================== TEIL 1 — Der alte Fehler: eine Aufgabe, die sich selbst versteckt =================================================================== */ console.log("\n— Eine Aufgabe ohne Creator bleibt sichtbar —"); const ohne = await api(seiteCigdem, "/workspace/api/aufgaben", { method: "POST", koerper: { titel: "Ohne Zuordnung angelegt", prioritaet: "hoch" }, }); ok(ohne.status === 201, "Cigdem legt eine Aufgabe ohne Creator an"); const sichtCigdem = await api(seiteCigdem, "/workspace/api/aufgaben"); ok(sichtCigdem.daten.aufgaben?.some((a) => a.id === ohne.daten.id), "und SIEHT sie danach auch – genau das ging vorher verloren"); /* GEGENPROBE: Es sieht sie nicht plötzlich jeder. Wäre die Regel zu weit geraten, wäre die Zeile darüber ebenfalls grün — und niemand hätte gemerkt, dass Cigdem jetzt fremde Aufgaben sieht. */ const seiteBen = await anmelden("scout", "CODE-BEN-00001"); const sichtBen = await api(seiteBen, "/workspace/api/aufgaben"); ok(!sichtBen.daten.aufgaben?.some((a) => a.id === ohne.daten.id), "GEGENPROBE: Ben sieht Cigdems Aufgabe NICHT – die Regel ist nicht zu weit"); /* =================================================================== TEIL 2 — Freie Namen: nichts darf verschwinden =================================================================== */ console.log("\n— Aufgabe mit frei eingetragenem Namen —"); const frei = await api(seiteDogi, "/workspace/api/aufgaben", { method: "POST", koerper: { titel: "Thumbnails von der Agentur", prioritaet: "mittel", creator_extern: "Marke XY", verantwortlich_extern: "Agentur Müller", }, }); ok(frei.status === 201, "eine Aufgabe mit zwei freien Namen lässt sich anlegen"); ok(zahl("SELECT COUNT(*) AS n FROM aufgaben WHERE id = ? AND creator_id IS NULL AND creator_extern = 'Marke XY'", frei.daten.id) === 1, "der Name steht in der eigenen Spalte, die Verknüpfung bleibt leer"); const liste = await api(seiteDogi, "/workspace/api/aufgaben"); const eintrag = liste.daten.aufgaben.find((a) => a.id === frei.daten.id); ok(!!eintrag, "die Aufgabe steht in der Liste – sie ist nicht verschwunden"); ok(eintrag?.creator_name === "Marke XY (extern)", `der Name wird angezeigt und als extern gekennzeichnet ("${eintrag?.creator_name}")`); ok(eintrag?.verantwortlich_name === "Agentur Müller (extern)", `auch der Verantwortliche ("${eintrag?.verantwortlich_name}")`); const suche = await api(seiteDogi, "/workspace/api/suche?q=Thumbnails"); const gefunden = JSON.stringify(suche.daten); ok(gefunden.includes("Thumbnails von der Agentur"), "die Suche findet sie – der häufigste Ort, an dem so etwas lautlos fehlt"); const uebersicht = await api(seiteDogi, "/workspace/api/uebersicht"); ok((uebersicht.daten.offen ?? 0) >= 1, "die Übersicht zählt sie mit"); console.log("\n— Termin mit frei eingetragenem Namen —"); const morgen = new Date(Date.now() + 86400_000).toISOString().slice(0, 10); const termin = await api(seiteDogi, "/workspace/api/termine", { method: "POST", koerper: { titel: "Call mit der Agentur", art: "call", beginn: morgen + "T14:00", dauer_min: 30, teilnehmer_extern: "Agentur Müller", }, }); ok(termin.status === 201, "ein Termin mit freiem Namen lässt sich anlegen"); const termine = await api(seiteDogi, `/workspace/api/termine?von=${morgen}&tage=2`); const t = termine.daten.termine?.find((x) => x.id === termin.daten.id); ok(!!t, "er steht im Kalender"); ok(t?.teilnehmer_name === "Agentur Müller (extern)", `mit Namen und Kennzeichnung ("${t?.teilnehmer_name}")`); console.log("\n— Wiederholung mit frei eingetragenem Namen —"); const serie = await api(seiteDogi, "/workspace/api/serien", { method: "POST", koerper: { titel: "Wochen-Call Agentur", art: "call", takt: "woechentlich", start_tag: morgen, uhrzeit: "11:00", teilnehmer_extern: "Agentur Müller", }, }); ok(serie.status === 201, "eine Wiederholung mit freiem Namen lässt sich anlegen"); ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND teilnehmer_extern = 'Agentur Müller'", serie.daten.id) > 20, "der Name landet auf JEDER erzeugten Ausprägung, nicht nur auf der Regel"); console.log("\n— Bereichseintrag mit frei eingetragenem Namen —"); const eintragB = await api(seiteCigdem, "/workspace/api/bereich/live", { method: "POST", koerper: { art: "vorbereitung", titel: "Technikpartner meldet Ausfall", datum: morgen, creator_extern: "Technikpartner" }, }); ok(eintragB.status === 201, "ein Bereichseintrag mit freiem Namen lässt sich anlegen" + (eintragB.status === 201 ? "" : " · " + (eintragB.daten.fehler || eintragB.status))); const bereich = await api(seiteCigdem, "/workspace/api/bereich/live"); const be = bereich.daten.eintraege?.find((x) => x.id === eintragB.daten.id); ok(!!be, "Cigdem sieht ihn danach – auch ohne zugeteilten Creator"); ok(be?.creator_name === "Technikpartner (extern)", `mit Namen und Kennzeichnung ("${be?.creator_name}")`); console.log("\n— Grenzen —"); for (const [koerper, was, erwartet] of [ [{ titel: "Langer Name", creator_extern: "A".repeat(200) }, "zu langer Name", 400], [{ titel: "Beides gesetzt", creator_id: idTili, creator_extern: "Gast" }, "Person UND Name → Name gewinnt", 201], ]) { const a = await api(seiteDogi, "/workspace/api/aufgaben", { method: "POST", koerper }); ok(a.status === erwartet, `${was} → ${a.status}${a.status === erwartet ? "" : " · " + (a.daten.fehler || "")}`); if (a.status === 201) { ok(zahl("SELECT COUNT(*) AS n FROM aufgaben WHERE id = ? AND creator_id IS NULL", a.daten.id) === 1, " und die Verknüpfung ist dabei wirklich leer – nie beides zugleich"); } } /* =================================================================== TEIL 3 — Die Bedienung im Browser =================================================================== */ console.log("\n— Suchen und eintragen im echten Browser —"); await seiteDogi.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" }); await seiteDogi.waitForTimeout(800); await seiteDogi.click("#neu-oeffnen").catch(() => {}); await seiteDogi.waitForTimeout(400); /* Das Auswahlfeld ist durch wahl.js ein Knopf -- angeklickt wird der. */ const knopf = seiteDogi.locator('#f-creator').locator('xpath=following-sibling::button').first(); await knopf.click(); await seiteDogi.waitForTimeout(300); const hatSuche = await seiteDogi.locator(".wahl2__suche").count(); ok(hatSuche === 1, "die geöffnete Liste hat ein Suchfeld"); await seiteDogi.fill(".wahl2__suche", "Til"); await seiteDogi.waitForTimeout(200); const nachFilter = await seiteDogi.evaluate(() => [...document.querySelectorAll(".wahl2__eintrag")].map((e) => e.textContent.trim())); ok(nachFilter.some((t) => t.includes("Tili")), "Tippen filtert die Liste auf Tili"); ok(!nachFilter.some((t) => t.includes("Cigdem")), "und blendet die anderen aus"); await seiteDogi.fill(".wahl2__suche", "Agentur Müller"); await seiteDogi.waitForTimeout(200); const eigen = await seiteDogi.locator(".wahl2__eintrag--eigen").count(); ok(eigen === 1, "ein unbekannter Name wird als eigener Eintrag angeboten"); await seiteDogi.locator(".wahl2__eintrag--eigen").click(); await seiteDogi.waitForTimeout(200); const zustand = await seiteDogi.evaluate(() => ({ extern: document.getElementById("f-creator").dataset.extern, wert: document.getElementById("f-creator").value, knopftext: document.querySelector('#f-creator + .wahl2__knopf .wahl2__text')?.textContent || document.querySelector('.wahl2 .wahl2__text')?.textContent, hinweis: document.querySelector(".wahl2__hinweis")?.textContent || "", })); ok(zustand.extern === "Agentur Müller", `der Name steht am Feld ("${zustand.extern}")`); ok(zustand.wert === "", "und die Personenauswahl ist dabei leer – nie beides"); ok(/Agentur Müller/.test(zustand.knopftext || ""), "der Knopf zeigt den Namen an"); ok(zustand.hinweis.length > 20, "unter dem Feld steht, was ein freier Name bedeutet"); /* GEGENPROBE: Ohne Erlaubnis darf NICHTS erfunden werden. Die Säulenauswahl im Content ist genau so ein Feld -- eine Säule gehört immer einem echten Creator, der Server lehnte alles andere ab. */ await seiteDogi.goto(BASIS + "/workspace/content.html", { waitUntil: "networkidle" }); await seiteDogi.waitForTimeout(800); const freiFlags = await seiteDogi.evaluate(() => ({ creator: document.getElementById("f-creator")?.dataset.frei, saeule: document.getElementById("s-creator")?.dataset.frei, })); ok(freiFlags.creator === "ja", "im Content darf man beim Creator selbst eintragen"); ok(freiFlags.saeule === "nein", "GEGENPROBE: bei der Säulen-Zuordnung ausdrücklich NICHT – dort gäbe es der Server nicht her"); await seiteDogi.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" }); await seiteDogi.waitForTimeout(600); await seiteDogi.click("#neu-oeffnen").catch(() => {}); await seiteDogi.waitForTimeout(300); await seiteDogi.screenshot({ path: "pruef-freie-namen.png", fullPage: false }); /* =================================================================== Abschluss — die Anzahl zählt mit =================================================================== */ const ERWARTET = 31; console.log(""); ok(geprueft >= ERWARTET, `es sind ${geprueft} Prüfungen gelaufen (erwartet mindestens ${ERWARTET})`); await browser.close(); d.close(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(`\n${fehler ? "FEHLER: " + fehler : "Alles in Ordnung"} — ${geprueft} Prüfungen`); process.exit(fehler ? 1 : 0);