Files
DogFatherGitandClaude Opus 5 4503b1a475 Benachrichtigungen: nicht "Verbindung offen", sondern "sieht jemand hin"
Diene im Support (vor 5 Tagen): „Die Benachrichtigungen werden nicht
angezeigt, wenn neue Nachrichten reinkommen. Erst, wenn man die App
öffnet."

ERST GEMESSEN, WAS NICHT DAS PROBLEM IST. Am echten Bestand
nachgesehen: Diene HAT ein angemeldetes Geraet (Android, Chrome, seit
dem 29.09.), und alle zehn Geraete im Haus melden `fehler = 0`. An
der Zustellung liegt es nicht.

DANN NACHGESTELLT (pruef-abzeichen, Abschnitt 8):

    Verbindung offen  ->  KEINE Benachrichtigung
    Verbindung zu     ->  sie kommt

Genau sein Befund.

DER GEDANKE WAR RICHTIG, DIE FRAGE FALSCH. Im Quelltext stand:

    if ((zuschauer.get(personId) || new Set()).size) continue;

und daneben die Begruendung -- „wer die Seite offen hat, sieht die
Nachricht ohnehin; ihm auch noch eine Meldung aufs Handy zu schicken
ist der schnellste Weg, dass er Benachrichtigungen abschaltet." Das
stimmt. Nur beantwortet `zuschauer` eine ANDERE Frage: ob eine
VERBINDUNG offen ist. Ein Handy mit der App im Hintergrund haelt sie
weiter -- und der Server hielt Diene fuer anwesend, waehrend sein
Bildschirm schwarz war.

DIE SEITE WEISS ES, DER SERVER NICHT. `document.visibilityState` ist
die einzige Stelle, die den Unterschied kennt. Also sagt sie es --
ueber einen winzigen Weg (`/api/chat/sicht`), beim Aufbau, bei jedem
Wechsel und mit `keepalive` beim Weggehen.

MIT VERFALL, und das ist der wichtige Teil: Ein Geraet, das
abstuerzt, im Funkloch steht oder eingefroren wird, sagt gar nichts
mehr. Ohne Verfall bliebe es fuer immer „sichtbar" und fuer immer
still. Wer nicht widerspricht, gilt nach zweieinhalb Minuten als weg
-- eine Meldung zu viel ist laestig, eine zu wenig ist genau der
Fehler, den Diene gemeldet hat. Dazu alle Minute ein Lebenszeichen,
solange die App vorn liegt.

EINE STELLE FUER DIE FRAGE. Sie wurde an zwei Orten gestellt:
`siehtZu()` und eine Abschrift mitten in `chatEreignis`. Die
Abschrift war die kaputte. Jetzt fragen beide dieselbe Funktion.

GEPRUEFT -- vier Lagen, und die zweite ist die wichtigere
Gegenprobe:

  App liegt hinten        -> Meldung kommt      (war: nichts)
  sieht wirklich hin      -> KEINE Meldung      (Absicht bleibt)
  App weggelegt           -> Meldung kommt wieder
  gar keine Verbindung    -> Meldung kommt

Ohne die zweite Zeile hiesse die Reparatur nur „jetzt kommt immer
eine", und das waere der schnellste Weg, dass jemand
Benachrichtigungen abschaltet.

