/* 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 { portMussFreiSein } = await import("./helfer-port.mjs"); await portMussFreiSein(4379, "die Rueckmeldungspruefung"); process.env.PORT = "4379"; 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:4379"; /* 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: 4379, 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);