Files
dogfather-universe/server/pruef-monatswechsel.mjs
DogFatherGitandClaude Opus 5 72e51c57b6 Den Monatswechsel durchgespielt -- und zwei stille Fehler gefunden
Der zentrale Weg dieser Kachel war nie gelaufen: „Am 1. jedes Monats
um 00:00 Uhr starten alle Zaehler automatisch bei 0." Der erste
Oktober lag vor der Auslieferung, der erste November liegt dahinter.
Er laeuft ohne Zutun und betrifft alle gleichzeitig -- waere er
falsch, waere er fuer alle auf einmal falsch, und niemand wuesste
warum.

Dieselbe Lage gab es am 01.09.2026 schon einmal in RunOne (der Umbau
zur Hashkette war seit Tagen live und nie gelaufen). Die Lehre stand
danach in der Projektnotiz, und sie gilt hier woertlich: Was sich
nicht zuruecknehmen laesst, wird vorher auf einer Kopie durchgespielt.

`pruef-monatswechsel.mjs` verstellt dafuer die Uhr -- eine Huelle um
`Date`, ganz oben, vor jedem Import. Damit laeuft der ECHTE Code
durch zwei echte Monatswechsel (20. Oktober -> 1. November, 00:05 ->
3. Dezember), ohne dass eine Zeile dafuer umgebaut werden muesste.
41 Pruefungen, 0 Fehler: Zaehler bei 0, keine Warnung am ersten Tag,
die neuen Zielzahlen greifen, die alten Eintraege stehen unveraendert
da, der Oktober ist zu, der Verlauf misst ihn am OKTOBER-Ziel, und
„Neuer Monat" geht an alle drei -- auch an den, der den Oktober voll
hatte.

WAS DABEI AUFGEFALLEN IST -- zwei Fehler, beide stumm

1. DIE SUCHE NACH DEM ROLLENWECHSEL MASS DIE FALSCHE PERSON.
   `WHERE person_id = ?` im Protokoll findet den, der die Aenderung
   GEMACHT hat -- also DogFather --, nicht den, dessen Rolle sich
   geaendert hat (workspace-personen.js schreibt die betroffene
   Nummer ins `detail`). Fuer den Betroffenen fand die Abfrage
   deshalb nie etwas; bei DogFather schob jede fremde
   Rollenaenderung SEINEN Pflichtbeginn. In der echten Datenbank
   stand bei ihm „grund: rollenwechsel" -- ein plausibler Wert aus
   der falschen Zeile.

   NACHGESEHEN, OB ES SCHADET: Alle sechs Zeilen stehen auf 2026-10,
   und das ist ohnehin der frueheste moegliche Monat. Der Fehler
   hatte noch keine Wirkung -- er haette sie beim naechsten
   Rollenwechsel bekommen.

2. `node:sqlite` BINDET EINE ZAHL ALS REAL.
   Der erste Versuch der Reparatur lautete
   `detail LIKE ('#' || ? || ' %')` und traf NIE -- ohne Fehler, ohne
   Warnung, immer leer. Nachgemessen:

       SELECT ('#' || ? || ' %')  mit der Zahl 42  ->  '#42.0 %'

   Gesucht wurde „#42.0 ", gespeichert ist „#42 ". Mit derselben Zahl
   als Zeichenkette stimmt es sofort. Das Muster wird jetzt in
   JavaScript gebaut; im uebrigen Haus kommt dieselbe Verkettung mit
   einer Zahl nicht vor (nachgesehen).

   Gefunden hat das nicht das Lesen, sondern eine Pruefung, die den
   ECHTEN Weg benutzt (die Route der Personenverwaltung) statt den
   Protokolltext selbst zu schreiben. Haette sie ihn selbst
   geschrieben, haette sie ihre eigene Annahme geprueft und waere
   gruen gewesen.

AUSSERDEM BERICHTIGT

Der Pflichtbeginn wurde EINMAL gesetzt und nie wieder angesehen
(`INSERT OR IGNORE`). Wer die Rolle verliert und spaeter zurueck-
bekommt, haette damit keinen Schonmonat mehr bekommen, obwohl die
Vorlage ihn zusichert. Jetzt wird neu gerechnet, wenn seit der
Festlegung ein Rollenwechsel dazugekommen ist -- und nur dann;
geprueft wird ausdruecklich, dass ein zweiter Abruf nichts bewegt.

GEPRUEFT: 216 + 41 = 257 Pruefungen, 0 Fehler

Mit drei Gegenproben an der neuen Stelle: DogFathers Pflichtbeginn
bewegt sich NICHT, wenn er die Rolle eines anderen aendert; ein
unbeteiligter Scout behaelt seinen Wert; und ein zweiter Abruf
rechnet nichts neu. Ohne die erste waere der Fehler von oben
unentdeckt geblieben, ohne die zweite hiesse „einer hat sich
geaendert" vielleicht „alle".

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 18:02:28 +02:00

340 lines
16 KiB
JavaScript

/* =====================================================================
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);