diff --git a/server/pruef-ziel-zustellung.mjs b/server/pruef-ziel-zustellung.mjs new file mode 100644 index 00000000..7a648cdb --- /dev/null +++ b/server/pruef-ziel-zustellung.mjs @@ -0,0 +1,309 @@ +/* ===================================================================== + WERDEN DIE MONATSZIEL-ERINNERUNGEN WIRKLICH ZUGESTELLT? (02.10.2026) + ===================================================================== + + `pruef-manager-ziele` prueft, was `zielRufe()` ZURUECKGIBT -- eine + Liste. Eine Liste ist aber keine Benachrichtigung. Zwischen ihr und + dem Handy liegen noch: der Fuenf-Minuten-Takt, die Artenliste, der + Schalter in der Glocke, die Ruhezeit, die Verschluesselung und das + Merkmal gegen Doppelsendungen. + + Dieser Weg ist genauso nie gelaufen wie der Monatswechsel -- und + er laeuft zum ersten Mal am 1. November um 00:05 Uhr, von selbst, + fuer alle gleichzeitig. + + --------------------------------------------------------------------- + DIE WICHTIGSTE FRAGE STEHT GLEICH AM ANFANG + + Am 1. um 00:05 ist RUHEZEIT (Vorgabe 22 bis 7). Die Meldung darf + dann nicht herausgehen -- aber sie darf auch nicht VERFALLEN. Das + haengt an einer einzigen Zeile in `benachrichtige`: Das Merkmal + wird erst geschrieben, NACHDEM wirklich zugestellt wurde. Waere es + umgekehrt, bekaeme am 1. November niemand eine Meldung, und zwar + fuer immer -- der Takt haette sie als „schon geschickt" abgehakt, + waehrend alle schliefen. + + --------------------------------------------------------------------- + GEMESSEN WERDEN ZUSTAENDE, NICHT ZEITPUNKTE + + Der Server startet seinen eigenen Takt (alle fuenf Minuten, der + erste Lauf nach zwanzig Sekunden). Er kann also jederzeit + mitten in diese Pruefung hineinlaufen. Deshalb steht hier nirgends + „nach meinem Aufruf kamen genau drei dazu", sondern „in + push_verschickt steht jetzt genau eine Zeile je Person" -- das + stimmt auch dann, wenn der Takt dieselbe Arbeit vorher gemacht hat. + + Dieselbe Falle wie am 06.09.2026 in RunOne, als zwei Prueflaeufe + gleichzeitig liefen und sechzehn Fehler meldeten, von denen keiner + aus dem Code kam. + ===================================================================== */ + +/* ---------- 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; } +}; +function uhrAuf(iso) { + versatz = 0; + versatz = new ECHTE_ZEIT(iso).getTime() - ECHTE_ZEIT.now(); +} + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "node:http"; +import { eigenerPort } from "./helfer-port.mjs"; + +const PORT = await eigenerPort(import.meta, "pruef-ziel-zustellung"); +const DIENST = await eigenerPort(import.meta, "pruef-ziel-zustellung (Push-Dienst)", 1); + +const ordner = mkdtempSync(join(tmpdir(), "ws-zz-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +/* DIE RUHEZEIT BLEIBT AUF DER VORGABE (22 bis 7). Sie wird hier nicht + per Umgebungsvariable verstellt, sondern ueber die Uhr erreicht -- + denn genau die Uhrzeit ist der Fall, der am 1. November eintritt. + Eine gefaelschte Ruhezeit um neun Uhr morgens wuerde eine andere + Frage beantworten als die, die sich stellt. */ + +/* 1. NOVEMBER, 00:05 -- fuenf Minuten nach dem Monatswechsel, mitten + in der Ruhezeit. Genau so wird es wirklich sein. */ +uhrAuf("2026-11-01T00:05: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 }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(240_000, "pruef-ziel-zustellung"); +await new Promise((r) => setTimeout(r, 800)); + +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +function braucht(bedingung, was) { + if (bedingung) return; + console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}\n`); + try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + process.exit(2); +} + +const { DatabaseSync } = await import("node:sqlite"); +const { randomBytes, createECDH } = await import("node:crypto"); +const push = await import("./workspace-push.js"); + +/* ---------- Ein Push-Dienst, der alles annimmt -------------------- */ +let zustellungen = 0; +const dienst = createServer((q, a) => { + q.on("data", () => {}); + q.on("end", () => { zustellungen++; a.writeHead(201); a.end(); }); +}); +await new Promise((r) => dienst.listen(DIENST, "127.0.0.1", r)); + +/* ---------- Leute mit Geraeten ------------------------------------ */ +const ALT = "2025-10-01T08:00:00Z"; +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const anlegen = (name, rolle) => { + d.prepare(`INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) + VALUES (?,?,?,?,32768,1,?)`) + .run(name, rolle, randomBytes(32).toString("hex"), + randomBytes(16).toString("hex"), ALT); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +}; +const idOffen1 = anlegen("Offen Eins", "scout"); +const idOffen2 = anlegen("Offen Zwei", "manager"); +const idStumm = anlegen("Stumm", "scout"); +const idOhneGeraet = anlegen("Ohne Geraet", "scout"); +const idCreator = anlegen("Luna", "creator"); + +/* Echte Schluessel -- ein erfundener wuerde beim Verschluesseln + scheitern, und „nicht zugestellt" waere dann die Antwort auf eine + ganz andere Frage. */ +function geraet(personId) { + const k = createECDH("prime256v1"); + k.generateKeys(); + d.prepare(`INSERT INTO push_anmeldungen (person_id, endpunkt, p256dh, auth, erstellt) + VALUES (?,?,?,?,?)`) + .run(personId, `http://127.0.0.1:${DIENST}/push/${personId}`, + k.getPublicKey().toString("base64url"), randomBytes(16).toString("base64url"), + new Date().toISOString()); +} +for (const id of [idOffen1, idOffen2, idStumm, idCreator]) geraet(id); + +/* „Stumm" hat die Art abgeschaltet -- das ist der Schalter aus der + Glocke, und er muss staerker sein als jede Erinnerung. */ +d.prepare(`INSERT INTO push_einstellungen (person_id, art, an) VALUES (?,?,0)`) + .run(idStumm, "manager_ziele"); +d.close(); + +const zaehle = (sql, ...w) => { + const db2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); + const n = db2.prepare(sql).get(...w)?.n ?? 0; + db2.close(); + return n; +}; +const mzMerkmale = () => zaehle( + "SELECT COUNT(*) n FROM push_verschickt WHERE merkmal LIKE 'mz:%'"); + +/* ===================================================================== + 0 · MISST DIESE PRUEFUNG UEBERHAUPT ETWAS? + ===================================================================== + „Null zugestellt" ist gleich die erste erwartete Antwort. Ohne + diesen Beweis hiesse sie moeglicherweise „der Dienst ist gar nicht + angeschlossen" -- und die ganze Datei waere wertlos. Die Art + „test" traegt `ruhe: "nie"` und geht deshalb auch nachts durch. + ===================================================================== */ +melde("\n=== Zuerst: kann hier ueberhaupt etwas ankommen? ==="); +const probe = await push.benachrichtige(idOffen1, "test", + { titel: "Probe", text: "nur zum Messen", ziel: "/workspace/start.html" }); +ok(probe.verschickt === 1, + `eine Probemeldung kommt an (${probe.verschickt}) — der Dienst ist angeschlossen`); +ok(zustellungen >= 1, `und der Zaehler bewegt sich (${zustellungen})`); + +/* ===================================================================== + 1 · 1. NOVEMBER, 00:05 UHR -- RUHEZEIT + ===================================================================== */ +melde("\n=== 1. November, 00:05 Uhr — mitten in der Ruhezeit ==="); +const vorher = zustellungen; +await push.laufen(); +ok(zustellungen === vorher, + `nachts geht nichts raus (${zustellungen - vorher} zugestellt)`); +ok(mzMerkmale() === 0, + `UND das Merkmal wurde NICHT gesetzt (${mzMerkmale()})` + + " — sonst waere die Meldung fuer immer verfallen, waehrend alle schliefen"); + +/* ===================================================================== + 2 · DERSELBE TAG, 09:00 UHR + ===================================================================== */ +melde("\n=== Derselbe 1. November, 09:00 Uhr ==="); +uhrAuf("2026-11-01T09:00:00+01:00"); +await push.laufen(); + +const db3 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); +const zeilen = db3.prepare( + "SELECT person_id, merkmal FROM push_verschickt WHERE merkmal LIKE 'mz:%'").all(); +db3.close(); +const wer = new Set(zeilen.map((z) => z.person_id)); + +ok(zeilen.length === 2, + `genau zwei Meldungen sind heraus (${zeilen.length})`); +ok(wer.has(idOffen1) && wer.has(idOffen2), + "…an die beiden mit offenen Zielen und einem Geraet"); +ok(!wer.has(idStumm), + "…NICHT an den, der die Art abgeschaltet hat — der Schalter schlaegt die Erinnerung"); +ok(!wer.has(idCreator), + "…und nicht an einen Creator, der diese Pflichten gar nicht hat"); +ok(!wer.has(idOhneGeraet), + "…und nicht an den ohne Geraet (es gibt niemanden, der sie annimmt)"); +ok(zeilen.every((z) => z.merkmal === "mz:2026-11-01"), + `das Merkmal traegt den Tag (${zeilen[0]?.merkmal})`); + +/* ===================================================================== + 3 · UND JETZT LAEUFT DER TAKT 287 WEITERE MALE + ===================================================================== + Das ist der Fall, der im Betrieb wirklich eintritt: Der Lauf + findet alle fuenf Minuten statt. Ohne das Merkmal bekaeme jeder + 288 Meldungen an einem Tag -- das waere kein Hinweis mehr, sondern + ein Grund, das Handy wegzulegen. + ===================================================================== */ +melde("\n=== Der Takt laeuft weiter ==="); +const nachErstem = zustellungen; +await push.laufen(); +await push.laufen(); +await push.laufen(); +ok(zustellungen === nachErstem, + `drei weitere Laeufe stellen nichts mehr zu (${zustellungen - nachErstem})`); +ok(mzMerkmale() === 2, `und es bleibt bei zwei Merkmalen (${mzMerkmale()})`); + +/* ===================================================================== + 4 · DER 10. -- EIN NEUER TAG, EINE NEUE MELDUNG + ===================================================================== */ +melde("\n=== Der 10. November ==="); +uhrAuf("2026-11-10T09:00:00+01:00"); +const vor10 = zustellungen; +await push.laufen(); +ok(zustellungen - vor10 === 2, + `an einem neuen Anlasstag geht wieder etwas raus (${zustellungen - vor10})`); +ok(zaehle("SELECT COUNT(*) n FROM push_verschickt WHERE merkmal = ?", + "mz:2026-11-10") === 2, + "…unter einem eigenen Merkmal fuer diesen Tag"); + +/* GEGENPROBE: Der 11. ist KEIN Anlasstag. Ginge auch dort etwas + heraus, hiesse „es kommt an" in Wahrheit „es kommt jeden Tag", und + der ganze Terminplan der Vorlage waere wirkungslos. */ +melde("\n=== Der 11. November — kein Anlass ==="); +uhrAuf("2026-11-11T09:00:00+01:00"); +const vor11 = zustellungen; +await push.laufen(); +ok(zustellungen === vor11, + `am 11. ist Ruhe (${zustellungen - vor11} zugestellt)`); + +/* ===================================================================== + 5 · WER ALLES GESCHAFFT HAT + ===================================================================== + Vorlage: „Glueckwunsch-Meldung, danach keine Erinnerungen mehr in + diesem Monat." + ===================================================================== */ +melde("\n=== Wer alles geschafft hat ==="); +const dbF = new DatabaseSync(process.env.WORKSPACE_DB); +dbF.prepare("UPDATE mz_lage SET monat = '2026-11' WHERE eins = 1").run(); +/* Alle Ziele dieser Rolle auf 0 -- damit ist „alles erfuellt" wahr, + ohne dass Eintraege erfunden werden muessten. Null Ziele heisst + aber auch: nichts zu gratulieren. Deshalb bekommt Offen Zwei EIN + gefordertes Ziel, das er erfuellt. */ +for (const a of ["creator", "meeting", "schulung", "werbung"]) { + dbF.prepare(`INSERT INTO mz_ziel (monat, rolle, aufgabe, ziel, gesetzt_am) + VALUES ('2026-11','manager',?,?,?) + ON CONFLICT(monat, rolle, aufgabe) DO UPDATE SET ziel = excluded.ziel`) + .run(a, a === "meeting" ? 1 : 0, new Date().toISOString()); +} +dbF.prepare(`INSERT INTO mz_eintrag (person_id, von_name, von_rolle, aufgabe, monat, datum, erstellt) + VALUES (?,?,?,?,?,?,?)`) + .run(idOffen2, "Offen Zwei", "manager", "meeting", "2026-11", "2026-11-05", + new Date().toISOString()); +dbF.close(); + +uhrAuf("2026-11-15T09:00:00+01:00"); +const vor15 = zustellungen; +await push.laufen(); +const glueck = zaehle("SELECT COUNT(*) n FROM push_verschickt WHERE merkmal = ?", + "mz:2026-11:fertig"); +ok(glueck === 1, `der Fertige bekommt genau einen Glueckwunsch (${glueck})`); +ok(zustellungen - vor15 === 2, + `insgesamt zwei Meldungen an diesem Tag (${zustellungen - vor15})` + + " — ein Glueckwunsch und eine Mahnung"); + +/* UND DANACH IST RUHE FUER IHN. Der 16. ist ein Anlasstag; der + Fertige darf dort nichts mehr bekommen, der andere schon. */ +melde("\n=== Der 16. — Mahnung ja, Glueckwunsch nein ==="); +uhrAuf("2026-11-16T09:00:00+01:00"); +const vor16 = zustellungen; +await push.laufen(); +const am16 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); +const empfaenger16 = am16.prepare( + "SELECT person_id FROM push_verschickt WHERE merkmal = ?").all("mz:2026-11-16"); +am16.close(); +ok(zustellungen - vor16 === 1, + `nur noch einer bekommt etwas (${zustellungen - vor16})`); +ok(empfaenger16.length === 1 && empfaenger16[0].person_id === idOffen1, + "…und zwar der, bei dem noch etwas offen ist"); +ok(zaehle("SELECT COUNT(*) n FROM push_verschickt WHERE merkmal = ?", + "mz:2026-11:fertig") === 1, + "der Glueckwunsch bleibt bei einem — er wird nicht wiederholt"); + +/* ===================================================================== + SCHLUSS + ===================================================================== */ +versatz = 0; +dienst.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +melde(`\n${geprueft} Pruefungen, ${fehler} Fehler`); +process.exit(fehler ? 1 : 0);