UND EINE PRUEFUNG HAT DEN FEHLER MITGETRAGEN. pruef-anruf-klingelt
hielt den WORTLAUT der kaputten Zeile fest -- genau das, wovor ihr
eigener Kommentar drei Zeilen darueber warnt („Die Pruefung hat den
alten Wortlaut bestaetigt statt sein Verhalten"). Sie prueft jetzt
beides: dass gefragt wird, und dass die Frage die richtige ist.

pruef-abzeichen 26/0, pruef-chat, pruef-anruf 132/0,
pruef-chat-kanaele 81/0, pruef-anruf-klingelt 25/0, pruef-push-ziel
38/0, pruef-arten 28/0, pruef-reaktion 421/0, pruef-struktur,
pruef-zwischenspeicher 34/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-30 23:48:49 +02:00

677 lines
29 KiB
JavaScript

/* =====================================================================
DIE ZAHL AUF DEM APP-SYMBOL (30.09.2026)
Filipe: „ich will dass du die benarichtigungen perfektionierst …
und das sehr hochwertig."
Das Abzeichen ist die kleine Zahl, die auf dem Startbildschirm am
Symbol der installierten App klebt. Es fehlte ganz — `setAppBadge`
kam im ganzen Haus nicht ein einziges Mal vor.
---------------------------------------------------------------------
WARUM ES UNGELESENE NACHRICHTEN ZÄHLT UND NICHTS SONST
Ein Abzeichen muss WEGGEHEN können. Ungelesene Nachrichten
verschwinden, sobald man sie liest. Eine überfällige Aufgabe
verschwindet nicht dadurch, dass man die App öffnet — eine Zahl,
die dauerhaft dasteht, ist nach drei Tagen keine Auskunft mehr,
sondern ein Fleck. Dann übersieht man auch die, die etwas bedeutet.
Dieselbe Überlegung wie bei der Warnung, die immer kommt.
Deshalb ist Abschnitt 3 der wichtigste in dieser Datei: Er misst
nicht, dass die Zahl STEIGT, sondern dass sie wieder FÄLLT.
---------------------------------------------------------------------
WAS HIER GEMESSEN WIRD — UND ZWAR AM ECHTEN WEG
Ein nachgebauter Browser meldet ein Gerät an, behält seinen
privaten Schlüssel, und ein nachgebauter Push-Dienst nimmt die
Nachricht entgegen. Aufgemacht wird sie mit dem Schlüssel des
Geräts — also genau so, wie es auf Filipes Handy passiert.
Ohne das Aufmachen wäre die Prüfung wertlos: Der Inhalt ist
verschlüsselt, und `benachrichtige` gibt die Zahl nicht zurück. Man
sähe nur, DASS etwas verschickt wurde, nicht WAS darin steht.
---------------------------------------------------------------------
GEGENPROBEN (Abschnitt 4)
Ohne sie hieße „die Zahl ist da" nur, dass irgendeine Zahl da ist:
* Eine Benachrichtigung OHNE Zahl darf auch keine mitschicken.
Sonst löschte eine Aufgabenerinnerung das Abzeichen des Chats.
* Etwas, das keine Zahl ist, darf nicht durchrutschen.
Aufrufen mit: node server/pruef-abzeichen.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { createServer } from "node:http";
import { eigenerPort } from "./helfer-port.mjs";
import { pushAufmachen } from "./helfer-push-aufmachen.mjs";
import { jsOhneKommentar } from "./helfer-ohne-kommentar.mjs";
const HIER = dirname(fileURLToPath(import.meta.url));
const WURZEL = join(HIER, "..");
const PORT = await eigenerPort(import.meta, "pruef-abzeichen");
const DIENST = await eigenerPort(import.meta, "pruef-abzeichen (Push-Dienst)", 1);
const ordner = mkdtempSync(join(tmpdir(), "ws-abz-"));
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";
/* ---- KEINE RUHEZEIT, UND ZWAR HERGESTELLT STATT GEHOFFT ----------
`chat_nachricht` trägt keine eigene Ruheregel, gilt also als
„immer": zwischen 22 und 7 Uhr geht nichts hinaus. Ohne diese zwei
Zeilen wäre die Prüfung eine Zeitbombe — grün am Nachmittag, rot um
halb zwölf nachts, und beides ohne eine Aussage über die Software.
PUSH_RUHE_AB = 24 gesperrt ab Stunde 24, die es nicht gibt
PUSH_RUHE_BIS = 0 und frei ab Stunde 0
Damit ist `s >= 24 || s < 0` zu jeder Tageszeit falsch. Die Uhr
wird nicht gefälscht, nur die Regel klar gesetzt — dieselbe Technik
wie in pruef-push-ziel, dort andersherum. */
process.env.PUSH_RUHE_AB = "24";
process.env.PUSH_RUHE_BIS = "0";
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(120_000, "pruef-abzeichen");
await new Promise((r) => setTimeout(r, 900));
/* Eigene Zuhörer NEBEN denen aus index.js — `process.on` ergänzt, es
ersetzt nicht. Sonst notiert das Auffangnetz des Betriebs meinen
Absturz nur, und der Lauf sieht von außen aus wie „läuft noch". */
process.on("uncaughtException", (f) => {
console.error("ABSTURZ:", f?.stack || f); process.exit(1);
});
process.on("unhandledRejection", (f) => {
console.error("ABSTURZ (Versprechen):", f?.stack || f); process.exit(1);
});
const BASIS = `http://127.0.0.1:${PORT}`;
let fehler = 0, geprueft = 0;
const ok = (b, t) => {
geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++;
};
/* DER DRITTE AUSGANG. Eine Prüfung, die ihre Voraussetzung nicht hat,
muss das SAGEN — „grün" und „rot" sind beide gelogen. */
const konntenicht = (t) => {
geprueft++; console.log(" ?? konnte nicht nachsehen: " + t); fehler++;
};
/* ---- Der nachgebaute Push-Dienst --------------------------------------
Er hebt die Rümpfe auf, damit wir hineinsehen können. Ein echter
Dienst kann das NICHT (das beweist pruef-push-weg) — hier geht es
nur deshalb, weil wir gleich auch den privaten Schlüssel des
„Geräts" besitzen. */
const pakete = [];
const dienst = createServer((q, a) => {
const teile = [];
q.on("data", (t) => teile.push(t));
q.on("end", () => { pakete.push(Buffer.concat(teile)); a.writeHead(201); a.end(); });
});
await new Promise((r) => dienst.listen(DIENST, "127.0.0.1", r));
const schliessen = () => new Promise((f) => {
if (!dienst.listening) return f();
dienst.close(() => f());
});
/* ---- Bestand ----------------------------------------------------------- */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createECDH } = await import("node:crypto");
/* Einmal anklopfen, damit index.js die Datenbank angelegt hat. */
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }),
}).catch(() => {});
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 salz = randomBytes(16).toString("hex");
const hash = 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, aktiv, erstellt)
VALUES (?,?,?,?,32768,1,?)`).run(name, rolle, hash, salz, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idAyla = anlegen("Ayla", "modi", "CODE-TEAM-0001");
/* DAS GERÄT — und wir behalten seinen privaten Schlüssel.
Ein erfundener Schlüssel würde beim Verschlüsseln scheitern; dann
wäre „nichts angekommen" die Antwort auf eine ganz andere Frage. */
const geraet = createECDH("prime256v1");
geraet.generateKeys();
const geheim = randomBytes(16);
d.prepare(`INSERT INTO push_anmeldungen (person_id, endpunkt, p256dh, auth, erstellt)
VALUES (?,?,?,?,?)`)
.run(idAyla, `http://127.0.0.1:${DIENST}/push/${idAyla}`,
geraet.getPublicKey().toString("base64url"), geheim.toString("base64url"), jetzt);
/* ---- Anmelden an der Crew-Wand ------------------------------------------
`fetch` setzt den Host-Kopf selbst; für die Wand braucht es
node:http. Danach genügt der Keks. */
const { request: httpAnfrage } = await import("node:http");
function anmeldenAn(rolle, code, wand = "crew.dogfather-universe.com") {
return new Promise((fertig) => {
const rumpf = JSON.stringify({ rolle, code });
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: "/workspace/api/anmelden", method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(rumpf),
Host: wand,
},
}, (antwort) => {
antwort.resume();
antwort.on("end", () => fertig({
ok: antwort.statusCode === 200,
keks: (antwort.headers["set-cookie"] || []).map((k) => k.split(";")[0]).join("; "),
wand,
}));
});
a.on("error", () => fertig({ ok: false, keks: "", wand }));
a.end(rumpf);
});
}
/** Ein Aufruf MIT Wand — und warum das hier nicht `fetch` sein darf.
*
* Das Haus einer Sitzung kommt aus dem Host-Feld (sitzungLesen liest
* `req.get("host")`), und `fetch` setzt dieses Feld selbst aus der
* Adresse; ein mitgegebenes ignoriert es. Über 127.0.0.1 ist das
* Haus damit weder crew noch agentur — `hausWo` gibt „1=1" zurück,
* und Abschnitt 7 hätte die Hausregel an einer Verbindung gemessen,
* die gar kein Haus hat.
*
* Genau das ist beim ersten Anlauf passiert: Die Prüfung blieb rot,
* nachdem die Regel schon eingebaut war. Ein Messfehler, der wie ein
* Befund aussieht — und andersherum hätte er einen echten Befund
* verdeckt. */
function rufe(sitzung, weg, koerper, methode = "GET") {
const rumpf = koerper === undefined ? undefined : JSON.stringify(koerper);
return new Promise((fertig) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: weg, method: methode,
headers: {
"Content-Type": "application/json",
Cookie: sitzung.keks,
Host: sitzung.wand,
...(rumpf === undefined ? {} : { "Content-Length": Buffer.byteLength(rumpf) }),
},
}, (antwort) => {
const teile = [];
antwort.on("data", (t) => teile.push(t));
antwort.on("end", () => {
const text = Buffer.concat(teile).toString("utf8");
fertig({
ok: antwort.statusCode >= 200 && antwort.statusCode < 300,
status: antwort.statusCode,
json: () => { try { return JSON.parse(text); } catch { return {}; } },
});
});
});
a.on("error", () => fertig({ ok: false, status: 0, json: () => ({}) }));
a.end(rumpf);
});
}
const dogi = await anmeldenAn("admin", "CODE-DOGI-0001");
const ayla = await anmeldenAn("modi", "CODE-TEAM-0001");
if (!dogi.ok || !ayla.ok) {
konntenicht("die Anmeldung hat nicht geklappt — ohne sie ist nichts zu messen");
await schliessen(); d.close();
rmSync(ordner, { recursive: true, force: true });
process.exit(1);
}
/* Ein Zweiergespräch. Ayla schaut NICHT zu (kein Ereignisstrom offen)
— nur dann entsteht überhaupt eine Benachrichtigung. */
const raumId = ((await rufe(dogi, "/workspace/api/chat/direkt",
{ mit: idAyla }, "POST")).json()).raum_id;
/** Schickt eine Nachricht und wartet, bis das Paket beim Push-Dienst
* liegt — auf das ERGEBNIS, nicht auf die Uhr. `chatEreignis` ruft
* die Benachrichtigung mit `void` auf; die Antwort der Route ist
* also schon da, bevor irgendetwas verschickt wurde. */
async function schreibenUndWarten(text, frist = 8000) {
const vorher = pakete.length;
const a = await rufe(dogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
{ text }, "POST");
if (!a.ok) return { fehlgeschlagen: `HTTP ${a.status}` };
const bis = Date.now() + frist;
while (pakete.length === vorher && Date.now() < bis) {
await new Promise((r) => setTimeout(r, 60));
}
if (pakete.length === vorher) return { fehlgeschlagen: `nach ${frist / 1000} s nichts angekommen` };
return { paket: pakete[pakete.length - 1] };
}
/** Das letzte Paket aufmachen und als Objekt zurückgeben. */
const inhalt = (paket) => JSON.parse(pushAufmachen(paket, geraet, geheim));
/* =======================================================================
1. DIE ZAHL KOMMT ÜBERHAUPT MIT
======================================================================= */
console.log("");
console.log("=== 1. Die Zahl reist in der Benachrichtigung mit ===");
let eins = null;
{
const r = await schreibenUndWarten("Erste Nachricht");
if (r.fehlgeschlagen) {
konntenicht(`die erste Nachricht kam nicht am Push-Dienst an (${r.fehlgeschlagen})`);
} else {
eins = inhalt(r.paket);
ok(typeof eins.titel === "string" && eins.titel.length > 0,
`die Benachrichtigung ist lesbar angekommen ("${eins.titel}")`);
ok(eins.zahl === 1,
`und trägt die Zahl 1 für das App-Symbol (zahl=${JSON.stringify(eins.zahl)})`);
}
}
/* =======================================================================
2. SIE ZÄHLT MIT
======================================================================= */
console.log("");
console.log("=== 2. Bei der zweiten Nachricht steht 2 darauf ===");
{
const r = await schreibenUndWarten("Zweite Nachricht");
if (r.fehlgeschlagen) {
konntenicht(`die zweite Nachricht kam nicht an (${r.fehlgeschlagen})`);
} else {
/* OHNE DIESE ZEILE beweist Abschnitt 1 nur, dass irgendeine Eins
mitkommt — eine fest eingebaute täte dasselbe. */
ok(inhalt(r.paket).zahl === 2,
`die Zahl wächst mit (zahl=${JSON.stringify(inhalt(r.paket).zahl)})`);
}
}
/* =======================================================================
3. UND SIE GEHT WIEDER WEG — das Wichtigste an der ganzen Sache
======================================================================= */
console.log("");
console.log("=== 3. Wer liest, wird das Abzeichen los ===");
{
const letzte = d.prepare("SELECT MAX(id) AS n FROM chat_nachrichten WHERE raum_id = ?")
.get(raumId)?.n;
const a = await rufe(ayla, `/workspace/api/chat/raeume/${raumId}/gelesen`,
{ bis: letzte }, "PUT");
ok(a.ok, `Ayla liest bis Nachricht ${letzte} (HTTP ${a.status})`);
const z = (await rufe(ayla, "/workspace/api/chat/ungelesen")).json();
ok(z.ungelesen === 0,
`danach steht die Zahl auf null — das Abzeichen verschwindet (${z.ungelesen})`);
/* UND SIE FÄNGT WIEDER BEI EINS AN. Das trennt „die Zahl wird
zurückgesetzt" von „die Zahl wird nur nicht mehr geschickt". */
const r = await schreibenUndWarten("Dritte Nachricht");
if (r.fehlgeschlagen) {
konntenicht(`die dritte Nachricht kam nicht an (${r.fehlgeschlagen})`);
} else {
ok(inhalt(r.paket).zahl === 1,
`die nächste Nachricht zählt wieder ab eins (zahl=${JSON.stringify(inhalt(r.paket).zahl)})`);
}
}
/* =======================================================================
4. GEGENPROBEN — was KEINE Zahl tragen darf
======================================================================= */
console.log("");
console.log("=== 4. Gegenproben ===");
{
const push = await import("./workspace-push.js");
/* EINE MELDUNG OHNE ZAHL DARF AUCH KEINE MITSCHICKEN.
Das ist die Zeile, an der die ganze Überlegung hängt: Käme bei
einer Aufgabenerinnerung `zahl: 0` mit, würde sie das Abzeichen
löschen — und die ungelesenen Nachrichten wären vom
Startbildschirm verschwunden, ohne dass jemand sie gelesen hat. */
const vorher = pakete.length;
await push.benachrichtige(idAyla, "test",
{ titel: "Erinnerung", text: "Etwas ganz anderes" });
if (pakete.length === vorher) {
konntenicht("die Probe-Benachrichtigung kam nicht an");
} else {
const o = inhalt(pakete[pakete.length - 1]);
ok(!("zahl" in o),
`eine Meldung ohne Zahl trägt auch keine (${JSON.stringify(Object.keys(o))})`);
}
/* UND ETWAS, DAS KEINE ZAHL IST, RUTSCHT NICHT DURCH. `Number
.isFinite` und nicht `typeof === "number"`: NaN wäre sonst eine
Zahl, und `setAppBadge(NaN)` ist keine Auskunft. */
const vorher2 = pakete.length;
await push.benachrichtige(idAyla, "test",
{ titel: "Krumm", text: "Probe", zahl: Number.NaN });
if (pakete.length === vorher2) {
konntenicht("die zweite Probe kam nicht an");
} else {
ok(!("zahl" in inhalt(pakete[pakete.length - 1])),
"eine Zahl, die keine ist (NaN), kommt gar nicht erst mit");
}
/* DIE GEGENPROBE ZUR GEGENPROBE. Ohne sie hieße „keine zahl dabei"
womöglich nur, dass NIE eine mitgeht — dann wären die drei
Abschnitte darüber grün gewesen, weil sie alle dasselbe Nichts
gemessen hätten. */
const vorher3 = pakete.length;
await push.benachrichtige(idAyla, "test",
{ titel: "Gerade", text: "Probe", zahl: 7 });
if (pakete.length === vorher3) {
konntenicht("die dritte Probe kam nicht an");
} else {
ok(inhalt(pakete[pakete.length - 1]).zahl === 7,
"eine echte Zahl geht dagegen mit — die Messung unterscheidet also");
}
}
/* =======================================================================
5. WER DIE ZAHL IM BROWSER SETZT
======================================================================= */
console.log("");
console.log("=== 5. Die eine Stelle in der Kopfleiste ===");
{
/* ==== GEMESSEN WIRD AM KOMMENTARFREIEN TEXT (30.09.2026) =======
Beim ersten Lauf war Abschnitt 6 rot — zu Unrecht. Die Suche
nahm die ERSTE Fundstelle von `setAppBadge`, und die steht im
Kommentar darüber („`setAppBadge` gibt es nicht überall"). Sie
hat damit den Kommentar vermessen, nicht den Code.
Dieselbe Falle wie in pruef-bewegung, dort unter „ein Beispiel
im Kommentar zählt nicht". Sie fällt nur auf, wenn sie zufällig
rot wird — andersherum hätte ein Kommentar eine fehlende Zeile
jahrelang gedeckt. */
const kopf = jsOhneKommentar(
readFileSync(join(WURZEL, "workspace", "assets", "js", "kopf.js"), "utf8"));
/* SIE MUSS IN `chatZahlZeigen` STEHEN und nirgends sonst. Diese
Funktion wird beim Laden, aus dem Ereignisstrom und aus dem Chat
gerufen — eine zweite Zählung wäre die, die irgendwann abweicht. */
const block = kopf.slice(kopf.indexOf("window.chatZahlZeigen"));
const ende = block.indexOf("\n };");
const rumpf = ende > 0 ? block.slice(0, ende) : "";
ok(rumpf.includes("setAppBadge"),
"das Abzeichen wird in chatZahlZeigen() gesetzt");
ok(rumpf.includes("clearAppBadge"),
"und bei null wieder gelöscht — sonst bliebe es für immer stehen");
/* KEINE ZWEITE STELLE. `setAppBadge` darf im ganzen Haus nur dort
und im Service Worker vorkommen. */
const zweite = [];
const { readdirSync } = await import("node:fs");
const suche = (verz) => {
for (const e of readdirSync(verz, { withFileTypes: true })) {
const p = join(verz, e.name);
if (e.isDirectory()) { suche(p); continue; }
if (!e.name.endsWith(".js")) continue;
const t = jsOhneKommentar(readFileSync(p, "utf8"));
if (!/setAppBadge|clearAppBadge/.test(t)) continue;
const kurz = p.slice(WURZEL.length + 1).replace(/\\/g, "/");
if (kurz !== "workspace/assets/js/kopf.js" && kurz !== "workspace/sw.js") {
zweite.push(kurz);
}
}
};
suche(join(WURZEL, "workspace"));
ok(zweite.length === 0,
zweite.length === 0
? "und es gibt keine zweite Stelle, die das Abzeichen anfasst"
: `ZWEITE STELLE: ${zweite.join(", ")}`);
/* GEGENPROBE: Die Suche findet überhaupt etwas — sonst bewiese die
Null oben nur, dass sie nichts liest. */
ok(/setAppBadge/.test(kopf),
"die Suche findet setAppBadge in kopf.js (sie liest also wirklich)");
}
/* =======================================================================
6. DER SERVICE WORKER — für die geschlossene App
======================================================================= */
console.log("");
console.log("=== 6. Wenn die App zu ist ===");
{
const sw = jsOhneKommentar(readFileSync(join(WURZEL, "workspace", "sw.js"), "utf8"));
ok(/Number\.isFinite\(d\.zahl\)/.test(sw),
"der Service Worker rührt das Abzeichen nur an, wenn eine Zahl dabei ist");
/* KEIN BEDINGUNGSLOSES LÖSCHEN. Genau das wäre der stille Schaden:
Jede beliebige Meldung nähme dem Chat sein Abzeichen weg. */
const zeilen = sw.split("\n").filter((z) => /clearAppBadge/.test(z));
ok(zeilen.length > 0 && zeilen.every((z) => /else/.test(z)),
zeilen.length
? `gelöscht wird nur im else-Zweig von „Zahl > 0" (${zeilen.length} Stelle(n))`
: "KEIN clearAppBadge gefunden — dann geht das Abzeichen nie weg");
/* Und es darf nichts zum Absturz bringen: Firefox kennt die
Funktion nicht, Safari nur für installierte Apps. Eine Zugabe,
die die Benachrichtigung mitreißt, wäre ein schlechtes Geschäft. */
/* AB DER WACHE GEMESSEN, nicht ab irgendeinem Vorkommen. `indexOf`
nimmt das erste — und das war beim ersten Lauf das im Kommentar. */
const stelle = sw.indexOf("Number.isFinite(d.zahl)");
const umher = sw.slice(stelle, stelle + 400);
ok(stelle >= 0 && /try\s*\{/.test(umher) && /catch/.test(umher),
"und der Versuch ist abgesichert — ein Gerät ohne Abzeichen bekommt trotzdem seine Meldung");
}
/* =======================================================================
7. DIE ZAHL GEHOERT ZU EINEM HAUS
Seit dem 24.09.2026 sieht jedes Haus nur seinen eigenen Bestand --
auch DogFather, der in beiden arbeitet. Die Raumliste haelt sich
daran (`hausWo(req.person, "r")`), die Zaehlung der ungelesenen
Nachrichten wurde nie danach gefragt.
Das faellt jetzt ins Gewicht: Bis heute war es eine Zahl an einem
Knopf. Ab heute steht dieselbe Zahl auf dem Startbildschirm -- und
eine Zahl, die etwas Falsches zeigt, ist schlimmer als keine, weil
man ihr glaubt.
======================================================================= */
console.log("");
console.log("=== 7. Jede Adresse zaehlt nur ihr eigenes Haus ===");
{
const idMana = anlegen("Cigdem", "manager", "CODE-MANA-0001");
const mana = await anmeldenAn("manager", "CODE-MANA-0001",
"workspace.dogfather-universe.com");
if (!mana.ok) {
konntenicht("die Managerin kam nicht an der Agenturwand herein");
} else {
/* Ein Gespraech im ANDEREN Haus. `direktRaum` traegt das Haus der
Person ein, die es anlegt -- hier also "agentur". */
const raumA = ((await rufe(mana, "/workspace/api/chat/direkt",
{ mit: idDogi }, "POST")).json()).raum_id;
ok(Number.isInteger(raumA), `die Agentur legt ein Gespraech an (Raum ${raumA})`);
const vorher = (await rufe(dogi, "/workspace/api/chat/ungelesen")).json();
await rufe(mana, `/workspace/api/chat/raeume/${raumA}/nachrichten`,
{ text: "Agentursache" }, "POST");
const nachher = (await rufe(dogi, "/workspace/api/chat/ungelesen")).json();
ok(nachher.ungelesen === vorher.ungelesen,
`DogFather zaehlt auf crew. keine Agenturnachricht mit `
+ `(vorher ${vorher.ungelesen}, nachher ${nachher.ungelesen})`);
/* GEGENPROBE: Auf der AGENTURADRESSE muss dieselbe Nachricht
gezaehlt werden. Ohne sie hiesse "nicht mitgezaehlt" womoeglich
nur, dass gar nichts gezaehlt wird. */
const dogiAgentur = await anmeldenAn("admin", "CODE-DOGI-0001",
"workspace.dogfather-universe.com");
if (!dogiAgentur.ok) {
konntenicht("DogFather kam nicht an der Agenturwand herein");
} else {
const dort = (await rufe(dogiAgentur,
"/workspace/api/chat/ungelesen")).json();
ok(dort.ungelesen >= 1,
`auf der Agenturadresse steht sie dagegen sehr wohl (${dort.ungelesen})`);
}
}
}
/* =======================================================================
8. DIENES MELDUNG: DIE APP IM HINTERGRUND (30.09.2026)
„Die Benachrichtigungen werden nicht angezeigt, wenn neue
Nachrichten reinkommen. Erst, wenn man die App öffnet."
Am echten Bestand nachgesehen: Diene HAT ein angemeldetes Geraet
(Android, Chrome, seit dem 29.09.), und alle zehn Geraete im Haus
melden `fehler = 0`. An der Zustellung liegt es nicht.
DER VERDACHT steht in workspace-chat.js:
if ((zuschauer.get(personId) || new Set()).size) continue;
Wer eine offene Verbindung hat, bekommt KEINE Benachrichtigung.
Der Gedanke daneben ist richtig -- „wer die Seite offen hat, sieht
die Nachricht ohnehin, und ihm auch noch eine Meldung aufs Handy zu
schicken ist der schnellste Weg, dass er Benachrichtigungen
abschaltet."
NUR HEISST EINE OFFENE VERBINDUNG NICHT, DASS JEMAND HINSIEHT.
Ein Handy mit der App im Hintergrund haelt sie weiter; der Server
haelt ihn dann fuer anwesend und schweigt.
HERGESTELLT UND NICHT ANGENOMMEN: Der Strom wird geoeffnet und
offen gelassen -- genau das, was ein Telefon im Hintergrund tut.
======================================================================= */
console.log("");
console.log("=== 8. Wenn die App im Hintergrund liegt ===");
{
const { request: httpRoh } = await import("node:http");
/* EINE ROHE VERBINDUNG, DIE OFFEN BLEIBT. `fetch` wartet auf das
Ende; ein Ereignisstrom endet nie. Genau daran ist im September
schon einmal eine Pruefung haengengeblieben. */
const stromAuf = (keks) => new Promise((fertig) => {
const a = httpRoh({
host: "127.0.0.1", port: PORT, path: "/workspace/api/chat/strom",
method: "GET",
headers: { Cookie: keks, Host: "crew.dogfather-universe.com", Accept: "text/event-stream" },
}, (antwort) => {
antwort.on("data", () => { }); /* mitlesen, nichts tun */
fertig({ anfrage: a, code: antwort.statusCode });
});
a.on("error", () => fertig({ anfrage: a, code: 0 }));
a.end();
});
const strom = await stromAuf(ayla.keks);
ok(strom.code === 200, `Aylas Handy haelt die Verbindung (HTTP ${strom.code})`);
/* Kurz warten, damit der Server sie wirklich eingetragen hat. */
await new Promise((r) => setTimeout(r, 400));
const vorher = pakete.length;
const g = await rufe(dogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
{ text: "Kommt das an, wenn die App hinten liegt?" }, "POST");
ok(g.ok, `DogFather schreibt ihr (HTTP ${g.status})`);
/* Auf das Ergebnis warten, nicht auf die Uhr -- aber hier ist das
AUSBLEIBEN das Ergebnis, also braucht es eine Frist. Drei
Sekunden sind grosszuegig: Der Weg zum Push-Dienst dauert
oertlich Millisekunden. */
const bis = Date.now() + 3000;
while (pakete.length === vorher && Date.now() < bis) {
await new Promise((r) => setTimeout(r, 60));
}
const kam = pakete.length > vorher;
console.log(` Benachrichtigung verschickt: ${kam ? "ja" : "NEIN"}`);
ok(kam,
kam
? "sie bekommt die Meldung, obwohl eine Verbindung offen ist "
+ "(die App liegt hinten)"
/* BACKTICKS, WEIL EINE DEUTSCHE ANFUEHRUNG DARIN STEHT. In
einem "..."-String beendet das ASCII-Zeichen nach dem Wort
den String -- mir heute zum fuenften Mal passiert. Wo eine
deutsche Anfuehrung hinein soll, gehoeren Backticks darum. */
: `KEINE MELDUNG — der Server haelt eine offene Verbindung fuer `
+ `„sie sieht ja hin". Genau Dienes Befund.`);
/* ==== DIE GEGENPROBE, OHNE DIE DIE ZEILE OBEN NICHTS BEWEIST ====
Wer WIRKLICH hinsieht, soll KEINE Meldung bekommen -- das war
der richtige Gedanke hinter der alten Zeile, und er bleibt.
Ohne diesen Teil hiesse die Reparatur nur „jetzt kommt immer
eine", und das waere der schnellste Weg, dass jemand
Benachrichtigungen abschaltet.
GEMELDET WIRD, WAS DIE SEITE SAGEN WUERDE: `sichtbar: true` --
genau das schickt die Kopfleiste, wenn die App vorn liegt. */
const sicht = await rufe(ayla, "/workspace/api/chat/sicht",
{ sichtbar: true }, "POST");
ok(sicht.ok, `Ayla meldet: ich sehe hin (HTTP ${sicht.status})`);
const vorherS = pakete.length;
await rufe(dogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
{ text: "Und wenn sie wirklich hinsieht?" }, "POST");
await new Promise((r) => setTimeout(r, 2500));
ok(pakete.length === vorherS,
`wer hinsieht, bekommt KEINE Meldung aufs Handy `
+ `(${pakete.length - vorherS} verschickt)`);
/* Und wenn sie die App weglegt, kommt sie wieder. */
await rufe(ayla, "/workspace/api/chat/sicht", { sichtbar: false }, "POST");
const vorherW = pakete.length;
await rufe(dogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
{ text: "App weggelegt." }, "POST");
const bisW = Date.now() + 5000;
while (pakete.length === vorherW && Date.now() < bisW) {
await new Promise((r) => setTimeout(r, 60));
}
ok(pakete.length > vorherW,
`legt sie die App weg, kommt die Meldung wieder `
+ `(${pakete.length - vorherW})`);
/* GEGENPROBE ZUM SCHLUSS: Ohne offene Verbindung erst recht. */
strom.anfrage.destroy();
await new Promise((r) => setTimeout(r, 500));
const vorher2 = pakete.length;
await rufe(dogi, `/workspace/api/chat/raeume/${raumId}/nachrichten`,
{ text: "Und jetzt, mit geschlossener App?" }, "POST");
const bis2 = Date.now() + 5000;
while (pakete.length === vorher2 && Date.now() < bis2) {
await new Promise((r) => setTimeout(r, 60));
}
ok(pakete.length > vorher2,
`Gegenprobe: ohne offene Verbindung kommt sie sehr wohl `
+ `(${pakete.length - vorher2})`);
}
/* ------------------------------------------------------------------- */
await schliessen();
d.close();
/* AUFRAEUMEN IST KEIN BEFUND. Der Server hält die Datenbank offen,
solange der Prozess läuft; unter Windows gibt `rmSync` darauf
EPERM. Das ist ein Rest im Temp-Ordner, kein Fehler in der
Software — und er als Absturz gemeldet hätte die ganze Prüfung rot
gefärbt, obwohl alle 15 Messungen grün waren. Genau so entsteht
ein Rot, das man sich abgewöhnt zu lesen. */
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest im Temp */ }
console.log("");
console.log(fehler === 0
? `ALLES IN ORDNUNG (${geprueft} Pruefungen)`
: `${fehler} von ${geprueft} Pruefungen sind nicht in Ordnung.`);
process.exit(fehler === 0 ? 0 : 1);