Files
dogfather-universe/server/pruef-rueckmeldung.mjs
T
DogFatherGitandClaude Opus 5 6e46a08543 Portnummern werden abgeleitet, nicht mehr vergeben
Gemessen: 165 Pruefdateien, 129 verschiedene Nummern -- NEUNZEHN
doppelt, vier davon dreifach. Niemand hatte das gewollt; jede neue
Pruefung wurde von einer vorhandenen abgeschrieben, und die Nummer kam
mit. Meine eigene Notiz sagte "sieben" -- auch eine Bestandsliste
altert.

Der Waechter faengt den Schaden ab, aber er kann nur melden, was schon
passiert ist: Zwei Pruefungen mit derselben Nummer koennen nie
gleichzeitig laufen, und ein liegengebliebener Prozess der einen laesst
die andere abbrechen mit einer Meldung, die wie ein Befund aussieht.
Genau das ist mir am 20.09. zweimal passiert.

Jetzt leitet jede Datei ihre Nummer aus ihrer STELLE IM ALPHABET ab
(eigenerPort in helfer-port.mjs), zwei je Datei. Nicht ueber eine
Pruefsumme: Bei 165 Namen in 4900 Nummern waeren nach dem
Geburtstagsproblem rund DREI Zusammenstoesse zu erwarten -- ein Hash
tauscht eine sichtbare Doppelung gegen eine unsichtbare. Die Stelle im
Alphabet ist eindeutig von der Bauart her.

--- ZWEI FEHLER AUF DEM WEG, BEIDE LEHRREICH -------------------------

