/* ===================================================================== DER MONATSWECHSEL (02.10.2026) ===================================================================== WARUM ES DIESE PRUEFUNG GIBT, obwohl pruef-manager-ziele schon 206 Dinge misst: Weil keines davon den Monatswechsel erlebt hat. Die Vorlage sagt: „Am 1. jedes Monats um 00:00 Uhr starten alle Zaehler automatisch bei 0." Das ist der zentrale Weg dieser Kachel, er laeuft ohne Zutun, er betrifft alle gleichzeitig, und er war am Tag der Auslieferung noch nie gelaufen -- der erste Oktober lag davor, der erste November liegt dahinter. Genau diese Lage gab es in diesem Haus schon einmal: Am 01.09.2026 war in RunOne der Umbau „Hashkette beginnt bei der Festschreibung" seit Tagen live und NIE gelaufen -- null festgeschriebene Buchungen. Der zentrale Weg war unerprobt, ausgerechnet der einzige, den man nicht rueckgaengig machen kann. Die Lehre stand danach in der Projektnotiz: „Was sich nicht zuruecknehmen laesst, wird vorher auf einer Kopie durchgespielt." --------------------------------------------------------------------- WIE HIER DIE ZEIT VERSTELLT WIRD -- und was das NICHT beweist Der ganze Code rechnet ueber `new Date()` und `Date.now()`. Diese Datei setzt davor eine Huelle, die beide um einen festen Betrag verschiebt. Damit laeuft der ECHTE Code durch einen echten Monatswechsel, ohne dass irgendetwas umgebaut werden muesste. WAS DAMIT NICHT GEMESSEN WIRD, und das gehoert dazugesagt: SQLite rechnet in den Triggern mit `datetime('now')` und sieht weiterhin die wahre Uhr. Fuer die Freigabe alter Monate heisst das: Ihr Ablaufzeitpunkt liegt in der verschobenen Zukunft und damit immer in der Zukunft. Dass die Freigabe nach zwei Minuten verfaellt, prueft deshalb weiterhin pruef-manager-ziele, nicht diese Datei. DIE HUELLE STEHT GANZ OBEN, vor jedem Import. Ein Modul, das beim Laden einmal `new Date()` aufruft, wuerde sonst die echte Zeit festhalten -- und die Pruefung maesse danach zwei verschiedene Uhren gegeneinander. ===================================================================== */ /* ---------- Die Uhr ---------------------------------------------- */ const ECHTE_ZEIT = Date; let versatz = 0; globalThis.Date = class extends ECHTE_ZEIT { constructor(...a) { if (a.length === 0) super(ECHTE_ZEIT.now() + versatz); else super(...a); } static now() { return ECHTE_ZEIT.now() + versatz; } }; /** Setzt die Uhr auf einen bestimmten Zeitpunkt. */ function uhrAuf(iso) { versatz = 0; // erst zurueck auf echt versatz = new ECHTE_ZEIT(iso).getTime() - ECHTE_ZEIT.now(); return iso; } import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-mw-")); 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 des Monatswechsels"); 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"; /* OKTOBER, UND ZWAR MITTEN DRIN. Der 20. ist bewusst gewaehlt: nach dem 16. (also in der Orange-Zone) und vor den letzten sieben Tagen. So ist am Ausgangspunkt eine Warnung aktiv, die nach dem Wechsel verschwunden sein MUSS -- waere der Ausgangspunkt neutral, bewiese das Verschwinden nichts. */ uhrAuf("2026-10-20T09:00:00+02: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(300_000, "pruef-monatswechsel"); 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); function braucht(bedingung, was) { if (bedingung) return; console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}`); console.log("Das ist KEIN Befund am Code, sondern eine Voraussetzung,"); console.log("die sich nicht herstellen liess.\n"); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } process.exit(2); } /* ---------- Leute ------------------------------------------------- */ const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); 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, new Date().toISOString()); /* Ein Jahr alt -- damit alle vom ersten Tag an pflichtig sind. Waeren sie frisch angelegt, waere jede Ampel neutral und die Pruefung saehe gruen aus, ohne etwas gemessen zu haben. */ const ALT = "2025-10-01T08:00:00Z"; const CODES = {}, IDS = {}; function anlegen(name, rolle, code) { const salz = randomBytes(16).toString("hex"); const h = scryptSync(code, salz, 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, h, salz, 32768, createHmac("sha256", kennSchluessel).update(code).digest("hex"), ALT); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } for (const [schluessel, rolle, name] of [ ["admin", "admin", "Filipe"], ["fleissig", "scout", "Fleissig"], ["faul", "scout", "Faul"], ]) { CODES[schluessel] = `CODE-MW${schluessel.slice(0, 3).toUpperCase()}-01`; IDS[schluessel] = anlegen(name, rolle, CODES[schluessel]); } d.close(); /* ---------- Anmelden ---------------------------------------------- */ const kekse = {}; for (const schluessel of Object.keys(CODES)) { const rolle = schluessel === "admin" ? "admin" : "scout"; const a = await fetch(`${BASIS}/workspace/api/anmelden`, { method: "POST", headers: { "Content-Type": "application/json", Origin: BASIS }, body: JSON.stringify({ rolle, code: CODES[schluessel] }), }); if (a.status === 200) { kekse[schluessel] = (a.headers.getSetCookie?.() || []) .map((z) => z.split(";")[0]).join("; "); } } braucht(Object.keys(kekse).length === 3, "nicht alle drei kamen herein"); const hol = (wer, pfad, einst = {}) => fetch(BASIS + pfad, { ...einst, headers: { Cookie: kekse[wer] || "", Origin: BASIS, ...(einst.body ? { "Content-Type": "application/json" } : {}), ...(einst.headers || {}), }, }); const schick = (wer, pfad, art, rumpf) => hol(wer, pfad, { method: art, body: JSON.stringify(rumpf) }); /* ===================================================================== 1 · OKTOBER: ein voller und ein halber Monat ===================================================================== */ melde("\n=== Oktober, am 20. ==="); const okt = await (await hol("fleissig", `${API}/stand`)).json(); ok(okt.monat === "2026-10", `wir sind im Oktober (${okt.monat})`); ok(okt.tag === 20, `am 20. (${okt.tag})`); ok(okt.aufgaben.find((a) => a.schluessel === "creator").stufe === "orange", "und die offene Aufgabe steht auf ORANGE — Ausgangspunkt mit Warnung"); /* Fleissig macht alles fertig. */ const erledigt = await schick("fleissig", `${API}/eintraege`, "POST", { aufgabe: "creator", datum: "2026-10-18", namen: ["@a", "@b", "@c"] }); ok((await erledigt.json()).angelegt.length === 3, "drei Creator eingetragen"); for (const [aufgabe, extra, mal] of [["meeting", {}, 2], ["schulung", { art: "talk" }, 2], ["werbung", { art: "agentur", link: "https://tiktok.com/@x/video/9" }, 1]]) { for (let i = 0; i < mal; i++) { await schick("fleissig", `${API}/eintraege`, "POST", { aufgabe, datum: `2026-10-1${i}`, ...extra, ...(aufgabe === "werbung" ? { link: `https://tiktok.com/@x/video/${i}9` } : {}) }); } } /* Faul macht genau einen Creator. */ await schick("faul", `${API}/eintraege`, "POST", { aufgabe: "creator", datum: "2026-10-05", namen: ["@nurEiner"] }); const oktFleissig = await (await hol("fleissig", `${API}/stand`)).json(); const oktFaul = await (await hol("faul", `${API}/stand`)).json(); ok(oktFleissig.gesamt.erledigt === 8 && oktFleissig.gesamt.offen === 0, `Fleissig hat alles (${oktFleissig.gesamt.erledigt} von ${oktFleissig.gesamt.ziel})`); ok(oktFaul.gesamt.erledigt === 1 && oktFaul.gesamt.offen === 4, `Faul hat eins von acht (${oktFaul.gesamt.erledigt} von ${oktFaul.gesamt.ziel})`); /* DogFather setzt die Zielzahlen fuer den naechsten Monat hoch. */ const gesetzt = await schick("admin", `${API}/ziele`, "PUT", { ziele: { scout: { creator: 5, meeting: 1 } } }); const gesetztD = await gesetzt.json(); ok(gesetzt.status === 200 && gesetztD.monat === "2026-11", `neue Zielzahlen gelten ab November (${gesetztD.monat})`); ok((await (await hol("fleissig", `${API}/stand`)).json()) .aufgaben.find((a) => a.schluessel === "creator").ziel === 3, "…und der Oktober bleibt bei 3 — die Aenderung wirkt nicht rueckwaerts"); /* ===================================================================== 2 · DER WECHSEL ===================================================================== */ melde("\n=== Und jetzt ist es der 1. November, 00:05 Uhr ==="); uhrAuf("2026-11-01T00:05:00+01:00"); const nov = await (await hol("fleissig", `${API}/stand`)).json(); ok(nov.monat === "2026-11", `der Monat ist gewechselt (${nov.monat})`); ok(nov.tag === 1, `wir sind am 1. (${nov.tag})`); ok(nov.aufgaben.every((a) => a.zahl === 0), "ALLE Zaehler stehen bei 0 — ohne dass jemand etwas angefasst haette"); ok(nov.aufgaben.every((a) => a.stufe === "neutral" || a.stufe === "aus"), "keine einzige Warnung am ersten Tag"); ok(nov.aufgaben.find((a) => a.schluessel === "creator").ziel === 5, `die neue Zielzahl gilt jetzt (${nov.aufgaben.find((a) => a.schluessel === "creator").ziel})`); ok(nov.aufgaben.find((a) => a.schluessel === "meeting").ziel === 1, "…auch die zweite geaenderte"); ok(nov.aufgaben.find((a) => a.schluessel === "schulung").ziel === 2, "…und was nicht geaendert wurde, bleibt bei der Vorgabe"); /* DIE ALTEN DATEN SIND NICHT WEG -- das ist der Kern der Regel „Zuordnung zu Monat/Jahr, nicht Loeschen von Daten". */ const oktNochDa = await (await hol("fleissig", `${API}/eintraege?monat=2026-10&aufgabe=creator`)).json(); ok(oktNochDa.eintraege.length === 3, `die Oktober-Eintraege stehen noch da (${oktNochDa.eintraege.length})`); ok(oktNochDa.bearbeitbar === false, "…und sind fuer den Scout nicht mehr bearbeitbar"); const dbL = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); const lage = dbL.prepare("SELECT monat FROM mz_lage WHERE eins = 1").get(); const oktZeilen = dbL.prepare( "SELECT COUNT(*) n FROM mz_eintrag WHERE monat = '2026-10'").get().n; dbL.close(); ok(lage.monat === "2026-11", `die Sperre kennt den neuen Monat (${lage.monat})`); ok(oktZeilen === 9, `neun Oktober-Zeilen unveraendert in der Datenbank (${oktZeilen})`); /* GEGENPROBE: Der Oktober ist wirklich zu. */ const altAendern = await schick("fleissig", `${API}/eintrag/${oktNochDa.eintraege[0].id}`, "PATCH", { datum: "2026-10-19", name: "@heimlich" }); ok(altAendern.status === 409, `der Scout kommt im Oktober nicht mehr hinein (${altAendern.status})`); const altAdmin = await schick("admin", `${API}/eintrag/${oktNochDa.eintraege[0].id}`, "PATCH", { datum: "2026-10-19", name: "@berichtigt" }); ok(altAdmin.status === 200, `DogFather schon (${altAdmin.status})`); /* ===================================================================== 3 · DER VERLAUF ===================================================================== */ melde("\n=== Der Verlauf zeigt den Oktober ==="); const verlaufF = await (await hol("fleissig", `${API}/verlauf`)).json(); const verlaufA = await (await hol("faul", `${API}/verlauf`)).json(); const oktF = verlaufF.monate.find((m) => m.monat === "2026-10"); const oktA = verlaufA.monate.find((m) => m.monat === "2026-10"); ok(!!oktF, "der Oktober steht im Verlauf"); ok(oktF?.aufgaben.every((a) => a.erreicht === true), "bei Fleissig ist jede Aufgabe als erreicht vermerkt"); ok(oktA?.aufgaben.filter((a) => a.erreicht === false).length === 4, `bei Faul sind vier als nicht erreicht vermerkt (${oktA?.aufgaben.filter((a) => a.erreicht === false).length})`); ok(oktF?.aufgaben.find((a) => a.schluessel === "creator")?.ziel === 3, "…und gemessen wird am OKTOBER-Ziel (3), nicht am neuen (5)"); ok(!verlaufF.monate.some((m) => m.monat === "2026-11"), "der laufende Monat steht NICHT im Verlauf — er ist noch nicht vorbei"); /* ===================================================================== 4 · DIE ERINNERUNG AM ERSTEN ===================================================================== */ melde("\n=== Die Meldung zum Monatsanfang ==="); const mz = await import("./workspace-manager-ziele.js"); const rufe = mz.zielRufe(new Date()); ok(rufe.length === 3, `alle drei Pflichtigen bekommen eine Meldung (${rufe.length})`); ok(rufe.every((r) => r.titel === "Neuer Monat"), '…und zwar „Neuer Monat"'); ok(rufe.every((r) => r.merkmal === "mz:2026-11-01"), "…mit dem Tag im Merkmal, also hoechstens eine je Person"); ok(rufe.every((r) => /starten bei 0/.test(r.text)), "…und dem Satz aus der Vorlage"); /* GEGENPROBE: Wer im Oktober alles geschafft hatte, bekommt im November trotzdem eine -- es ist ein neuer Monat. Haette der alte Glueckwunsch die Erinnerungen dauerhaft abgestellt, waere Fleissig hier still, und das waere der schlimmste denkbare Fehler: Der Beste bekaeme als Einziger keine Erinnerung mehr. */ ok(rufe.some((r) => r.personId === IDS.fleissig), "auch der, der den Oktober voll hatte — der Glueckwunsch galt nur fuer den Oktober"); /* ===================================================================== 5 · NOCH EIN WECHSEL -- und ein Monat ohne eigene Zielzahl ===================================================================== */ melde("\n=== Und weiter in den Dezember ==="); uhrAuf("2026-12-03T10:00:00+01:00"); const dez = await (await hol("fleissig", `${API}/stand`)).json(); ok(dez.monat === "2026-12", `Dezember (${dez.monat})`); ok(dez.aufgaben.find((a) => a.schluessel === "creator").ziel === 5, "ein Monat ohne eigene Festlegung erbt die letzte geltende (5)"); ok(dez.aufgaben.every((a) => a.zahl === 0), "und faengt wieder bei 0 an"); const verlaufDez = await (await hol("fleissig", `${API}/verlauf`)).json(); ok(verlaufDez.monate.length === 2, `der Verlauf kennt jetzt zwei Monate (${verlaufDez.monate.length})`); ok(verlaufDez.monate[0].monat === "2026-11" && verlaufDez.monate[1].monat === "2026-10", "…der juengste zuerst"); ok(verlaufDez.monate[0].aufgaben.every((a) => a.erreicht === false), "der leere November steht als nicht erreicht da"); /* UND DER OKTOBER HAT SICH NICHT VERAENDERT, obwohl inzwischen zwei Monatswechsel und eine Zielzahlaenderung dazwischenliegen. Das ist die eigentliche Zusage des eingefrorenen Ziels. */ const oktSpaet = verlaufDez.monate.find((m) => m.monat === "2026-10"); ok(oktSpaet?.aufgaben.find((a) => a.schluessel === "creator")?.ziel === 3, `der Oktober steht zwei Monate spaeter immer noch auf 3 (${oktSpaet?.aufgaben.find((a) => a.schluessel === "creator")?.ziel})`); ok(oktSpaet?.aufgaben.every((a) => a.erreicht === true), "…und gilt weiterhin als vollstaendig erreicht"); /* ---------- Die Team-Uebersicht eines vergangenen Monats ---------- */ const teamOkt = await (await hol("admin", `${API}/team?monat=2026-10`)).json(); const fleissigOkt = teamOkt.leute.find((p) => p.id === IDS.fleissig); ok(fleissigOkt?.gesamt.erledigt === 8, `DogFather sieht den Oktober unveraendert (${fleissigOkt?.gesamt.erledigt} von ${fleissigOkt?.gesamt.ziel})`); ok(fleissigOkt?.stufe === "gruen", "…und Fleissig steht dort auf gruen"); /* ===================================================================== SCHLUSS ===================================================================== */ versatz = 0; try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } melde(`\n${geprueft} Pruefungen, ${fehler} Fehler`); process.exit(fehler ? 1 : 0);