/* ===================================================================== MANAGER-ZIELE (02.10.2026) ===================================================================== Geprüft wird, was Filipe bestellt hat — und fünf Dinge, die er nicht bestellt hat, weil sie sonst niemandem auffallen: * JEDE Sperre muss auch NEIN sagen können. Eine Prüfung, die nur den Erfolgsfall ansieht, beweist nichts: `every()` liefert auf einem leeren Feld `true`, und ein Trigger, dessen Spaltenname falsch ist, lehnt die Änderung ab, ohne je gelaufen zu sein (RunOne, 01.09.2026). Zu jeder „geht"-Prüfung steht hier eine „geht nicht"-Prüfung. * DIE DATUMSLOGIK WIRD MIT EINGESETZTEN TAGEN GEMESSEN, nie gegen die Wanduhr. `gate-oeffnung.mjs` im Shop war grün, bis der Kalender sie überholte, und dann rot, ohne dass etwas kaputt war. Hier liegen die Tage als Zahl im Aufruf — diese Prüfung sagt am 16. November dasselbe wie heute. * DIE ZAHL DER GEPRÜFTEN DINGE STEHT IN DER BEDINGUNG, nicht nur im Meldetext. „Alle Rollen abgewiesen" ist auf einer leeren Liste wahr. * DIE BEIDEN HÄUSER BLEIBEN GETRENNT. Auf der Crew-Adresse gibt es diese Kachel nicht, auch nicht für DogFather. * DER MONATSSCHNITT ÄNDERT DEN VERLAUF NICHT. Wer die Zielzahlen erhöht, darf einen abgeschlossenen Monat nicht nachträglich auf „nicht erreicht" drehen. ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-mz-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); import { notbremse } from "./helfer-notbremse.mjs"; const { eigenerPort } = await import("./helfer-port.mjs"); const PORT = await eigenerPort(import.meta, "die Pruefung der Manager-Ziele"); process.env.PORT = `${PORT}`; 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"); notbremse(240_000, "pruef-manager-ziele"); await new Promise((r) => setTimeout(r, 900)); const BASIS = `http://127.0.0.1:${PORT}`; const API = "/workspace/api/manager-ziele"; let fehler = 0, geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const melde = (t) => console.log(t); /* DER DRITTE AUSGANG. Kann die Prüfung ihre Voraussetzung nicht herstellen, ist das WEDER in Ordnung NOCH ein Befund am Code -- sie muss es sagen und abbrechen, statt grün zu melden. */ function braucht(bedingung, was) { if (bedingung) return; console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}`); console.log("Das ist KEIN Befund am Code. Die Pruefung konnte ihre"); console.log("Voraussetzung nicht herstellen und sagt das, statt zu schweigen.\n"); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } process.exit(2); } /* ---------- Personen anlegen ---------------------------------------- */ const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetztIso = new Date().toISOString(); const kennSchluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", kennSchluessel, jetztIso); /* ERSTELLT: ein Jahr alt. Sonst wären alle erst im Folgemonat pflichtig (Regel 09 der Vorlage) und jede Ampelprüfung wäre neutral — die Prüfung sähe grün aus und hätte nichts gemessen. */ const ALT = new Date(Date.now() - 365 * 86400_000).toISOString(); function anlegen(name, rolle, code, erstellt = ALT) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n," + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", kennSchluessel).update(code).digest("hex"), erstellt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } const ROLLEN = [ ["admin", "Filipe"], ["spicy", "Spicy Media"], ["manager", "Patrick"], ["scout", "Kevin"], ["scout", "Sandra"], ["creator", "Luna"], ["hand", "VanVan"], ["modi", "Miss"], ]; const CODES = {}, IDS = {}; for (const [i, [rolle, name]] of ROLLEN.entries()) { const schluessel = IDS[rolle] === undefined ? rolle : `${rolle}2`; CODES[schluessel] = `CODE-Z${String(i).padStart(2, "0")}-000001`; IDS[schluessel] = anlegen(name, rolle, CODES[schluessel]); } d.close(); /* ---------- Anmelden ------------------------------------------------- */ const { request } = await import("node:http"); const CREW_HOST = "crew.dogfather-universe.com"; function roh(pfad, host, koerper, methode) { return new Promise((fertig, schief) => { const daten = koerper ? JSON.stringify(koerper) : null; const a = request({ host: "127.0.0.1", port: PORT, path: pfad, method: methode || (daten ? "POST" : "GET"), headers: { ...(host ? { Host: host } : {}), Origin: host ? `https://${host}` : BASIS, ...(daten ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(daten) } : {}), }, }, (antwort) => { let text = ""; antwort.on("data", (t) => { text += t; }); antwort.on("end", () => fertig({ code: antwort.statusCode, kopf: antwort.headers, text })); }); a.on("error", schief); if (daten) a.write(daten); a.end(); }); } /** Derselbe Aufruf mit Hausadresse UND Sitzung. `fetch` verwirft den * Host-Kopf (er steht auf der Liste der verbotenen Kopfzeilen), also * geht das nur ueber node:http. */ function rohMitKeks(pfad, host, keks) { return new Promise((fertig, schief) => { const a = request({ host: "127.0.0.1", port: PORT, path: pfad, method: "GET", headers: { Host: host, Origin: `https://${host}`, Cookie: keks || "" }, }, (antwort) => { let text = ""; antwort.on("data", (t) => { text += t; }); antwort.on("end", () => fertig({ code: antwort.statusCode, text })); }); a.on("error", schief); a.end(); }); } async function anmelden(schluessel, rolle, host = null) { const r = await roh("/workspace/api/anmelden", host, { rolle, code: CODES[schluessel] }); if (r.code !== 200) return null; return [].concat(r.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ") || null; } const kekse = {}; melde("\n=== Alle Rollen kommen herein ==="); for (const schluessel of Object.keys(CODES)) { const rolle = schluessel.replace(/2$/, ""); kekse[schluessel] = await anmelden(schluessel, rolle); ok(!!kekse[schluessel], `${schluessel}: angemeldet`); } braucht(Object.values(kekse).every(Boolean), "nicht alle Rollen konnten sich anmelden"); async function hol(schluessel, pfad, einst = {}) { return fetch(BASIS + pfad, { ...einst, headers: { Cookie: kekse[schluessel] || "", Origin: BASIS, ...(einst.body ? { "Content-Type": "application/json" } : {}), ...(einst.headers || {}), }, }); } const schick = (schluessel, pfad, methode, rumpf) => hol(schluessel, pfad, { method: methode, body: JSON.stringify(rumpf) }); /* ===================================================================== 1 · WER DARF HEREIN — und wer ausdrücklich nicht ===================================================================== */ melde("\n=== Sichtbarkeit: vier Rollen ja, der Rest nein ==="); const DUERFEN = ["admin", "spicy", "manager", "scout"]; const DUERFEN_NICHT = ["creator", "hand", "modi"]; let drin = 0; for (const r of DUERFEN) { const a = await hol(r, `${API}/stand`); if (a.status === 200) drin++; ok(a.status === 200, `${r} kommt an den Stand (${a.status})`); } /* DIE ZAHL STEHT IN DER BEDINGUNG. „Alle durchgelassen" wäre auf einer leeren Liste wahr. */ ok(drin === DUERFEN.length, `genau ${DUERFEN.length} Rollen kommen herein (${drin})`); let draussen = 0; for (const r of DUERFEN_NICHT) { const a = await hol(r, `${API}/stand`); if (a.status === 404) draussen++; ok(a.status === 404, `${r} bekommt 404 (nicht 403) (${a.status})`); } ok(draussen === DUERFEN_NICHT.length, `genau ${DUERFEN_NICHT.length} Rollen bleiben draussen (${draussen})`); melde("\n=== Die Seite selbst ist ebenfalls zu ==="); const seiteCreator = await hol("creator", "/workspace/manager-ziele.html", { redirect: "manual" }); ok(seiteCreator.status === 302, `Creator wird von der Seite weggeschickt (${seiteCreator.status})`); const seiteScout = await hol("scout", "/workspace/manager-ziele.html", { redirect: "manual" }); ok(seiteScout.status === 200, `Scout bekommt die Seite (${seiteScout.status})`); melde("\n=== Zwei getrennte Haeuser: auf crew. gibt es die Kachel nicht ==="); const crewKeks = await anmelden("admin", "admin", CREW_HOST); braucht(!!crewKeks, "DogFather konnte sich auf der Crew-Adresse nicht anmelden"); /* DER KEKS MUSS MIT. Beim ersten Lauf stand hier 401 statt 404 -- und das sah aus wie ein Befund an der Haustrennung. Es war einer an meiner Messung: Ohne Sitzung kommt man ueberall nicht herein, das beweist nichts ueber DIESE Kachel. */ const crewStand = await rohMitKeks(`${API}/stand`, CREW_HOST, crewKeks); ok(crewStand.code === 404, `DogFather bekommt auf crew. ein 404 auf die Schnittstelle (${crewStand.code})`); const crewMitKeks = await rohMitKeks("/workspace/start.html", CREW_HOST, crewKeks); ok(crewMitKeks.code === 200, `…und ist dort trotzdem angemeldet (${crewMitKeks.code}) — die Sitzung war es nicht`); /* ===================================================================== 2 · DIE AMPEL — reine Rechnung, mit eingesetzten Tagen ===================================================================== */ melde("\n=== Die Ampel (eingesetzte Tage, nicht die Wanduhr) ==="); const mz = await import("./workspace-manager-ziele.js"); /* Ein Monat mit 31 Tagen. `bisEnde = letzter - tag`. */ const L = 31; ok(mz.stufeFuer(3, 3, 5, L) === "gruen", "Ziel erreicht am 5. -> gruen"); ok(mz.stufeFuer(4, 3, 5, L) === "gruen", "uebererfuellt (4/3) -> gruen"); ok(mz.stufeFuer(0, 3, 1, L) === "neutral", "Tag 1, offen -> neutral"); ok(mz.stufeFuer(1, 3, 15, L) === "neutral", "Tag 15, offen -> neutral"); ok(mz.stufeFuer(1, 3, 16, L) === "orange", "Tag 16, offen -> orange"); ok(mz.stufeFuer(1, 3, 23, L) === "orange", "Tag 23 (8 Tage uebrig) -> noch orange"); ok(mz.stufeFuer(1, 3, 24, L) === "rot", "Tag 24 (7 Tage uebrig) -> rot"); ok(mz.stufeFuer(1, 3, 31, L) === "rot", "letzter Tag, offen -> rot"); ok(mz.stufeFuer(0, 0, 31, L) === "aus", "Ziel 0 -> aus, nie eine Warnung"); /* GEGENPROBE: Die Rechnung muss auch dann rot sagen koennen, wenn der 16. gar nicht erreicht wurde -- in einem kurzen Monat. */ ok(mz.stufeFuer(0, 1, 21, 28) === "rot", "Februar, Tag 21 (7 uebrig) -> rot"); ok(mz.stufeFuer(0, 1, 13, 28) === "neutral", "Februar, Tag 13 -> neutral"); ok(mz.stufenText("rot", 0) === "Dringend – heute ist der letzte Tag", "der letzte Tag bekommt seinen eigenen Satz"); ok(mz.stufenText("rot", 1) === "Dringend – noch 1 Tag", "Einzahl bei einem Tag"); ok(mz.stufenText("rot", 5) === "Dringend – noch 5 Tage", "Mehrzahl ab zwei Tagen"); ok(mz.stufenText("orange", 9) === "Sollte bis Monatsmitte erledigt sein", "der Orange-Satz steht woertlich wie in der Vorlage"); ok(mz.stufenText("neutral", 20) === "", "neutral sagt ausdruecklich nichts"); ok(mz.schlechteste(["gruen", "neutral", "rot"]) === "rot", "die Kachel traegt die schlechteste Farbe"); ok(mz.schlechteste(["gruen", "aus"]) === "gruen", "eine abgeschaltete Aufgabe verschlechtert nichts"); melde("\n=== Monatsrechnung ==="); ok(mz.letzterTagVon("2026-02") === 28, "Februar 2026 hat 28 Tage"); ok(mz.letzterTagVon("2024-02") === 29, "Februar 2024 hat 29 Tage"); ok(mz.letzterTagVon("2026-10") === 31, "Oktober hat 31 Tage"); ok(mz.monatPlus("2026-12", 1) === "2027-01", "Dezember + 1 = Januar des Folgejahres"); ok(mz.monatPlus("2026-01", -1) === "2025-12", "Januar - 1 = Dezember des Vorjahres"); /* Die Zeitzone: ein Zeitpunkt, der in UTC noch zum Vortag gehoert, aber in Berlin schon zum naechsten Tag. Ohne Europe/Berlin faende diese Zeile den falschen Tag -- und am Monatsersten den falschen Monat. */ ok(mz.tagBerlin(new Date("2026-06-30T23:30:00Z")) === "2026-07-01", "23:30 UTC am 30.06. ist in Berlin schon der 1. Juli"); /* ===================================================================== 3 · EINTRAGEN ===================================================================== */ melde("\n=== Eintragen ==="); const standVorher = await (await hol("scout", `${API}/stand`)).json(); braucht(standVorher && Array.isArray(standVorher.aufgaben), "der Stand kam nicht als Liste zurueck"); const MONAT = standVorher.monat; const HEUTE = standVorher.heute; ok(standVorher.aufgaben.length === 4, `vier Aufgaben (${standVorher.aufgaben.length})`); ok(standVorher.pflichtig === true, "ein ein Jahr alter Scout ist pflichtig"); const vorherCreator = standVorher.aufgaben.find((a) => a.schluessel === "creator"); ok(vorherCreator.ziel === 3, `Vorgabe fuer Creator ist 3 (${vorherCreator.ziel})`); ok(vorherCreator.zahl === 0, "noch nichts eingetragen"); const neu1 = await schick("scout", `${API}/eintrag`, "POST", { aufgabe: "creator", datum: HEUTE, name: "@LunaTanzt", notiz: "über Discord gefunden" }); const neu1d = await neu1.json(); ok(neu1.status === 201, `Creator-Eintrag angelegt (${neu1.status})`); ok(neu1d.stand?.aufgaben?.find((a) => a.schluessel === "creator")?.zahl === 1, "der Zaehler steht sofort auf 1 (ohne Neuladen)"); for (const [aufgabe, rumpf] of [ ["meeting", { datum: HEUTE }], ["schulung", { datum: HEUTE, art: "talk" }], ["werbung", { datum: HEUTE, art: "agentur", link: "https://www.tiktok.com/@dogi/video/123" }], ]) { const a = await schick("scout", `${API}/eintrag`, "POST", { aufgabe, ...rumpf }); ok(a.status === 201, `${aufgabe}: angelegt (${a.status})`); } const standNachher = await (await hol("scout", `${API}/stand`)).json(); ok(standNachher.gesamt.erledigt === 4, `Gesamtfortschritt 4 von 8 (${standNachher.gesamt.erledigt} von ${standNachher.gesamt.ziel})`); ok(standNachher.gesamt.ziel === 8, "Zielsumme 3+2+2+1 = 8"); ok(standNachher.aufgaben.find((a) => a.schluessel === "werbung").stufe === "gruen", "Werbung (1 von 1) steht auf gruen"); /* ===================================================================== 4 · WAS ABGELEHNT WERDEN MUSS — die Gegenproben ===================================================================== */ melde("\n=== Abgelehnt wird, was abgelehnt gehoert ==="); const morgen = new Date(Date.parse(HEUTE + "T12:00:00Z") + 86400_000).toISOString().slice(0, 10); const fremderMonat = `${mz.monatPlus(MONAT, -1)}-15`; const absagen = [ ["Datum in der Zukunft", { aufgabe: "meeting", datum: morgen }, "datum_zukunft"], ["Datum aus dem Vormonat", { aufgabe: "meeting", datum: fremderMonat }, "datum_fremder_monat"], ["gar kein Datum", { aufgabe: "meeting", datum: "" }, "datum_fehlt"], ["Creator ohne Namen", { aufgabe: "creator", datum: HEUTE, name: " " }, "name_fehlt"], ["Werbung ohne Link", { aufgabe: "werbung", datum: HEUTE, art: "eigen", link: "" }, "link_ungueltig"], ["Werbung mit javascript:", { aufgabe: "werbung", datum: HEUTE, art: "eigen", link: "javascript:alert(1)" }, "link_ungueltig"], ["Werbung mit blossem Wort", { aufgabe: "werbung", datum: HEUTE, art: "eigen", link: "tiktok" }, "link_ungueltig"], ["Schulung ohne Art", { aufgabe: "schulung", datum: HEUTE, art: "" }, "art_fehlt"], ["Aufgabe, die es nicht gibt", { aufgabe: "kaffee", datum: HEUTE }, "aufgabe_unbekannt"], ]; let abgelehnt = 0; for (const [was, rumpf, grund] of absagen) { const a = await schick("scout", `${API}/eintrag`, "POST", rumpf); const j = await a.json().catch(() => ({})); const richtig = a.status === 400 && j.fehler === grund; if (richtig) abgelehnt++; ok(richtig, `${was} -> abgelehnt mit „${grund}" (${a.status}/${j.fehler})`); } ok(abgelehnt === absagen.length, `alle ${absagen.length} Absagen kamen mit dem richtigen Grund (${abgelehnt})`); /* EINE GUELTIGE FREMDE ADRESSE MUSS DURCH -- Filipe am 02.10.2026: „jede gültige Internetadresse". Ohne diese Zeile bewiese der Block darueber nur, dass die Pruefung streng ist, nicht dass sie richtig ist. */ const fremdLink = await schick("scout", `${API}/eintrag`, "POST", { aufgabe: "werbung", datum: HEUTE, art: "eigen", link: "https://www.instagram.com/p/abc" }); ok(fremdLink.status === 201, `eine Instagram-Adresse wird angenommen (${fremdLink.status}) — nicht nur TikTok`); melde("\n=== Dubletten ==="); const doppelt = await schick("scout", `${API}/eintrag`, "POST", { aufgabe: "creator", datum: HEUTE, name: "lunatanzt" }); const doppeltD = await doppelt.json().catch(() => ({})); ok(doppelt.status === 409 && doppeltD.fehler === "schon_eingetragen", `„lunatanzt" gilt als derselbe wie „@LunaTanzt" (${doppelt.status})`); const anderer = await schick("scout2", `${API}/eintrag`, "POST", { aufgabe: "creator", datum: HEUTE, name: "@LunaTanzt" }); const andererD = await anderer.json().catch(() => ({})); ok(anderer.status === 201, `eine ANDERE Person darf denselben Namen eintragen (${anderer.status})`); ok(!!andererD.warnung, "…bekommt aber einen Hinweis, dass es jemand anders auch hat"); const neuerName = await schick("scout", `${API}/eintrag`, "POST", { aufgabe: "creator", datum: HEUTE, name: "@ZweiterCreator" }); ok(neuerName.status === 201, `ein anderer Name geht weiterhin (${neuerName.status})`); /* ===================================================================== 5 · AENDERN UND LOESCHEN ===================================================================== */ melde("\n=== Aendern und Loeschen ==="); const liste = await (await hol("scout", `${API}/eintraege?monat=${MONAT}&aufgabe=creator`)).json(); braucht(liste.eintraege?.length >= 2, "es gibt zu wenige Eintraege zum Aendern"); const einId = liste.eintraege.find((e) => e.name === "@ZweiterCreator").id; const geaendert = await schick("scout", `${API}/eintrag/${einId}`, "PATCH", { datum: HEUTE, name: "@ZweiterCreatorNeu", notiz: "Name berichtigt" }); ok(geaendert.status === 200, `eigener Eintrag geaendert (${geaendert.status})`); const fremdAendern = await schick("manager", `${API}/eintrag/${einId}`, "PATCH", { datum: HEUTE, name: "@Geklaut" }); ok(fremdAendern.status === 403, `ein Manager kann einen fremden Eintrag NICHT aendern (${fremdAendern.status})`); const adminAendern = await schick("admin", `${API}/eintrag/${einId}`, "PATCH", { datum: HEUTE, name: "@VonDogfatherBerichtigt" }); ok(adminAendern.status === 200, `DogFather darf korrigieren (${adminAendern.status})`); const dd = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); const protokolliert = dd.prepare( "SELECT COUNT(*) n FROM protokoll WHERE aktion = 'manager_ziele_korrigiert'").get().n; dd.close(); ok(protokolliert >= 1, `jede Korrektur steht im Protokoll (${protokolliert})`); const fremdLoeschen = await hol("manager", `${API}/eintrag/${einId}`, { method: "DELETE" }); ok(fremdLoeschen.status === 403, `ein Manager kann einen fremden Eintrag NICHT loeschen (${fremdLoeschen.status})`); const vorLoeschen = await (await hol("scout", `${API}/stand`)).json(); const geloescht = await hol("scout", `${API}/eintrag/${einId}`, { method: "DELETE" }); const geloeschtD = await geloescht.json().catch(() => ({})); ok(geloescht.status === 200, `eigener Eintrag geloescht (${geloescht.status})`); ok(geloeschtD.stand?.aufgaben?.find((a) => a.schluessel === "creator")?.zahl === vorLoeschen.aufgaben.find((a) => a.schluessel === "creator").zahl - 1, "der Zaehler geht nach dem Loeschen um genau eins zurueck"); /* ===================================================================== 6 · DER MONATSSCHNITT — die Sperre muss wirklich sperren ===================================================================== */ melde("\n=== Vergangene Monate sind zu ==="); const VORMONAT = mz.monatPlus(MONAT, -1); /* Eine Zeile aus dem Vormonat einsetzen. Das geht NUR mit geoeffneter Freigabe -- und genau das ist schon die erste Gegenprobe: Ohne sie lehnt der Trigger ab. */ const db2 = new DatabaseSync(process.env.WORKSPACE_DB); let triggerHaelt = false; try { db2.prepare("INSERT INTO mz_eintrag (person_id, aufgabe, monat, datum, erstellt)" + " VALUES (?,?,?,?,?)").run(IDS.scout, "meeting", VORMONAT, `${VORMONAT}-05`, jetztIso); } catch (f) { triggerHaelt = String(f?.message || "").includes("monat_gesperrt"); } ok(triggerHaelt, "die DATENBANK lehnt einen Eintrag im Vormonat ab (nicht erst der Code)"); const weit = new Date(Date.now() + 600_000).toISOString().replace("T", " ").slice(0, 19); db2.prepare("UPDATE mz_freigabe SET offen = 1, bis = ? WHERE eins = 1").run(weit); db2.prepare("INSERT INTO mz_eintrag (person_id, aufgabe, monat, datum, erstellt)" + " VALUES (?,?,?,?,?)").run(IDS.scout, "meeting", VORMONAT, `${VORMONAT}-05`, jetztIso); const altId = db2.prepare("SELECT last_insert_rowid() AS id").get().id; db2.prepare("UPDATE mz_freigabe SET offen = 0, bis = NULL WHERE eins = 1").run(); db2.close(); braucht(!!altId, "es liess sich keine Zeile im Vormonat anlegen"); const altAendern = await schick("scout", `${API}/eintrag/${altId}`, "PATCH", { datum: `${VORMONAT}-06` }); ok(altAendern.status === 409, `ein Scout kann im Vormonat nichts mehr aendern (${altAendern.status})`); const altLoeschen = await hol("scout", `${API}/eintrag/${altId}`, { method: "DELETE" }); ok(altLoeschen.status === 409, `…und auch nichts loeschen (${altLoeschen.status})`); const altAdmin = await schick("admin", `${API}/eintrag/${altId}`, "PATCH", { datum: `${VORMONAT}-07`, notiz: "Korrektur im Nachhinein" }); ok(altAdmin.status === 200, `DogFather darf auch im Vormonat korrigieren (${altAdmin.status})`); /* UND DIE FREIGABE IST DANACH WIEDER ZU. Ein Schloss, das offen bleibt, ist dasselbe wie keines. */ const db3 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); const freigabe = db3.prepare("SELECT offen FROM mz_freigabe WHERE eins = 1").get(); db3.close(); ok(freigabe.offen === 0, `die Freigabe steht nach der Korrektur wieder auf zu (${freigabe.offen})`); const altNochmal = await schick("scout", `${API}/eintrag/${altId}`, "PATCH", { datum: `${VORMONAT}-08` }); ok(altNochmal.status === 409, `der Scout kommt auch danach nicht hinein (${altNochmal.status}) — die Freigabe wirkte nur einmal`); /* ===================================================================== 7 · DIE ZIELZAHLEN ===================================================================== */ melde("\n=== Zielzahlen: aenderbar, aber erst ab naechstem Monat ==="); const zieleScout = await hol("scout", `${API}/ziele`); ok(zieleScout.status === 403, `ein Scout sieht die Zielzahlen nicht (${zieleScout.status})`); const zieleManager = await hol("manager", `${API}/ziele`); ok(zieleManager.status === 403, `ein Manager auch nicht (${zieleManager.status})`); for (const r of ["admin", "spicy"]) { const a = await hol(r, `${API}/ziele`); ok(a.status === 200, `${r} darf die Zielzahlen sehen (${a.status})`); } const vorAenderung = await (await hol("scout", `${API}/stand`)).json(); const zielVorher = vorAenderung.aufgaben.find((a) => a.schluessel === "creator").ziel; const gesetzt = await schick("spicy", `${API}/ziele`, "PUT", { ziele: { scout: { creator: 9 }, admin: { creator: 0 } } }); const gesetztD = await gesetzt.json().catch(() => ({})); ok(gesetzt.status === 200, `Spicy Media darf die Zielzahlen setzen (${gesetzt.status})`); ok(gesetztD.monat === mz.monatPlus(MONAT, 1), `gespeichert wird fuer den NAECHSTEN Monat (${gesetztD.monat})`); const nachAenderung = await (await hol("scout", `${API}/stand`)).json(); ok(nachAenderung.aufgaben.find((a) => a.schluessel === "creator").ziel === zielVorher, `der laufende Monat bleibt bei ${zielVorher} — eine Aenderung wirkt NICHT rueckwirkend`); const zieleJetzt = await (await hol("admin", `${API}/ziele`)).json(); ok(zieleJetzt.naechster.find((r) => r.rolle === "scout").ziele.creator === 9, "im naechsten Monat steht beim Scout die 9"); ok(zieleJetzt.jetzt.find((r) => r.rolle === "scout").ziele.creator === zielVorher, "im laufenden Monat steht dort weiterhin die alte Zahl"); const unsinn = await schick("admin", `${API}/ziele`, "PUT", { ziele: { scout: { creator: 500 } } }); ok(unsinn.status === 400, `eine Zielzahl von 500 wird abgelehnt (${unsinn.status})`); const negativ = await schick("admin", `${API}/ziele`, "PUT", { ziele: { scout: { creator: -1 } } }); ok(negativ.status === 400, `eine negative Zielzahl wird abgelehnt (${negativ.status})`); /* ===================================================================== 8 · DIE TEAM-UEBERSICHT UND DER EXPORT ===================================================================== */ melde("\n=== Team-Uebersicht ==="); for (const r of ["manager", "scout"]) { const a = await hol(r, `${API}/team`); ok(a.status === 403, `${r} sieht die Team-Uebersicht nicht (${a.status})`); } const team = await hol("admin", `${API}/team`); const teamD = await team.json(); ok(team.status === 200, `DogFather sieht die Team-Uebersicht (${team.status})`); ok(teamD.leute.length === 5, `fuenf pflichtige Personen in der Liste (${teamD.leute.length}) — Creator, Hand und Modi fehlen zu Recht`); ok(teamD.leute.every((p) => ["spicy", "admin", "manager", "scout"].includes(p.rolle)), "und keine einzige fremde Rolle darunter"); const spicyTeam = await hol("spicy", `${API}/team`); ok(spicyTeam.status === 200, `Spicy Media ebenfalls (${spicyTeam.status})`); const fremdeSicht = await hol("scout", `${API}/stand?person=${IDS.scout2}`); const fremdeSichtD = await fremdeSicht.json(); ok(fremdeSichtD.person?.id === IDS.scout, "ein Scout, der eine fremde Nummer in die Adresse schreibt, sieht trotzdem nur sich selbst"); melde("\n=== CSV-Export ==="); const csvScout = await hol("scout", `${API}/export.csv`); ok(csvScout.status === 403, `ein Scout bekommt keinen Export (${csvScout.status})`); const csv = await hol("admin", `${API}/export.csv?monat=${MONAT}`); /* IN BYTES GEMESSEN, nicht in Zeichen. `Response.text()` entfernt ein fuehrendes BOM beim Dekodieren -- die Pruefung meldete deshalb beim ersten Lauf "kein BOM", obwohl es da war. Das war ein Fehler an der Messung, nicht am Export. */ const csvBytes = new Uint8Array(await csv.clone().arrayBuffer()); const csvText = await csv.text(); ok(csv.status === 200, `DogFather bekommt den Export (${csv.status})`); ok(csvBytes[0] === 0xEF && csvBytes[1] === 0xBB && csvBytes[2] === 0xBF, `die Datei beginnt mit dem BOM (${[...csvBytes.slice(0, 3)].map((b) => b.toString(16)).join(" ")}) — sonst zeigt deutsches Excel kaputte Umlaute`); ok(csvText.split("\n")[0].includes(";"), "getrennt wird mit Semikolon — sonst steht alles in einer Spalte"); const csvZeilen = csvText.trim().split(/\r?\n/); ok(csvZeilen.length === teamD.leute.length + 1, `eine Kopfzeile und ${teamD.leute.length} Personen (${csvZeilen.length} Zeilen)`); ok((csv.headers.get("content-disposition") || "").includes(`manager-ziele-${MONAT}.csv`), "der Dateiname trägt den Monat"); /* ===================================================================== 9 · DIE PIPELINE-BRUECKE ===================================================================== */ melde("\n=== Die Bruecke zur Scout-Pipeline ==="); const db4 = new DatabaseSync(process.env.WORKSPACE_DB); db4.prepare(`INSERT INTO leads (name, handle, status, scout_id, erstellt, erstellt_von, uebergeben_am) VALUES (?,?,?,?,?,?,?)`) .run("Mara K.", "@maratanzt", "uebergeben", IDS.scout, jetztIso, IDS.scout, `${HEUTE}T10:00:00Z`); const leadId = db4.prepare("SELECT last_insert_rowid() AS id").get().id; /* Ein Lead, der jemand ANDEREM gehoert -- fuer die Gegenprobe. */ db4.prepare(`INSERT INTO leads (name, handle, status, scout_id, erstellt, erstellt_von, uebergeben_am) VALUES (?,?,?,?,?,?,?)`) .run("Fremd F.", "@fremd", "uebergeben", IDS.scout2, jetztIso, IDS.scout2, `${HEUTE}T10:00:00Z`); const fremdLeadId = db4.prepare("SELECT last_insert_rowid() AS id").get().id; db4.close(); braucht(!!leadId && !!fremdLeadId, "die Leads liessen sich nicht anlegen"); const vorschlaege = await (await hol("scout", `${API}/vorschlaege`)).json(); ok(vorschlaege.vorschlaege.some((v) => v.id === leadId), "der eigene uebergebene Lead steht als Vorschlag da"); ok(!vorschlaege.vorschlaege.some((v) => v.id === fremdLeadId), "ein fremder Lead steht NICHT darin"); const uebernommen = await schick("scout", `${API}/eintrag`, "POST", { aufgabe: "creator", datum: HEUTE, name: "@maratanzt", lead_id: leadId }); ok(uebernommen.status === 201, `der Lead laesst sich uebernehmen (${uebernommen.status})`); const danach = await (await hol("scout", `${API}/vorschlaege`)).json(); ok(!danach.vorschlaege.some((v) => v.id === leadId), "…und verschwindet danach aus den Vorschlaegen"); /* GEGENPROBE: Eine fremde Lead-Nummer im Rumpf darf nicht uebernommen werden -- der Eintrag entsteht, aber OHNE die Verbindung. Eine Nummer in einem Formular ist kein Besitznachweis. */ const mitFremd = await schick("scout", `${API}/eintrag`, "POST", { aufgabe: "creator", datum: HEUTE, name: "@nochwer", lead_id: fremdLeadId }); ok(mitFremd.status === 201, `der Eintrag entsteht trotzdem (${mitFremd.status})`); const db5 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); const ohneVerbindung = db5.prepare( "SELECT lead_id FROM mz_eintrag WHERE name = ?").get("@nochwer"); const mitVerbindung = db5.prepare( "SELECT lead_id FROM mz_eintrag WHERE name = ?").get("@maratanzt"); db5.close(); ok(ohneVerbindung?.lead_id === null, "…aber die fremde Lead-Verbindung wird verworfen"); ok(mitVerbindung?.lead_id === leadId, "die eigene Verbindung dagegen bleibt gespeichert"); const treffer = await (await hol("scout", `${API}/namen?q=mara`)).json(); ok(treffer.treffer.some((t) => t.id === leadId), "beim Tippen schlaegt die eigene Pipeline den Namen vor"); const trefferFremd = await (await hol("scout", `${API}/namen?q=fremd`)).json(); ok(!trefferFremd.treffer.length, "fremde Leads werden beim Tippen NICHT vorgeschlagen"); /* ===================================================================== 10 · DIE ERINNERUNGEN ===================================================================== */ melde("\n=== Die Erinnerungen (eingesetzte Tage) ==="); const { ARTEN } = await import("./workspace-push.js"); ok(ARTEN.some((a) => a.schluessel === "manager_ziele"), "es gibt eine eigene, abschaltbare Benachrichtigungsart"); /* `zielRufe` bekommt das Datum als Argument -- dadurch misst diese Prüfung dasselbe, egal an welchem Tag sie läuft. Gerechnet wird in Berlin, deshalb 12:00 UTC: Da ist der Tag in beiden Zeitzonen derselbe. */ const amTag = (tag) => new Date(`${MONAT}-${String(tag).padStart(2, "0")}T12:00:00Z`); const letzterTag = mz.letzterTagVon(MONAT); const rufe1 = mz.zielRufe(amTag(1)); ok(rufe1.length > 0, `am 1. geht eine Meldung an alle mit offenen Zielen (${rufe1.length})`); ok(rufe1.every((r) => r.merkmal.includes(`${MONAT}-01`) || r.merkmal.endsWith(":fertig")), "das Merkmal enthaelt den TAG — dadurch kann es hoechstens eine pro Tag geben"); ok(rufe1.some((r) => r.titel === "Neuer Monat"), "…mit dem Titel aus der Vorlage"); ok(rufe1.every((r) => r.ziel === "/workspace/manager-ziele.html"), "jede Meldung fuehrt direkt zur Kachel"); const RUF_TAGE = [ [1, "Neuer Monat"], [10, "Kurze Erinnerung"], [15, "Heute ist Stichtag Monatsmitte"], [16, "Monatsmitte ist vorbei"], [letzterTag - 7, "Dringend – noch 7 Tage"], [letzterTag - 2, "Noch 2 Tage"], [letzterTag - 1, "Noch 1 Tag"], [letzterTag, "Letzter Tag des Monats"], ]; let getroffen = 0; for (const [tag, titel] of RUF_TAGE) { const r = mz.zielRufe(amTag(tag)).filter((x) => !x.merkmal.endsWith(":fertig")); const passt = r.some((x) => x.titel === titel); if (passt) getroffen++; ok(passt, `Tag ${tag}: „${titel}" (${r[0]?.titel || "nichts"})`); } ok(getroffen === RUF_TAGE.length, `alle ${RUF_TAGE.length} Anlaesse der Vorlage sind belegt (${getroffen})`); /* GEGENPROBE: An einem Tag OHNE Anlass darf nichts herausgehen. Ohne diese Zeile bewiese der Block darueber nur, dass immer etwas kommt. */ const stillerTag = mz.zielRufe(amTag(7)).filter((x) => !x.merkmal.endsWith(":fertig")); ok(stillerTag.length === 0, `am 7. ist Ruhe (${stillerTag.length} Meldungen) — es wird nicht jeden Tag gemahnt`); /* UND WER ALLES HAT, BEKOMMT GENAU EINEN GLUECKWUNSCH. Dafuer wird einer Person alles abgenommen: Ziel 0 ueberall heisst „nichts offen". */ const db6 = new DatabaseSync(process.env.WORKSPACE_DB); for (const a of ["creator", "meeting", "schulung", "werbung"]) { db6.prepare(`INSERT INTO mz_ziel (monat, rolle, aufgabe, ziel, gesetzt_am) VALUES (?,?,?,?,?) ON CONFLICT(monat, rolle, aufgabe) DO UPDATE SET ziel = 0`) .run(MONAT, "manager", a, 0, jetztIso); } db6.close(); const fertig = mz.zielRufe(amTag(10)).filter((r) => r.personId === IDS.manager); ok(fertig.length === 0, "wer gar keine Ziele hat, bekommt weder Mahnung noch Glueckwunsch"); /* ===================================================================== 11 · DIE HINWEISE AUF DER STARTSEITE ===================================================================== */ melde("\n=== Die Zahl auf der Kachel ==="); const hinweise = await (await hol("scout", "/workspace/api/hinweise")).json(); const meine = (hinweise.hinweise || []).filter((h) => String(h.art).startsWith("mz_")); ok(meine.length > 0, `der Scout bekommt einen Hinweis zu den Monatszielen (${meine.length})`); ok(meine.every((h) => h.ziel === "manager-ziele.html"), "…und er fuehrt auf die richtige Seite"); const hinweiseCreator = await (await hol("creator", "/workspace/api/hinweise")).json(); const creatorMz = (hinweiseCreator.hinweise || []).filter((h) => String(h.art).startsWith("mz_")); ok(creatorMz.length === 0, `ein Creator bekommt keinen (${creatorMz.length}) — kein Weg zu einer Seite, die er nicht hat`); /* GEGENPROBE: Die Summe der Hinweise muss zur Zahl der offenen Aufgaben passen. Zwei Zaehler waeren zwei Wahrheiten. */ const standJetzt = await (await hol("scout", `${API}/stand`)).json(); const summe = meine.reduce((s, h) => s + h.anzahl, 0); ok(summe === standJetzt.gesamt.offen, `die Kachelzahl (${summe}) ist dieselbe wie auf der Seite (${standJetzt.gesamt.offen})`); /* ===================================================================== 12 · WER NEU DAZUKOMMT, WIRD NICHT GEMAHNT ===================================================================== */ melde("\n=== Wer im Laufe des Monats dazukommt ==="); const dbNeu = new DatabaseSync(process.env.WORKSPACE_DB); const heuteNr = Number(HEUTE.slice(8, 10)); dbNeu.close(); const neuerCode = "CODE-Z99-000001"; const dbA = new DatabaseSync(process.env.WORKSPACE_DB); const salt = randomBytes(16).toString("hex"); const hash = scryptSync(neuerCode, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); dbA.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n," + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") .run("Ganz Neu", "scout", hash, salt, 32768, createHmac("sha256", kennSchluessel).update(neuerCode).digest("hex"), `${HEUTE}T09:00:00Z`); const neuId = dbA.prepare("SELECT last_insert_rowid() AS id").get().id; dbA.close(); CODES.scout3 = neuerCode; kekse.scout3 = await anmelden("scout3", "scout"); braucht(!!kekse.scout3, "der neue Scout konnte sich nicht anmelden"); const standNeu = await (await hol("scout3", `${API}/stand`)).json(); if (heuteNr === 1) { ok(standNeu.pflichtig === true, "wer am 1. dazukommt, ist sofort pflichtig — er hat den vollen Monat"); } else { ok(standNeu.pflichtig === false, `wer am ${heuteNr}. dazukommt, bekommt diesen Monat keine Warnungen`); const rufeNeu = mz.zielRufe(amTag(Math.max(heuteNr, 16))) .filter((r) => r.personId === neuId); ok(rufeNeu.length === 0, "…und auch keine Erinnerung"); ok(standNeu.aufgaben.every((a) => a.stufe === "neutral" || a.stufe === "aus"), "…alle seine Zeilen stehen auf neutral, keine einzige warnt"); } /* ===================================================================== SCHLUSS ===================================================================== */ try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } melde(`\n${geprueft} Pruefungen, ${fehler} Fehler`); process.exit(fehler ? 1 : 0);