Files
dogfather-universe/server/pruef-bewerbung.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

667 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
DIE MODI-ANFRAGE (17.09.2026)
Filipe: „ich will dass die leute sich da anonym bei mir melden können
und eine anfrage machen können für modi ... und dan soll ich annehmen
ablehnen, in warteschlange stellen oder sofort kommunizieren können.
und wenn ich annehme dan soll diese person automatisch zu talente
kategorie weiter geleitet werden."
---------------------------------------------------------------------
WAS DIESE PRÜFUNG WIRKLICH ABSICHERN MUSS
Nicht „kann man ein Formular abschicken" -- das sieht man. Sondern
die drei Zusagen, deren Bruch NIEMAND meldet:
1. VERTRAULICH. Hier stehen Alter, Schwächen und alte Sperren. Wer
das liest, entscheidet mit; wer es nicht lesen darf, darf auch
nicht erfahren, DASS es etwas gibt. Also 404, nicht 403 -- und
im Protokoll steht keine Zeile Inhalt.
2. DIE BRÜCKE TRÄGT GENAU EINMAL. „Annehmen" legt ein Talent an.
Zweimal angenommen (Doppelklick, Zurück-Taste, zwei Geräte) darf
kein zweites Talent ergeben -- sonst steht dieselbe Person
doppelt im Trichter, und das merkt man erst, wenn man sie
zweimal anspricht.
3. EINE ENTSCHEIDUNG OHNE SATZ IST KEINE. „Reden" und „Ablehnen"
verlangen eine Nachricht. Sie ist der einzige Weg, dieser Person
etwas zu sagen: Ein Zugang aus der Community steht ausdrücklich
nicht in den Chatlisten (AUSSEN_ROLLEN).
---------------------------------------------------------------------
WARUM node:http UND NICHT fetch -- siehe pruef-treff.mjs: Die
Community meldet sich an einer anderen Wand an, und welche Wand gilt,
entscheidet der Host-Kopf. `fetch` in Node setzt den selbst.
===================================================================== */
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta, "pruef-bewerbung");
const ordner = mkdtempSync(join(tmpdir(), "ws-bewerb-"));
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";
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(300_000, "pruef-bewerbung");
await new Promise((r) => setTimeout(r, 700));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
let fehler = 0, geprueft = 0, offen = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* Der dritte Ausgang: konnte nicht nachsehen. Mit Grund -- sonst weiss
man danach so wenig wie vorher. */
const unklar = (t) => { offen++; console.log(" -- " + t); };
const CREW = "crew.dogfather-universe.com";
const HAUS = "workspace.dogfather-universe.com";
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const fragen = await import("./workspace-bewerbung-fragen.js");
const rollen = await import("./workspace-bewerbung-rollen.js");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const schluessel = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", schluessel, jetzt);
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, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Rieke", "hand", "CODE-HAND-0001");
anlegen("Frida", "modi", "CODE-MODI-0001");
const idKessi = anlegen("Kessi", "gast", "CODE-GAST-0001");
const idBenno = anlegen("Benno", "gast", "CODE-GAST-0002");
const idCora = anlegen("Cora", "gast", "CODE-GAST-0003");
d.close();
function roh(pfad, host, kopf = {}, koerper = null, methode = null) {
return new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad,
method: methode || (koerper ? "POST" : "GET"),
headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf },
}, (antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
}
const alsJson = (r) => { try { return JSON.parse(r.text); } catch { return {}; } };
async function anmelden(rolle, code, host) {
const r = await roh("/workspace/api/anmelden", host, {},
rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code });
const kekse = [].concat(r.kopf?.["set-cookie"] || []);
return { keks: kekse.map((c) => c.split(";")[0]).join("; "), code: r.code };
}
const kDogi = await anmelden("admin", "CODE-DOGI-0001", CREW);
const kHand = await anmelden("hand", "CODE-HAND-0001", CREW);
const kModi = await anmelden("modi", "CODE-MODI-0001", CREW);
const kKessi = await anmelden("gast", "CODE-GAST-0001", CREW);
const kBenno = await anmelden("gast", "CODE-GAST-0002", CREW);
const kCora = await anmelden("gast", "CODE-GAST-0003", CREW);
const holen = (pfad, k, host = CREW) => roh(pfad, host, { Cookie: k.keks });
const schicken = (pfad, k, koerper, methode = "POST", host = CREW) =>
roh(pfad, host, { Cookie: k.keks }, koerper, methode);
/* Ein vollstaendig ausgefuellter Bogen -- die Pflichtliste kommt aus dem
Katalog und nicht aus einer Abschrift hier. Wer eine Pflichtfrage
ergaenzt, bekommt sie automatisch mit; eine Liste hier waere die, die
beim naechsten Mal fehlt. */
function bogen(zusatz = {}) {
const a = {};
for (const s of fragen.PFLICHT) a[s] = "Antwort auf " + s + " - lang genug fuer eine Pruefung.";
a.name = zusatz.name || "Kessi";
return { ...a, ...zusatz };
}
/* =======================================================================
1. DER KATALOG
======================================================================= */
melde("");
melde("=== 1. Der Fragebogen selbst ===");
ok(fragen.BEWERBUNG_FRAGEN.length >= 12,
`der Bogen hat ${fragen.BEWERBUNG_FRAGEN.length} Fragen`);
ok(fragen.PFLICHT.length >= 6 && fragen.PFLICHT.length < fragen.BEWERBUNG_FRAGEN.length,
`davon ${fragen.PFLICHT.length} Pflicht – und nicht alle`);
{
/* JEDE FRAGE GEHOERT IN EINE GRUPPE. Eine Frage ohne Gruppe wird von
der Oberflaeche stillschweigend nicht gezeichnet -- sie faellt
nicht auf, sie fehlt einfach. */
const bekannt = new Set(fragen.BEWERBUNG_GRUPPEN.map((g) => g.schluessel));
const heimatlos = fragen.BEWERBUNG_FRAGEN.filter((f) => !bekannt.has(f.gruppe));
ok(heimatlos.length === 0,
heimatlos.length ? `ohne Gruppe: ${heimatlos.map((f) => f.schluessel).join(", ")}`
: "jede Frage steht in einer bekannten Gruppe");
const leer = fragen.BEWERBUNG_GRUPPEN.filter(
(g) => !fragen.BEWERBUNG_FRAGEN.some((f) => f.gruppe === g.schluessel));
ok(leer.length === 0,
leer.length ? `leere Gruppen: ${leer.map((g) => g.schluessel).join(", ")}`
: "und jede Gruppe hat Fragen");
}
{
const s = fragen.BEWERBUNG_FRAGEN.map((f) => f.schluessel);
ok(new Set(s).size === s.length, "die Schlüssel sind eindeutig");
}
ok(fragen.BEWERBUNG_WEGE.length === 4,
`vier Wege: ${fragen.BEWERBUNG_WEGE.map((w) => w.name).join(" · ")}`);
ok(fragen.BEWERBUNG_WEGE[0].schluessel === "gespraech",
"„Reden“ steht vorne – die häufigste richtige Antwort ist eine Rückfrage");
ok(fragen.BEWERBUNG_WEGE.filter((w) => w.weiter === "talent").length === 1,
"genau ein Weg führt zu den Talenten");
/* =======================================================================
2. WER DEN BOGEN ÜBERHAUPT BEKOMMT
======================================================================= */
melde("");
melde("=== 2. Wer ihn bekommt ===");
{
const a = alsJson(await holen("/workspace/api/bewerbung", kKessi));
ok(a.darf === true, "die Community darf eine Anfrage stellen");
ok(Array.isArray(a.fragen) && a.fragen.length === fragen.BEWERBUNG_FRAGEN.length,
`und bekommt alle ${a.fragen?.length} Fragen mitgeliefert`);
ok(typeof a.wer_liest === "string" && a.wer_liest.length > 10,
`und erfährt vorher, wer es liest („${String(a.wer_liest).slice(0, 40)}…“)`);
}
for (const [name, k] of [["ein Modi", kModi], ["die rechte Hand", kHand], ["DogFather", kDogi]]) {
const a = alsJson(await holen("/workspace/api/bewerbung", k));
ok(a.darf === false, `${name} bekommt kein Formular – wer dabei ist, bewirbt sich nicht`);
}
/* =======================================================================
3. ABSCHICKEN
======================================================================= */
melde("");
melde("=== 3. Abschicken ===");
{
/* Eine Pflichtantwort fehlt. Der Server sagt WELCHE -- sonst sucht
man in fuenfzehn Feldern. */
const unvoll = bogen();
delete unvoll[fragen.PFLICHT[2]];
const r = await schicken("/workspace/api/bewerbung", kKessi, { antworten: unvoll });
const a = alsJson(r);
ok(r.code === 400, `eine fehlende Pflichtantwort wird abgewiesen (${r.code})`);
ok(Array.isArray(a.fehlt) && a.fehlt.includes(fragen.PFLICHT[2]),
`und der Server sagt, welche (${JSON.stringify(a.fehlt)})`);
const zahl = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true })
.prepare("SELECT COUNT(*) n FROM bewerbungen").get().n;
ok(zahl === 0, "und es liegt nichts halb Fertiges in der Datenbank");
}
{
const r = await schicken("/workspace/api/bewerbung", kKessi,
{ antworten: bogen({ name: "Kessi", warum: "Ich bin fast jeden Abend da." }) });
ok(r.code === 201, `ein vollständiger Bogen geht durch (${r.code})`);
}
{
const r = await schicken("/workspace/api/bewerbung", kKessi, { antworten: bogen() });
ok(r.code === 409, `eine zweite Anfrage geht nicht, solange die erste offen ist (${r.code})`);
}
{
/* FREMDE HERKUNFT. Ein Formular auf einer fremden Seite koennte den
Weg im Browser eines Angemeldeten ausloesen. */
const r = await roh("/workspace/api/bewerbung", CREW,
{ Cookie: kBenno.keks, Origin: "https://boese.example" }, { antworten: bogen() });
ok(r.code === 403, `aus fremder Herkunft geht nichts (${r.code})`);
const gut = await schicken("/workspace/api/bewerbung", kBenno,
{ antworten: bogen({ name: "Benno" }) });
ok(gut.code === 201,
`– und die Gegenprobe: aus dem eigenen Haus geht es sehr wohl (${gut.code})`);
}
/* =======================================================================
4. VERTRAULICH
======================================================================= */
melde("");
melde("=== 4. Wer sie lesen darf ===");
for (const [name, k] of [["ein Modi", kModi], ["die Community selbst", kKessi]]) {
const r = await holen("/workspace/api/bewerbungen", k);
ok(r.code === 404,
`${name} bekommt den Eingang nicht – und zwar 404, nicht „kein Zugriff“ (${r.code})`);
}
for (const [name, k] of [["DogFather", kDogi], ["die rechte Hand", kHand]]) {
const r = await holen("/workspace/api/bewerbungen", k);
const a = alsJson(r);
ok(r.code === 200 && a.bewerbungen?.length === 2,
`${name} sieht beide Anfragen (${r.code}, ${a.bewerbungen?.length})`);
}
{
const a = alsJson(await holen("/workspace/api/bewerbungen", kDogi));
const eine = a.bewerbungen.find((b) => b.nenn_name === "Kessi");
ok(eine?.antworten?.warum === "Ich bin fast jeden Abend da.",
"die Antworten kommen vollständig mit");
ok(typeof eine?.tage === "number",
`und dabei steht, wie lange sie schon liegt (${eine?.tage} Tage)`);
}
{
/* IM PROTOKOLL STEHT KEIN INHALT. Das Protokoll lesen mehr Leute als
die Anfrage selbst. */
const p = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true })
.prepare("SELECT aktion, detail FROM protokoll WHERE aktion LIKE 'bewerbung%'").all();
ok(p.length >= 2, `das Protokoll hält die Anfragen fest (${p.length} Zeilen)`);
const verraten = p.filter((z) => /jeden Abend|Antwort auf/.test(String(z.detail || "")));
ok(verraten.length === 0,
verraten.length ? `Inhalt im Protokoll: ${JSON.stringify(verraten[0])}`
: "aber keine Zeile Inhalt – nur die Nummer");
}
/* =======================================================================
5. ENTSCHEIDEN — und der Satz, der bei der Person ankommt
======================================================================= */
melde("");
melde("=== 5. Entscheiden ===");
const idKessiAnfrage = alsJson(await holen("/workspace/api/bewerbungen", kDogi))
.bewerbungen.find((b) => b.nenn_name === "Kessi").id;
{
const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi,
{ weg: "gespraech", grund: "" }, "PUT");
ok(r.code === 400, `„Reden“ ohne einen Satz geht nicht (${r.code})`);
/* WARUM DAS EINE ECHTE REGEL IST und keine Gaengelei: Es gibt keinen
zweiten Weg, dieser Person etwas zu sagen. Ein Zugang aus der
Community steht nicht in den Chatlisten. */
ok(rollen.BEWERBUNG_ROLLEN.has("gast"),
"denn eine Anfrage kommt aus der Community – und die steht in keiner Chatliste");
}
{
const satz = "Wie viele Abende in der Woche schaffst du realistisch?";
const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi,
{ weg: "gespraech", grund: satz }, "PUT");
ok(r.code === 200, `mit Satz geht es (${r.code})`);
const eigen = alsJson(await holen("/workspace/api/bewerbung", kKessi));
ok(eigen.meine?.zustand === "gespraech" && eigen.meine?.grund === satz,
"und der Satz steht auf ihrer eigenen Seite – das ist das „sofort kommunizieren“");
ok(eigen.offen === true,
"die Anfrage bleibt dabei offen – „Reden“ ist keine Entscheidung");
}
{
/* DIE RECHTE HAND ENTSCHEIDET MIT -- Filipes Antwort auf die
Rueckfrage vom 17.09.: „Du und die rechte Hand." */
const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kHand,
{ weg: "warteschlange", grund: "" }, "PUT");
ok(r.code === 200, `die rechte Hand darf ebenfalls entscheiden (${r.code})`);
const r2 = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kModi,
{ weg: "angenommen", grund: "" }, "PUT");
ok(r2.code === 404, `ein Modi nicht – und erfährt auch nicht, dass es sie gibt (${r2.code})`);
}
{
const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi,
{ weg: "voellig-erfunden", grund: "x" }, "PUT");
ok(r.code === 400, `ein erfundener Weg wird abgewiesen (${r.code})`);
}
/* =======================================================================
6. DIE BRÜCKE ZU DEN TALENTEN
======================================================================= */
melde("");
melde("=== 6. Aus einer Anfrage wird ein Talent ===");
{
const vorher = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true })
.prepare("SELECT COUNT(*) n FROM eintraege WHERE bereich = 'talente'").get().n;
ok(vorher === 0, "vorher steht niemand bei den Talenten");
const r = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi,
{ weg: "angenommen", grund: "Willkommen." }, "PUT");
const a = alsJson(r);
ok(r.code === 200 && a.eintrag_id > 0, `„Annehmen“ legt ein Talent an (${r.code})`);
const db2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
const e = db2.prepare(
"SELECT titel, bereich FROM eintraege WHERE id = ?").get(a.eintrag_id);
ok(e?.bereich === "talente" && e?.titel === "Kessi",
`und zwar unter dem Namen, den sie selbst genannt hat („${e?.titel}“)`);
const st = db2.prepare("SELECT stufe FROM talent_stufe WHERE eintrag_id = ?").get(a.eintrag_id);
/* AUF „ANGESPROCHEN" -- die ersten beiden Stufen fragen, ob ueberhaupt
Interesse besteht. Wer sich selbst meldet, hat das beantwortet. */
ok(st?.stufe === "angesprochen",
`auf der Stufe, die zur Selbstmeldung passt („${st?.stufe}“)`);
/* GENAU EINMAL. Doppelklick, Zurueck-Taste, zweites Geraet -- eine
zweite Karte im Trichter faellt erst auf, wenn man dieselbe Person
zweimal anspricht. */
const nochmal = await schicken(`/workspace/api/bewerbung/${idKessiAnfrage}/weg`, kDogi,
{ weg: "angenommen", grund: "Willkommen." }, "PUT");
const b = alsJson(nochmal);
const nachher = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true })
.prepare("SELECT COUNT(*) n FROM eintraege WHERE bereich = 'talente'").get().n;
ok(nachher === 1, `zweimal annehmen ergibt kein zweites Talent (${nachher})`);
ok(b.eintrag_id === a.eintrag_id, "sondern denselben Eintrag");
}
{
const eigen = alsJson(await holen("/workspace/api/bewerbung", kKessi));
ok(eigen.meine?.zustand === "angenommen" && eigen.offen === false,
"und sie sieht auf ihrer Seite, dass sie dabei ist");
}
/* =======================================================================
7. ABLEHNEN — mit Grund, und danach eine Frist
======================================================================= */
melde("");
melde("=== 7. Ablehnen ===");
const idBennoAnfrage = alsJson(await holen("/workspace/api/bewerbungen", kDogi))
.bewerbungen.find((b) => b.nenn_name === "Benno").id;
{
const r = await schicken(`/workspace/api/bewerbung/${idBennoAnfrage}/weg`, kDogi,
{ weg: "abgelehnt", grund: "nein" }, "PUT");
ok(r.code === 400, `eine Ablehnung ohne Satz geht nicht (${r.code})`);
const r2 = await schicken(`/workspace/api/bewerbung/${idBennoAnfrage}/weg`, kDogi,
{ weg: "abgelehnt", grund: "Gerade zu wenig Zeit – melde dich im Frühjahr nochmal." }, "PUT");
ok(r2.code === 200, `mit Satz geht es (${r2.code})`);
const eigen = alsJson(await holen("/workspace/api/bewerbung", kBenno));
ok(String(eigen.meine?.grund || "").includes("Frühjahr"),
"und der Satz steht auf seiner Seite – nicht nur in der Akte");
ok(eigen.sperre?.tage > 0,
`danach läuft eine Frist (${eigen.sperre?.tage} von ${eigen.sperre?.gesamt} Tagen)`);
const neu = await schicken("/workspace/api/bewerbung", kBenno, { antworten: bogen({ name: "Benno" }) });
ok(neu.code === 429, `und eine neue Anfrage geht in dieser Frist nicht (${neu.code})`);
ok(/Tagen/.test(alsJson(neu).fehler || ""),
`die Absage sagt, WANN es wieder geht („${String(alsJson(neu).fehler).slice(0, 44)}…“)`);
}
{
/* DIE GEGENPROBE ZUR FRIST: Wer nie abgelehnt wurde, kommt durch.
Sonst hiesse „429" nur, dass gerade niemand etwas schicken kann. */
const r = await schicken("/workspace/api/bewerbung", kCora, { antworten: bogen({ name: "Cora" }) });
ok(r.code === 201, `wer nie abgelehnt wurde, kann schicken (${r.code})`);
}
/* =======================================================================
8. DIE WEGE DORTHIN
======================================================================= */
melde("");
melde("=== 8. Wie man hinkommt ===");
{
/* Der Knopf auf dem Brett „Mitmachen" -- vom Server, nicht aus einer
Datei, die jeder herunterladen kann. */
const a = alsJson(await holen("/workspace/api/bereich/mitmachen", kCora));
ok(a.weiter?.ziel === "bewerben.html",
"auf „Mitmachen“ steht für die Community der Weg zur Anfrage");
const b = alsJson(await holen("/workspace/api/bereich/mitmachen", kModi));
ok(!b.weiter,
"ein Modi sieht ihn nicht – ein Knopf, den der Server ablehnt, wäre schlimmer als keiner");
const c = alsJson(await holen("/workspace/api/bereich/regeln", kCora));
ok(!c.weiter, "und auf den anderen Brettern steht er auch nicht");
}
{
const a = alsJson(await holen("/workspace/api/bewerbungen-zahl", kDogi));
ok(a.offen === 1 && a.neu === 1,
`die Talente-Seite bekommt nur die Zahl (offen ${a.offen}, neu ${a.neu})`);
const r = await holen("/workspace/api/bewerbungen-zahl", kModi);
ok(r.code === 404, `und auch die nur die beiden (${r.code})`);
}
{
/* DIE SEITEN SELBST. Sie haben keine Kachel -- also muss die
Adressregel sie kennen, sonst landet man auf der Startseite. */
for (const [name, k, seite, erwartet] of [
["die Community", kKessi, "bewerben.html", 200],
["ein Modi", kModi, "bewerben.html", 302],
["DogFather", kDogi, "bewerbungen.html", 200],
["die rechte Hand", kHand, "bewerbungen.html", 200],
["ein Modi", kModi, "bewerbungen.html", 302],
["die Community", kKessi, "bewerbungen.html", 302],
]) {
const r = await holen("/workspace/" + seite, k);
ok(r.code === erwartet, `${name} → ${seite}: ${r.code}`);
}
/* AUCH AUF DER AGENTURADRESSE -- DogFather arbeitet auf beiden. */
const r = await roh("/workspace/bewerbungen.html", HAUS, { Cookie: kDogi.keks });
ok(r.code === 200 || r.code === 302,
`auf workspace. antwortet die Seite ebenfalls (${r.code})`);
}
/* =======================================================================
9. DIE OBERFLÄCHE
======================================================================= */
melde("");
/* =======================================================================
8b. WER SICH KUEMMERT (17.09.2026)
Filipe wollte die Arbeit mit der rechten Hand teilen. Gemessen: Es
gab dafuer nichts -- beide sehen jede Anfrage, niemand ist benannt.
Bei einer Anfrage ist das teurer als anderswo: Dort wartet ein
Mensch auf eine Antwort.
DREI DINGE WERDEN GEPRUEFT, und das dritte ist das eigentliche:
1. Uebernehmen und abgeben mit demselben Knopf.
2. Der Zustaendige steht in der Liste.
3. Eine FREMDE Uebernahme wird abgewiesen -- sonst nimmt einer dem
anderen die Anfrage aus der Hand, ohne dass es jemand merkt.
Und der Riegel: Wer den Eingang nicht sehen darf, darf sich auch
nicht zustaendig erklaeren. Diese Zeile hat beim Schreiben GEFEHLT
(der Router setzt `angemeldet` fuer den ganzen Pfad, `nurLeitung`
aber je Route) -- gefunden beim Nachsehen der Nachbarzeilen, nicht
beim Schreiben.
======================================================================= */
melde("");
melde("=== 8b. Wer sich kuemmert ===");
{
let liste = alsJson(await holen("/workspace/api/bewerbungen", kDogi));
const offen = (liste.bewerbungen || []).find((b) => b.zustand === "neu")
|| (liste.bewerbungen || [])[0];
ok(!!offen, `eine Anfrage zum Uebernehmen (${offen?.nenn_name})`);
/* DIE ANZAHL GEHOERT IN DIE BEDINGUNG. Beim ersten Lauf war die
Liste leer, und diese Zeile war trotzdem gruen -- `undefined` ist
nun einmal kein Zustaendiger. Eine Pruefung, die ohne Daten
bestaetigt, bestaetigt nichts. */
ok(!!offen && !offen.zustaendig_id, "um die sich noch niemand kuemmert");
{
const r = await schicken(`/workspace/api/bewerbung/${offen.id}/kuemmern`, kDogi, null, "PUT");
ok(r.code === 200 && alsJson(r).zustaendig === true, `uebernehmen geht (${r.code})`);
liste = alsJson(await holen("/workspace/api/bewerbungen", kDogi));
const b = (liste.bewerbungen || []).find((x) => x.id === offen.id);
ok(b?.zustaendig_name === "Filipe",
`und es steht in der Liste (${b?.zustaendig_name})`);
ok(!!b?.zustaendig_seit, `mit dem Zeitpunkt (${b?.zustaendig_seit?.slice(0, 10)})`);
}
/* DER FALL, AUF DEN ES ANKOMMT */
{
const r = await schicken(`/workspace/api/bewerbung/${offen.id}/kuemmern`, kHand, null, "PUT");
ok(r.code === 409, `die rechte Hand nimmt sie ihm nicht aus der Hand (${r.code})`);
ok((alsJson(r).fehler || "").includes("Filipe"),
`und erfaehrt, wer sich kuemmert ("${alsJson(r).fehler}")`);
liste = alsJson(await holen("/workspace/api/bewerbungen", kDogi));
const b = (liste.bewerbungen || []).find((x) => x.id === offen.id);
ok(b?.zustaendig_name === "Filipe", "der Zustaendige bleibt unveraendert");
}
/* Abgeben -- und dann kann der andere */
{
await schicken(`/workspace/api/bewerbung/${offen.id}/kuemmern`, kDogi, null, "PUT");
liste = alsJson(await holen("/workspace/api/bewerbungen", kDogi));
let b = (liste.bewerbungen || []).find((x) => x.id === offen.id);
ok(!b?.zustaendig_id, "derselbe Knopf gibt wieder ab");
const r = await schicken(`/workspace/api/bewerbung/${offen.id}/kuemmern`, kHand, null, "PUT");
ok(r.code === 200, `jetzt kann die rechte Hand (${r.code})`);
liste = alsJson(await holen("/workspace/api/bewerbungen", kDogi));
b = (liste.bewerbungen || []).find((x) => x.id === offen.id);
ok(b?.zustaendig_name === "Rieke", `und steht dort (${b?.zustaendig_name})`);
/* Zurueck auf Anfang, damit Abschnitt 9 die Karten unveraendert
vorfindet -- eine Pruefung, die der naechsten den Zustand
verstellt, macht deren Ergebnis unlesbar. */
await schicken(`/workspace/api/bewerbung/${offen.id}/kuemmern`, kHand, null, "PUT");
}
/* DER RIEGEL. Wer den Eingang nicht sehen darf, darf sich auch nicht
zustaendig erklaeren -- sonst haengt ein Name an einer Anfrage, der
dort nichts zu suchen hat, und sie gilt als betreut, waehrend sich
niemand kuemmert. */
for (const [wer, keks] of [["ein Modi", kModi], ["ein Zugang aus der Community", kKessi]]) {
const r = await schicken(`/workspace/api/bewerbung/${offen.id}/kuemmern`, keks, null, "PUT");
ok(r.code === 404, `${wer} kann sich nicht zustaendig erklaeren (${r.code})`);
}
{
const r = await schicken("/workspace/api/bewerbung/999999/kuemmern", kDogi, null, "PUT");
ok(r.code === 404, `eine erfundene Nummer wird abgewiesen (${r.code})`);
}
}
melde("");
melde("=== 9. Im echten Browser ===");
{
let chromium;
try {
({ chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"));
} catch { chromium = null; }
if (!chromium) {
unklar("kein Playwright gefunden – die Oberfläche wurde nicht angesehen");
} else {
const browser = await chromium.launch();
try {
const sicht = await browser.newContext({ viewport: { width: 390, height: 844 } });
const kekse = (k) => k.keks.split("; ").map((c) => {
const [name, ...rest] = c.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
});
/* ---- Der Fragebogen ---- */
await sicht.addCookies(kekse(kCora));
const s1 = await sicht.newPage();
const konsole = [];
s1.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
await s1.goto(`http://127.0.0.1:${PORT}/workspace/bewerben.html`,
{ waitUntil: "domcontentloaded" });
await s1.waitForTimeout(900);
const f = await s1.evaluate(() => ({
stand: document.getElementById('stand')?.textContent?.trim() || '',
bogenDa: !document.getElementById('bogen')?.hidden,
felder: document.querySelectorAll('.bw-feld__eingabe').length,
pflicht: document.querySelectorAll('.bw-feld__pflicht').length,
gruppen: document.querySelectorAll('.bw-gruppe').length,
}));
/* Cora hat gerade geschickt -- sie sieht den Stand, nicht das
Formular. Das ist die richtige Antwort und zugleich der Beweis,
dass die Seite den Zustand liest. */
ok(f.bogenDa === false && /liegt vor/.test(f.stand),
`wer schon geschickt hat, sieht den Stand statt des Formulars („${f.stand.slice(0, 32)}…“)`);
ok(konsole.length === 0,
konsole.length ? `Fehler in der Konsole: ${konsole[0]}` : "ohne Fehler in der Konsole");
await s1.close();
await sicht.close();
/* Ein unverbrauchter Zugang, um das Formular selbst zu sehen. */
const idDina = (() => {
const dd = new DatabaseSync(process.env.WORKSPACE_DB);
const salt = randomBytes(16).toString("hex");
const hash = scryptSync("CODE-GAST-0004", salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
dd.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?)")
.run("Dina", "gast", hash, salt, 32768,
createHmac("sha256", schluessel).update("CODE-GAST-0004").digest("hex"), jetzt);
const id = dd.prepare("SELECT last_insert_rowid() AS id").get().id;
dd.close();
return id;
})();
const kDina = await anmelden("gast", "CODE-GAST-0004", CREW);
const sicht2 = await browser.newContext({ viewport: { width: 390, height: 844 } });
await sicht2.addCookies(kekse(kDina));
const s2 = await sicht2.newPage();
await s2.goto(`http://127.0.0.1:${PORT}/workspace/bewerben.html`,
{ waitUntil: "domcontentloaded" });
await s2.waitForTimeout(900);
const g = await s2.evaluate(() => ({
felder: document.querySelectorAll('.bw-feld__eingabe').length,
pflicht: document.querySelectorAll('.bw-feld__pflicht').length,
gruppen: document.querySelectorAll('.bw-gruppe').length,
werLiest: document.getElementById('wer-liest')?.textContent || '',
}));
ok(g.felder === fragen.BEWERBUNG_FRAGEN.length,
`der Bogen zeichnet alle ${g.felder} Fragen`);
ok(g.pflicht === fragen.PFLICHT.length,
`und markiert genau die ${g.pflicht} Pflichtfragen`);
ok(g.gruppen === fragen.BEWERBUNG_GRUPPEN.length,
`in ${g.gruppen} Abschnitten`);
ok(/DogFather/.test(g.werLiest),
"über dem Absenden steht, wer es liest");
/* ABSCHICKEN OHNE PFLICHTANTWORT -- die Seite markiert, sie
schickt nicht. */
await s2.click('#abschicken');
await s2.waitForTimeout(400);
const h = await s2.evaluate(() => ({
markiert: document.querySelectorAll('.bw-feld--fehlt').length,
meldung: document.getElementById('fehler')?.textContent || '',
}));
ok(h.markiert === fragen.PFLICHT.length,
`ein leerer Bogen markiert die ${h.markiert} fehlenden Felder`);
ok(/fehlen|fehlt/.test(h.meldung),
`und sagt es in Worten („${h.meldung.slice(0, 40)}“)`);
const zahlNachher = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true })
.prepare("SELECT COUNT(*) n FROM bewerbungen WHERE person_id = ?").get(idDina).n;
ok(zahlNachher === 0, "und es wurde nichts geschickt");
if (process.env.SCHIRM) {
await s2.screenshot({ path: "server/pruef-bewerbung-bogen.png", fullPage: true });
melde(" Bild: server/pruef-bewerbung-bogen.png");
}
await s2.close();
await sicht2.close();
/* ---- Der Eingang ---- */
const sicht3 = await browser.newContext({ viewport: { width: 1280, height: 900 } });
await sicht3.addCookies(kekse(kDogi));
const s3 = await sicht3.newPage();
const konsole3 = [];
s3.on("console", (m) => { if (m.type() === "error") konsole3.push(m.text()); });
await s3.goto(`http://127.0.0.1:${PORT}/workspace/bewerbungen.html`,
{ waitUntil: "domcontentloaded" });
await s3.waitForTimeout(900);
const e = await s3.evaluate(() => ({
karten: document.querySelectorAll('.bw-karte').length,
wege: document.querySelectorAll('.bw-karte .bw-weg').length,
zahlen: document.querySelectorAll('#zahlen .bilanz-zahl').length,
antworten: document.querySelectorAll('.bw-antwort').length,
}));
ok(e.karten === 3, `der Eingang zeigt alle drei Anfragen (${e.karten})`);
ok(e.wege >= 12, `mit vier Wegen je Karte (${e.wege} Knöpfe)`);
ok(e.zahlen === 3, `und drei Zahlen oben (${e.zahlen})`);
ok(e.antworten > 10, `die Antworten stehen darin (${e.antworten} Zeilen)`);
ok(konsole3.length === 0,
konsole3.length ? `Fehler in der Konsole: ${konsole3[0]}` : "ohne Fehler in der Konsole");
if (process.env.SCHIRM) {
await s3.screenshot({ path: "server/pruef-bewerbung.png", fullPage: true });
melde(" Bild: server/pruef-bewerbung.png");
}
await s3.close();
await sicht3.close();
} finally {
await browser.close();
}
}
}
melde("");
melde(`${geprueft} geprüft, ${fehler} Fehler`
+ (offen ? `, ${offen} konnte(n) nicht nachgesehen werden` : ""));
process.exit(fehler ? 1 : 0);