/* Prueft die fertigen Vorlagen und was daraus wird. Der Kern ist nicht "es gibt Vorlagen", sondern was beim UEBERNEHMEN passiert: Aus einem Vorschlag muss ein ganz normaler Eintrag werden -- aenderbar, loeschbar, und ab dann unabhaengig von der Vorlage. Und "Hilfe anfordern" muss eine AUFGABE erzeugen, keine Nachricht. Eine Nachricht ist gelesen und dann weg; eine Aufgabe bleibt stehen, bis sie jemand erledigt. Geprueft wird deshalb, dass sie beim richtigen Betreuer landet und eine Frist hat. */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-vorl-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = "4204"; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_ACCESS_CODE_DOGI = "DOGI-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:4204"; setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 250_000).unref?.(); let fehler = 0; const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes } = await import("node:crypto"); for (let i = 0; i < 60; i++) { try { const p = new DatabaseSync(process.env.WORKSPACE_DB); try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } } catch { await new Promise((r) => setTimeout(r, 250)); } } const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); function anlegen(name, rolle, code) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, jetzt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001"); const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001"); d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idScout, jetzt); d.close(); const keks = async (rolle, code) => { const a = await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle, code }) }); return (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; "); }; const kDogi = await keks("admin", "CODE-DOGI-0001"); const kTili = await keks("creator", "CODE-ANNA-0001"); const hole = (pfad, k, o = {}) => fetch(BASIS + pfad, { ...o, headers: { Cookie: k, ...(o.headers || {}) } }); /* ---------- Was es gibt --------------------------------------------------- */ console.log("\n=== Die Vorlagen"); let vorlagen; { const a = await hole("/workspace/api/vorlagen", kTili); vorlagen = await a.json(); ok(a.ok, "die Vorlagen sind abrufbar"); ok(vorlagen.content.length >= 12, `genug Content-Ideen (${vorlagen.content.length})`); ok(vorlagen.live.vorbereitung.length >= 6, `Vorbereitungspunkte (${vorlagen.live.vorbereitung.length})`); ok(vorlagen.live.mitschrift.length >= 5, `Punkte fuer waehrend LIVE (${vorlagen.live.mitschrift.length})`); ok(vorlagen.live.auswertung.length >= 5, `Auswertungspunkte (${vorlagen.live.auswertung.length})`); /* Jede Content-Idee braucht einen ausformulierten Aufhaenger -- die ersten Sekunden entscheiden, und "mach was Persoenliches" hilft niemandem. */ const ohneHook = vorlagen.content.filter((v) => !v.hook || v.hook.length < 15); ok(ohneHook.length === 0, `jede Idee hat einen ausformulierten Aufhaenger (${ohneHook.length} ohne)`); const ohneFormat = vorlagen.content.filter((v) => !v.format); ok(ohneFormat.length === 0, `und ein Format (${ohneFormat.length} ohne)`); /* 70/20/10: der groesste Teil muss Inhalte mit Wert sein. */ const zaehl = {}; for (const v of vorlagen.content) zaehl[v.saeule] = (zaehl[v.saeule] || 0) + 1; const anteilWert = zaehl.Wert / vorlagen.content.length; ok(anteilWert >= 0.45, `der groesste Teil sind Inhalte mit Wert (${Math.round(anteilWert * 100)} %: ${JSON.stringify(zaehl)})`); /* Punkte, bei denen man Hilfe braucht, muessen als solche erkennbar sein -- sonst weiss niemand, wo er fragen darf. */ const mitHilfe = Object.values(vorlagen.live).flat().filter((v) => v.hilfe); ok(mitHilfe.length >= 5, `Punkte mit Hilfe-Angebot (${mitHilfe.length})`); } /* ---------- Uebernehmen --------------------------------------------------- */ console.log("\n=== Uebernehmen"); { /* Ein Creator uebernimmt fuer sich selbst -- ohne auf jemanden zu warten. */ const a = await hole("/workspace/api/vorlagen/uebernehmen", kTili, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ art: "content", nr: 0 }) }); const r = await a.json(); ok(a.ok && r.angelegt === 1, `ein Creator darf selbst uebernehmen (${r.angelegt})`); const liste = await (await hole("/workspace/api/bereich/content", kTili)).json(); const drin = liste.eintraege.find((e) => e.titel === vorlagen.content[0].titel); ok(!!drin, `die Idee steht jetzt in seiner Liste ("${drin?.titel}")`); /* Der Aufhaenger muss MITKOMMEN -- sonst ist die halbe Vorlage weg. */ ok(drin?.hook === vorlagen.content[0].hook, `mit dem Aufhaenger (${drin?.hook?.slice(0, 30)}…)`); ok(drin?.format === vorlagen.content[0].format, `und dem Format (${drin?.format})`); ok(drin?.art === "idee", `und beginnt bei "Idee" (${drin?.art})`); /* Sie ist ab jetzt ein NORMALER Eintrag: aenderbar. Das ist der eigentliche Punkt an Vorlagen gegenueber fest eingebauten Daten. */ const p = await hole(`/workspace/api/bereich/content/${drin.id}`, kTili, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ titel: "Selbst umbenannt" }) }); ok(p.ok, `und laesst sich aendern (${p.status})`); /* Alle auf einmal. */ const b = await hole("/workspace/api/vorlagen/uebernehmen", kTili, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ art: "live_alle", unterart: "vorbereitung" }) }); const rb = await b.json(); ok(b.ok && rb.angelegt === vorlagen.live.vorbereitung.length, `alle Vorbereitungspunkte auf einmal (${rb.angelegt})`); const live = await (await hole("/workspace/api/bereich/live", kTili)).json(); ok(live.eintraege.filter((e) => e.art === "vorbereitung").length === rb.angelegt, "und sie stehen alle im Bereich LIVE"); } /* Ein Betreuer uebernimmt fuer SEINEN Creator -- und nur fuer den. */ console.log("\n=== Wer fuer wen"); { const fremd = anlegenFremd(); const a = await hole("/workspace/api/vorlagen/uebernehmen", kDogi, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ art: "content", nr: 1, creator_id: idTili }) }); ok(a.ok, `DogFather darf fuer Tili uebernehmen (${a.status})`); const kScout = await keks("scout", "CODE-SCOU-0001"); const b = await hole("/workspace/api/vorlagen/uebernehmen", kScout, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ art: "content", nr: 2, creator_id: fremd }) }); ok(b.status === 404, `ein Scout NICHT fuer einen fremden Creator -- und erfaehrt nicht, dass es ihn gibt (${b.status})`); } function anlegenFremd() { const d2 = new DatabaseSync(process.env.WORKSPACE_DB); const salt = randomBytes(16).toString("hex"); const hash = scryptSync("CODE-FREM-0001", salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d2.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") .run("Fremd", "creator", hash, salt, 32768, jetzt); const id = d2.prepare("SELECT last_insert_rowid() AS id").get().id; d2.close(); return id; } /* ---------- Hilfe anfordern ----------------------------------------------- */ console.log("\n=== Hilfe anfordern"); { const a = await hole("/workspace/api/vorlagen/hilfe", kTili, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ thema: "Ton geprüft", notiz: "Mein Mikro rauscht." }) }); ok(a.ok, `ein Creator kann Hilfe anfordern (${a.status})`); /* Es muss eine AUFGABE geworden sein, nicht eine Nachricht. */ const d3 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); const auf = d3.prepare( "SELECT * FROM aufgaben WHERE titel LIKE 'Hilfe gewünscht%' ORDER BY id DESC LIMIT 1").get(); d3.close(); ok(!!auf, "daraus wird eine Aufgabe"); ok(auf?.verantwortlich_id === idScout, `sie geht an seinen Betreuer Patrick (${auf?.verantwortlich_id} = ${idScout})`); ok(auf?.creator_id === idTili, "und haengt an ihm"); ok(auf?.prioritaet === "hoch", `mit hoher Prioritaet (${auf?.prioritaet})`); /* Ohne Frist bleibt sie liegen -- das ist der Unterschied zwischen einer Aufgabe und einem Zettel. */ ok(!!auf?.frist, `und einer Frist (${auf?.frist})`); ok(String(auf?.beschreibung || "").includes("Mein Mikro rauscht"), "die Notiz steht mit drin"); /* Der Betreuer sieht sie auf seiner Startseite. */ const kScout = await keks("scout", "CODE-SCOU-0001"); /* Sie steht in SEINER Aufgabenliste. Als Hinweis auf der Startseite erscheint sie erst, wenn die Frist naeher rueckt -- eine Aufgabe mit drei Tagen Zeit ist weder ueberfaellig noch heute faellig, und das ist richtig so. Ein erster Anlauf hat genau das geprueft und war rot, obwohl das Verhalten stimmte. */ const seine = await (await hole("/workspace/api/aufgaben", kScout)).json(); const dabei = (seine.aufgaben || []).some((x) => String(x.titel).startsWith("Hilfe gewünscht")); ok(dabei, `und steht in seiner Aufgabenliste (${(seine.aufgaben || []).length} Aufgaben)`); /* Ohne Thema geht nichts -- eine leere Bitte hilft niemandem. */ const leer = await hole("/workspace/api/vorlagen/hilfe", kTili, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ thema: "" }) }); ok(leer.status === 400, `ohne Thema wird abgelehnt (${leer.status})`); } /* ---------- Im Browser ---------------------------------------------------- */ console.log("\n=== Die Seite"); const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); { const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } }); const konsole = []; const seite = await kontext.newPage(); seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); await seite.click('.rolle[data-rolle="creator"]'); await seite.fill("#code", "CODE-ANNA-0001"); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 15000 }); for (const [seiteName, art] of [["content.html", "Ideen"], ["bereich.html?b=live", "Punkte"]]) { await seite.goto(BASIS + "/workspace/" + seiteName, { waitUntil: "networkidle" }); await seite.waitForTimeout(900); const r = await seite.evaluate(() => ({ da: !document.getElementById("vorlagen")?.hidden, knopf: document.querySelector(".vorlagen__kopf .knopf")?.textContent?.trim(), offen: document.querySelector(".vorlagen__raster")?.hidden === false, anzahl: document.querySelectorAll(".vorlage").length, })); ok(r.da, `${seiteName}: der Vorlagenblock steht da`); ok(/Vorschläge zeigen/.test(r.knopf || ""), ` mit Aufklappknopf ("${r.knopf}")`); ok(r.offen === false, " und ist zunächst zu"); ok(r.anzahl > 0, ` ${r.anzahl} Vorschläge dahinter`); /* Aufklappen muss wirklich etwas zeigen. */ await seite.click(".vorlagen__kopf .knopf"); await seite.waitForTimeout(350); const auf = await seite.evaluate(() => ({ offen: document.querySelector(".vorlagen__raster")?.hidden === false, sichtbar: [...document.querySelectorAll(".vorlage")].filter((v) => v.offsetParent !== null).length, hooks: document.querySelectorAll(".vorlage__hook").length, })); ok(auf.offen && auf.sichtbar > 0, ` aufgeklappt sind ${auf.sichtbar} zu sehen`); if (art === "Ideen") { ok(auf.hooks === auf.sichtbar, ` und jede zeigt ihren Aufhänger (${auf.hooks})`); } } /* Übernehmen über die Oberfläche -- nicht nur über die Schnittstelle. */ await seite.goto(BASIS + "/workspace/content.html", { waitUntil: "networkidle" }); await seite.waitForTimeout(900); await seite.click(".vorlagen__kopf .knopf"); await seite.waitForTimeout(300); /* Die Karten heissen dort .video -- ein erster Anlauf suchte nach ".karte" und zaehlte deshalb immer null. */ const vorher = await seite.evaluate(() => document.querySelectorAll(".video").length); await seite.click(".vorlage .schritt"); await seite.waitForTimeout(1200); const nachher = await seite.evaluate(() => ({ karten: document.querySelectorAll(".video").length, genommen: document.querySelectorAll('.vorlage[data-genommen="ja"]').length, })); ok(nachher.karten > vorher, `Übernehmen legt wirklich an (${vorher} → ${nachher.karten})`); ok(nachher.genommen === 1, `und die Vorlage ist als übernommen markiert (${nachher.genommen})`); ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`); await seite.screenshot({ path: "pruef-vorlagen.png", fullPage: false }); await kontext.close(); } await browser.close(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); process.exit(fehler ? 1 : 0);