1. DER ERSTE VERSUCH WAR GRUEN UND KAPUTT. Ersetzt wurde mit einem
   Muster: "([^"]*4231[^"]*)". Das hielt

     { host: "127.0.0.1", port: 4231, path: "/404.html" }

   fuer eine Zeichenkette -- ein Muster kann eine oeffnende nicht von
   einer schliessenden Anfuehrung unterscheiden. Heraus kam

     { host: "127.0.0.1`, port: ${PORT}, path: `/404.html" }

   also GUELTIGER Code ohne port-Feld. `node --check` sagte gruen fuer
   alle 164 Dateien. Aufgefallen ist es erst, weil ich vier Vertreter
   gegen eine vorher gemessene Grundlinie laufen liess: pruef-schranke
   39/0 vorher, 38/1 nachher.

   Alles zurueckgenommen und mit einem Zerleger neu gemacht, der weiss,
   ob eine Stelle Code, Zeichenkette, Vorlage, Kommentar oder
   regulaerer Ausdruck ist. In pruef-ics stand die Nummer in einem
   regulaeren Ausdruck -- der wird jetzt gebaut statt hingeschrieben.

2. EIN MODUL, DAS BEIM IMPORTIEREN ARBEITET, IST EINE FALLE. Der
   zweite Durchgang importierte den ersten, um seine Mechanik zu
   benutzen -- und fuehrte dessen Hauptlauf gleich mit aus. Die
   zweiten Nummern wurden dadurch als erste behandelt, zwei Aufrufe
   bekamen dieselbe Nummer, und in pruef-content stand `const PORT`
   zweimal.

--- WAS DAS DAUERHAFT HAELT -----------------------------------------

pruef-portnummern.mjs (neu, 8 Pruefungen) fragt nicht "welche Nummern
sind doppelt", sondern "wer traegt ueberhaupt noch eine von Hand ein"
und "wer startet einen Server, ohne seine Nummer abzuleiten". Die
zweite Frage hat sofort etwas gefunden, das in KEINER Doppelungsliste
stand: pruef-push-weg belegte 4341 und 4342, rief den Waechter aber
gar nicht auf -- dieselbe Nummer wie pruef-agentur. Eine Liste zeigt
nur, was auf ihr steht.

Mit Gegenprobe: Eine unbekannte Datei bekommt keine geratene Nummer,
sondern einen Abbruch, und eine dritte Nummer je Datei gibt es nicht.

--- NACHGEMESSEN ----------------------------------------------------

Sechzehn Pruefungen gegen ihre vorher gemessene Grundlinie, je eine
Vertreterin jeder umgestellten Bauweise (eine Nummer, zwei Nummern,
Nummer in einer Zeichenkette, in einer Vorlage, in einem regulaeren
Ausdruck, dynamische Einfuhr, Nachtlauf mit zwei Laeufen):

  crew-adresse 132/0 · schranke 39/0 · content 45/0 · entwicklung 46/0
  anruf 127/0 · ics 37/0 · push-weg 20/0 · arten 28/0 · video 67/0
  kanalzeile 14/0 · agentur 62/0 · spicy 83/0 · creator-anlegen 50/0
  push-ziel 10/0 · portnummern 8/0

Alle exakt wie vorher. Zwei waren schon vorher rot und sind es
unveraendert geblieben (crew-wand-bild 41/4, teilen 14/3) -- per
`git stash` belegt, nicht angenommen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-20 22:51:21 +02:00

310 lines
14 KiB
JavaScript

/* RÜCKMELDUNG IN BEIDE RICHTUNGEN (10.09.2026)
Kapitel 5 und 6 des Pflichtenhefts:
"Nicht nur ich soll meine Modis bewerten oder ihnen Feedback geben
können. Auch die Modis sollen mir Feedback geben können. Sie sollen
mir beispielsweise sagen können: Was könnte ich verbessern?"
"Es soll nicht nur dazu dienen, Leistungen zu kontrollieren. Es soll
vor allem dabei helfen, als Team besser zu werden."
---------------------------------------------------------------------
DIE PRÜFUNG, DERENTWEGEN ES DIESE DATEI GIBT
Wer beim Schreiben „nur an DogFather" wählt, verlässt sich darauf.
Diese Zusage hat genau eine Stelle, an der sie gilt --
`sichtbarEintrag()` --, und genau zwei Arten, still zu brechen:
Sie könnte NICHT WEIT GENUG gehen: Die Liste ist gefiltert, aber
über die Nummer der Zeile (Ändern, Löschen) kommt man trotzdem
heran. Deshalb wird hier auch von hinten geklopft.
Sie könnte ZU WEIT gehen: `nur_leitung` ist eine neue Spalte, und
jede Zeile, die es vorher gab, hat dort NULL. In SQL ist
`NULL = 0` nicht falsch, sondern UNBEKANNT -- ohne COALESCE wäre
der gesamte alte Bestand von einer Minute auf die andere
unsichtbar gewesen. Ein leeres Brett sieht nicht nach Fehler aus;
das hätte niemand gemeldet.
Beide Fälle stehen unten, jeder mit seiner Gegenprobe.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-rueck-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Rueckmeldungspruefung");
process.env.PORT = `${PORT}`;
process.env.SITE_ACCESS_SECRET = "lokaler-test";
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");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = `http://127.0.0.1:${PORT}`;
/* Netz und Notbremse -- index.js faengt sonst jeden Absturz ab, und der
Lauf sieht von aussen aus wie "laeuft noch". */
for (const art of ["uncaughtException", "unhandledRejection"]) {
process.on(art, (f) => { console.error(""); console.error("ABBRUCH:", f?.stack || f); process.exit(1); });
}
setTimeout(() => { console.error(""); console.error("ABBRUCH: 120 s vorbei."); process.exit(1); },
120000).unref?.();
let fehler = 0, geprueft = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
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 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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Vanessa", "hand", "CODE-HAND-0001");
const idAyla = anlegen("Ayla", "modi", "CODE-TEAM-0001");
anlegen("Ben", "modi", "CODE-TEAM-0002");
anlegen("Cigdem", "manager", "CODE-MANA-0001");
/* Team-Rollen kommen nur ueber die Crew-Wand herein, Agenturrollen nur
ueber die Agenturwand -- die Regel vom 10.09.2026. `fetch` kann den
Host-Kopf nicht setzen (undici setzt ihn aus der Adresse), deshalb
node:http. */
const { request: httpAnfrage } = await import("node:http");
function anmelden(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("; "),
}));
});
a.on("error", () => fertig({ ok: false, keks: "" }));
a.end(rumpf);
});
}
const rufe = (keks, weg, koerper, methode = "GET") =>
fetch(BASIS + weg, {
method: methode,
headers: { "Content-Type": "application/json", Cookie: keks },
body: koerper === undefined ? undefined : JSON.stringify(koerper),
});
const holen = async (keks, weg) => (await rufe(keks, weg)).json();
const dogi = await anmelden("admin", "CODE-DOGI-0001");
const hand = await anmelden("hand", "CODE-HAND-0001");
const ayla = await anmelden("modi", "CODE-TEAM-0001");
const ben = await anmelden("modi", "CODE-TEAM-0002");
const cigdem = await anmelden("manager", "CODE-MANA-0001", "workspace.dogfather-universe.com");
ok([dogi, hand, ayla, ben, cigdem].every((x) => x.ok && x.keks), "alle fuenf sind angemeldet");
const WEG = "/workspace/api/bereich/rueckmeldung";
const titel = async (keks) =>
((await holen(keks, WEG)).eintraege || []).map((e) => e.titel);
/* =======================================================================
1. Es gibt den Bereich, und er hat die Fragen aus dem Pflichtenheft
======================================================================= */
console.log("");
console.log("=== Der Bereich ===");
{
const a = await holen(dogi.keks, WEG);
ok(a.einstellung?.name === "Rückmeldung", `DogFather kann ihn oeffnen (${a.einstellung?.name})`);
const arten = Object.keys(a.einstellung?.arten || {});
ok(arten.length === 9, `neun Fragen (${arten.join(", ")})`);
ok(a.einstellung?.vertraulich === true, "und er kennt die vertrauliche Zeile");
const b = await holen(ayla.keks, WEG);
ok(b.einstellung?.name === "Rückmeldung", "das Team kann ihn auch oeffnen");
/* GEGENPROBE: Die Agentur hat hier nichts zu suchen -- 404 und nicht
403, sonst waere allein die Antwort schon eine Auskunft. */
const c = await rufe(cigdem.keks, WEG);
ok(c.status === 404, `ein Manager kommt nicht hinein (${c.status})`);
}
/* =======================================================================
2. Beide Richtungen
======================================================================= */
console.log("");
console.log("=== Wer schreibt wem ===");
let offenId = 0, geheimId = 0;
{
const a = await rufe(ayla.keks, WEG, {
art: "gut", titel: "Die Absprachen vor dem Live helfen sehr",
datum: "2026-09-10", dringlichkeit: "niedrig",
}, "POST");
offenId = (await a.json())?.id;
ok(a.status === 201 && offenId > 0, `ein Teammitglied schreibt fuers Team (${a.status})`);
const b = await rufe(ayla.keks, WEG, {
art: "dogfather", titel: "Du unterbrichst uns oft mitten im Satz",
datum: "2026-09-10", dringlichkeit: "mittel", nur_leitung: 1,
}, "POST");
geheimId = (await b.json())?.id;
ok(b.status === 201 && geheimId > 0, `und eine nur an DogFather (${b.status})`);
const c = await rufe(dogi.keks, WEG, {
art: "behalten", titel: "Die Schichtübergabe so lassen",
datum: "2026-09-10", dringlichkeit: "niedrig",
}, "POST");
ok(c.status === 201, `DogFather schreibt in dieselbe Sammlung (${c.status})`);
/* Die Spalte muss wirklich gesetzt sein -- nicht nur die Antwort
stimmen. Ohne diese Zeile waere die Pruefung gruen, wenn das Feld
stillschweigend verworfen wuerde. */
const zeile = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(geheimId);
ok(zeile?.nur_leitung === 1, `die Zeile steht als vertraulich in der Datenbank (${zeile?.nur_leitung})`);
const offen = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(offenId);
ok(offen?.nur_leitung === 0, `und die andere als offen (${offen?.nur_leitung})`);
}
/* =======================================================================
3. DIE ZUSAGE: „nur DogFather" heisst nur DogFather
======================================================================= */
console.log("");
console.log("=== Wer sieht was ===");
{
const beiDogi = await titel(dogi.keks);
ok(beiDogi.some((t) => t.startsWith("Du unterbrichst")),
`DogFather sieht die vertrauliche Zeile (${beiDogi.length} Eintraege)`);
const beiAyla = await titel(ayla.keks);
ok(beiAyla.some((t) => t.startsWith("Du unterbrichst")),
"die Schreiberin sieht ihre eigene");
/* DIE EIGENTLICHE PRUEFUNG. Die rechte Hand sieht sonst ueberall
dasselbe wie DogFather -- hier nicht. */
const beiHand = await titel(hand.keks);
ok(!beiHand.some((t) => t.startsWith("Du unterbrichst")),
`die rechte Hand sieht sie NICHT (${beiHand.join(" | ")})`);
const beiBen = await titel(ben.keks);
ok(!beiBen.some((t) => t.startsWith("Du unterbrichst")),
"ein anderes Teammitglied auch nicht");
/* GEGENPROBE: Sonst koennte "sieht sie nicht" auch heissen "sieht
ueberhaupt nichts". Die OFFENE Zeile muss bei beiden ankommen. */
ok(beiHand.some((t) => t.startsWith("Die Absprachen")),
"die offene Zeile sehen beide sehr wohl");
ok(beiBen.some((t) => t.startsWith("Die Absprachen")),
"auch das andere Teammitglied");
}
/* =======================================================================
4. Und von hinten? Die Nummer ist kein Schluessel
======================================================================= */
console.log("");
console.log("=== Ueber die Nummer ===");
{
const a = await rufe(hand.keks, `${WEG}/${geheimId}`, { titel: "umgeschrieben" }, "PATCH");
ok(a.status === 404, `die rechte Hand kann sie nicht aendern (${a.status})`);
const b = await rufe(hand.keks, `${WEG}/${geheimId}`, undefined, "DELETE");
ok(b.status === 404, `und nicht loeschen (${b.status})`);
const heil = d.prepare("SELECT titel FROM eintraege WHERE id = ?").get(geheimId);
ok(heil?.titel?.startsWith("Du unterbrichst"), "die Zeile steht unveraendert da");
/* GEGENPROBE: An der OFFENEN Zeile darf sie arbeiten -- sonst hiesse
die 404 oben nur "diese Route geht fuer sie nie". */
const c = await rufe(hand.keks, `${WEG}/${offenId}`, { status: "erledigt" }, "PATCH");
ok(c.status === 200, `an der offenen Zeile darf sie arbeiten (${c.status})`);
}
/* =======================================================================
5. Der alte Bestand (NULL) bleibt sichtbar
======================================================================= */
console.log("");
console.log("=== Was vor der Spalte da war ===");
{
d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, erstellt, erstellt_von, nur_leitung)
VALUES ('rueckmeldung','idee','Aus der Zeit vor der Spalte',?,?,?,NULL)`)
.run("2026-09-01", jetzt, idAyla);
const beiHand = await titel(hand.keks);
ok(beiHand.some((t) => t === "Aus der Zeit vor der Spalte"),
"eine Zeile mit NULL gilt als offen und bleibt sichtbar");
/* Die Gegenprobe zur Gegenprobe: Genau diese Zeile MIT 1 waere weg. */
d.prepare("UPDATE eintraege SET nur_leitung = 1 WHERE titel = 'Aus der Zeit vor der Spalte'").run();
const nachher = await titel(hand.keks);
ok(!nachher.some((t) => t === "Aus der Zeit vor der Spalte"),
"dieselbe Zeile auf 1 gesetzt ist fuer sie weg -- die Regel greift wirklich");
d.prepare("UPDATE eintraege SET nur_leitung = NULL WHERE titel = 'Aus der Zeit vor der Spalte'").run();
}
/* =======================================================================
6. In anderen Bereichen gibt es das Feld nicht
======================================================================= */
console.log("");
console.log("=== Kein Versteck in anderen Bereichen ===");
{
const a = await rufe(ayla.keks, "/workspace/api/bereich/ideen", {
art: "content", titel: "Eine ganz normale Idee", datum: "2026-09-10",
nur_leitung: 1,
}, "POST");
const id = (await a.json())?.id;
ok(a.status === 201, `eine Idee laesst sich anlegen (${a.status})`);
const zeile = d.prepare("SELECT nur_leitung FROM eintraege WHERE id = ?").get(id);
ok(!zeile?.nur_leitung,
`das Feld wird dort stillschweigend verworfen (${zeile?.nur_leitung})`);
const beiHand = await holen(hand.keks, "/workspace/api/bereich/ideen");
ok((beiHand.eintraege || []).some((e) => e.id === id),
"und die Idee steht fuer alle da -- niemand kann sich dort verstecken");
}
/* =======================================================================
7. Die Kachel steht bei allen dreien -- mit demselben Namen
======================================================================= */
console.log("");
console.log("=== Die Kachel ===");
{
const kacheln = async (keks) => {
const i = await holen(keks, "/workspace/api/ich");
return [...(i.bereiche || []), ...(i.bereiche_zusatz || [])];
};
for (const [wer, keks] of [["DogFather", dogi.keks], ["die rechte Hand", hand.keks],
["das Team", ayla.keks]]) {
const k = (await kacheln(keks)).filter((x) => x.ziel === "bereich.html?b=rueckmeldung");
ok(k.length === 1 && k[0].name === "Rückmeldung",
`${wer}: genau eine Kachel, und sie heisst gleich (${k.map((x) => x.name).join("/") || "keine"})`);
}
/* Der Farbton muss neu sein -- zwei Kacheln in derselben Farbe sind
ein Fehler, den pruef-start-ansicht sonst erst am Bildschirm
findet. */
const alle = await kacheln(dogi.keks);
const toene = alle.map((x) => x.ton).filter((t) => t !== undefined);
ok(new Set(toene).size === toene.length,
`kein Farbton doppelt (${toene.length} Kacheln vom Server)`);
}
d.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log("");
console.log(`${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"} -- ${geprueft} geprueft, ${fehler} Fehler`);
process.exit(fehler ? 1 : 0);