/* ===================================================================== test-annahme.mjs — Anfrage annehmen und ablehnen Das Annehmen ist der folgenreichste Knopf der ganzen Seite: Ein Klick legt einen Kunden an, ein Projekt, eine Aufgabenliste, einen Termin und einen Zugang. Geht dabei etwas schief, entsteht Halbes, das man von Hand wieder auseinanderklauben muss — und beim Kunden ist der Eindruck schon entstanden. Deshalb wird hier nicht nur der gute Fall geprüft, sondern vor allem: - der ZWEITE Klick (darf kein zweites Projekt anlegen) - der Abbruch mittendrin (darf NICHTS zurücklassen) - der schon bekannte Kunde (darf keinen Zwilling erzeugen) - der gesperrte Kunde (darf nicht still entsperrt werden) ===================================================================== */ import { mkdtempSync, rmSync, writeSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { randomBytes, createHmac } from "node:crypto"; const L = (...a) => writeSync(1, a.join(" ") + "\n"); const tmp = mkdtempSync(join(tmpdir(), "wd-ann-")); process.env.DB_PATH = join(tmp, "t.db"); process.env.ENCRYPTION_KEY = randomBytes(32).toString("base64"); process.env.WEBDESIGN_API_SECRET = randomBytes(24).toString("hex"); process.env.WD_BASIS_URL = "https://dogfather-universe.com"; const { initDb, db } = await import("./db.js"); initDb(); const geheim = await import("./lib/webdesign-geheimnisse.js"); await geheim.geheimnisseLaden(); const { vorlagenSicherstellen } = await import("./lib/webdesign-vorlagen.js"); vorlagenSicherstellen(); const A = await import("./routes/webdesign-annahme.js"); let ok = 0, fehler = 0; const pruefe = (n, b, z) => b ? (ok++, L(" ok " + n)) : (fehler++, L(" FEHL " + n + (z !== undefined ? " -> " + z : ""))); function ausweis(rolle = "dogfather", gueltigMs = 60_000) { const nutzlast = Buffer.from(JSON.stringify({ bereich: "wd-admin", rolle, exp: Date.now() + gueltigMs, })).toString("base64url"); const sig = createHmac("sha256", process.env.WEBDESIGN_API_SECRET) .update(nutzlast).digest("base64url"); return nutzlast + "." + sig; } const REQ = (extra = {}) => ({ headers: { authorization: "Bearer " + ausweis() }, cookies: {}, params: {}, body: {}, query: {}, ...extra, }); function antwortFaenger() { let out = null; return { res: { status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {}, writeHead() { return this; } }, holen: () => out }; } async function ruf(fn, extra) { const f = antwortFaenger(); await fn(REQ(extra), f.res); return f.holen(); } const anfrageAnlegen = (id, email, paket = "website", name = "Anna Muster") => { db.prepare( `INSERT INTO wd_anfragen (id, nummer, name, email, telefon, paket, details, status, sprache, erstellt_am) VALUES (?,?,?,?,?,?,?, 'neu', 'de', ?)` ).run(id, "A-" + id, name, email, "+352 000", paket, "Bitte modern und schlicht.", new Date().toISOString()); }; L("\n1. VORSCHAU — was passiert, BEVOR etwas passiert?"); anfrageAnlegen("a1", "anna@example.com"); let v = await ruf(A.annahmeVorschau, { params: { id: "a1" } }); pruefe("die Vorschau antwortet", v && v.ok === true, JSON.stringify(v).slice(0, 120)); pruefe("sie schlaegt einen Titel vor", v.titelVorschlag === "Website für Anna Muster", v.titelVorschlag); pruefe("sie schlaegt einen echten Termin vor", /^\d{4}-\d{2}-\d{2}$/.test(v.terminVorschlag || ""), v.terminVorschlag); pruefe("mit 20 Werktagen fuer eine Website", v.werktage === 20, v.werktage); pruefe("sie kennt die Zahl der Arbeitsschritte", v.schritte > 5, v.schritte); pruefe("sie sagt, dass der Kunde neu ist", v.kundeNeu === true); pruefe("und dass noch nichts angenommen wurde", v.bereitsAngenommen === null); pruefe("die Vorschau hat NICHTS angelegt", db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n === 0); L("\n2. ANNEHMEN — ein Klick, und alles steht"); let r = await ruf(A.anfrageAnnehmen, { params: { id: "a1" } }); pruefe("die Annahme gelingt", r && r.ok === true, JSON.stringify(r).slice(0, 200)); pruefe("es gibt eine Projektnummer", /^P-/.test(r.projektNummer || ""), r.projektNummer); pruefe("der Kunde wurde angelegt", r.kundeNeu === true); pruefe("es gibt einen Einladungslink", /einladung=/.test(r.einladungsLink || ""), r.einladungsLink); const projekt = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(r.projektId); pruefe("das Projekt haengt an der Anfrage", projekt.anfrage_id === "a1"); pruefe("es hat einen echten Liefertermin", /^\d{4}-\d{2}-\d{2}$/.test(projekt.termin_am || ""), projekt.termin_am); pruefe("der Zeitpunkt der Annahme ist festgehalten", !!projekt.angenommen_am); pruefe("die zugesagten Werktage sind festgehalten", projekt.termin_werktage === 20, projekt.termin_werktage); /* Der erste Zug liegt beim Kunden -- ohne Unterlagen kann nichts beginnen. Stuende hier "dogfather", saehe der Kunde "wird bearbeitet" und wuesste nicht, dass auf ihn gewartet wird. */ pruefe("der erste Zug liegt beim KUNDEN", projekt.wartet_auf === "kunde", projekt.wartet_auf); pruefe("es gibt einen naechsten Schritt im Klartext", /Unterlagen/.test(projekt.naechster_schritt || ""), projekt.naechster_schritt); const aufgaben = db.prepare(`SELECT COUNT(*) AS n FROM wd_aufgaben WHERE projekt_id = ?`).get(r.projektId); pruefe("die Aufgabenliste aus der Vorlage steht", aufgaben.n > 5, aufgaben.n); pruefe("und die Antwort nennt dieselbe Zahl", r.schritte === aufgaben.n, r.schritte + " vs. " + aufgaben.n); const kunde = db.prepare(`SELECT * FROM wd_kunden WHERE id = ?`).get(r.kundeId); pruefe("der Kunde ist freigeschaltet", kunde.freigeschaltet === 1); pruefe("seine Sprache wurde uebernommen", kunde.sprache === "de", kunde.sprache); pruefe("seine Telefonnummer wurde uebernommen", !!kunde.telefon); const anf = db.prepare(`SELECT * FROM wd_anfragen WHERE id = 'a1'`).get(); pruefe("die Anfrage steht auf angenommen", anf.status === "angenommen", anf.status); pruefe("und ist mit dem Kunden verknuepft", anf.kunde_id === r.kundeId); L("\n3. DER ZWEITE KLICK — darf nichts doppelt anlegen"); const vorher = db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n; const r2 = await ruf(A.anfrageAnnehmen, { params: { id: "a1" } }); pruefe("der zweite Klick meldet KEINEN Fehler", r2 && r2.ok === true, JSON.stringify(r2).slice(0, 120)); pruefe("er sagt, dass schon angenommen war", r2.bereitsAngenommen === true); pruefe("er liefert dieselbe Projektnummer", r2.projektNummer === r.projektNummer); pruefe("und es entstand KEIN zweites Projekt", db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n === vorher, db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n + " statt " + vorher); pruefe("auch keine zweite Aufgabenliste", db.prepare(`SELECT COUNT(*) AS n FROM wd_aufgaben WHERE projekt_id = ?`).get(r.projektId).n === aufgaben.n); L("\n4. EIN KUNDE, DER SCHON DA IST — kein Zwilling"); /* Bewusst mit anderer Gross-/Kleinschreibung: "Anna@" und "anna@" sind derselbe Mensch. Zwei Kundeneintraege fuer eine Person sind spaeter kaum wieder zu trennen -- Nachrichten und Projekte haengen dann an verschiedenen Zugaengen. */ anfrageAnlegen("a2", "ANNA@example.com", "onepager"); const kundenVorher = db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden`).get().n; const r3 = await ruf(A.anfrageAnnehmen, { params: { id: "a2" } }); pruefe("die zweite Anfrage wird angenommen", r3 && r3.ok === true, JSON.stringify(r3).slice(0, 120)); pruefe("KEIN zweiter Kundeneintrag trotz anderer Schreibweise", db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden`).get().n === kundenVorher, db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden`).get().n); pruefe("es ist derselbe Kunde", r3.kundeId === r.kundeId); pruefe("kein neuer Einladungslink -- er hat ja schon einen Zugang", r3.einladungsLink === null, String(r3.einladungsLink)); pruefe("aber ein eigenes, zweites Projekt", r3.projektId !== r.projektId); pruefe("Onepager bekommt 10 Werktage", db.prepare(`SELECT termin_werktage AS w FROM wd_projekte WHERE id = ?`).get(r3.projektId).w === 10); L("\n5. EIN GESPERRTER KUNDE DARF NICHT STILL ENTSPERRT WERDEN"); db.prepare(`UPDATE wd_kunden SET gesperrt = 1, freigeschaltet = 0 WHERE id = ?`).run(r.kundeId); anfrageAnlegen("a3", "anna@example.com", "shop"); await ruf(A.anfrageAnnehmen, { params: { id: "a3" } }); const nachher = db.prepare(`SELECT gesperrt, freigeschaltet FROM wd_kunden WHERE id = ?`).get(r.kundeId); pruefe("er bleibt gesperrt", nachher.gesperrt === 1, nachher.gesperrt); pruefe("und wird nicht nebenbei freigeschaltet", nachher.freigeschaltet === 0, nachher.freigeschaltet); db.prepare(`UPDATE wd_kunden SET gesperrt = 0, freigeschaltet = 1 WHERE id = ?`).run(r.kundeId); L("\n6. EIGENER TERMIN STATT VORSCHLAG"); anfrageAnlegen("a4", "bert@example.com", "website", "Bert Beispiel"); const r4 = await ruf(A.anfrageAnnehmen, { params: { id: "a4" }, body: { termin: "2026-12-24" } }); pruefe("ein eigener Termin wird uebernommen", db.prepare(`SELECT termin_am AS t FROM wd_projekte WHERE id = ?`).get(r4.projektId).t === "2026-12-24"); anfrageAnlegen("a5", "carla@example.com", "website", "Carla Muster"); const r5 = await ruf(A.anfrageAnnehmen, { params: { id: "a5" }, body: { termin: "31.13.2026" } }); pruefe("ein unmoegliches Datum wird abgewiesen", r5.ok === false, JSON.stringify(r5).slice(0, 120)); pruefe("und dabei entsteht NICHTS", db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte WHERE anfrage_id = 'a5'`).get().n === 0); L("\n7. LAUFENDE BETREUUNG — kein Liefertermin, kein Countdown"); anfrageAnlegen("a6", "dora@example.com", "betreuung", "Dora Muster"); const r6 = await ruf(A.anfrageAnnehmen, { params: { id: "a6" } }); pruefe("sie wird angenommen", r6.ok === true); pruefe("aber ohne Liefertermin", db.prepare(`SELECT termin_am AS t FROM wd_projekte WHERE id = ?`).get(r6.projektId).t === null); L("\n8. OHNE E-MAIL GEHT ES NICHT — und es bleibt nichts liegen"); db.prepare( `INSERT INTO wd_anfragen (id, nummer, name, email, paket, status, sprache, erstellt_am) VALUES ('a7','A-a7','Ohne Mail','', 'website', 'neu','de',?)` ).run(new Date().toISOString()); const r7 = await ruf(A.anfrageAnnehmen, { params: { id: "a7" } }); pruefe("die Annahme wird abgelehnt", r7.ok === false, JSON.stringify(r7).slice(0, 140)); pruefe("und es entstand kein Kunde ohne Adresse", db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden WHERE email = ''`).get().n === 0); L("\n9. ABBRUCH MITTENDRIN — es darf NICHTS zurueckbleiben"); /* Der wichtigste Fall. Geprueft wird, indem der Vorlagenschritt zum Scheitern gebracht wird: Die Tabelle wd_aufgaben bekommt kurz einen Ausloeser, der jedes Einfuegen verweigert. Ohne Transaktion blieben Kunde und Projekt stehen -- ein Zugang, hinter dem eine leere Seite wartet. */ db.exec(`CREATE TRIGGER pruef_stopp BEFORE INSERT ON wd_aufgaben BEGIN SELECT RAISE(ABORT, 'absichtlicher Testfehler'); END;`); anfrageAnlegen("a8", "emil@example.com", "website", "Emil Muster"); const kundenVorAbbruch = db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden`).get().n; const projekteVorAbbruch = db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n; /* Vorher zaehlen statt eine feste Zahl zu erwarten: Bis hierher wurden drei Kunden voellig zurecht angelegt, also gibt es drei Zugaenge. Die erste Fassung dieser Pruefung verlangte "hoechstens einer" und schlug deshalb fehl, obwohl alles stimmte -- eine feste Erwartung an einen mitwachsenden Bestand ist immer falsch. */ const zugaengeVorAbbruch = db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden_token`).get().n; const r8 = await ruf(A.anfrageAnnehmen, { params: { id: "a8" } }); db.exec(`DROP TRIGGER pruef_stopp;`); pruefe("der Abbruch wird gemeldet", r8 && r8.ok === false, JSON.stringify(r8).slice(0, 160)); pruefe("KEIN Kunde blieb zurueck", db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden`).get().n === kundenVorAbbruch, db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden`).get().n + " statt " + kundenVorAbbruch); pruefe("KEIN Projekt blieb zurueck", db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n === projekteVorAbbruch); pruefe("die Anfrage steht NICHT faelschlich auf angenommen", db.prepare(`SELECT status FROM wd_anfragen WHERE id = 'a8'`).get().status === "neu"); pruefe("und der Abbruch hinterliess KEINEN neuen Zugang", db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden_token`).get().n === zugaengeVorAbbruch, db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden_token`).get().n + " statt " + zugaengeVorAbbruch); L("\n10. ABLEHNEN"); anfrageAnlegen("b1", "frank@example.com", "shop", "Frank Muster"); const vorschau = await ruf(A.absageVorschau, { params: { id: "b1" }, query: { grund: "budget" } }); pruefe("die Absage-Vorschau antwortet", vorschau.ok === true); pruefe("sie nennt den Kunden beim Namen", /Frank Muster/.test(vorschau.text), vorschau.text.slice(0, 60)); pruefe("sie nennt den Grund im Klartext", /Budget/.test(vorschau.text)); pruefe("die Vorschau veraendert NICHTS", db.prepare(`SELECT status FROM wd_anfragen WHERE id = 'b1'`).get().status === "neu"); const ab = await ruf(A.anfrageAblehnen, { params: { id: "b1" }, body: { grund: "budget", senden: false } }); pruefe("das Ablehnen gelingt", ab.ok === true, JSON.stringify(ab).slice(0, 120)); const b1 = db.prepare(`SELECT * FROM wd_anfragen WHERE id = 'b1'`).get(); pruefe("der Status steht auf abgelehnt", b1.status === "abgelehnt"); pruefe("der Grund ist auswertbar hinterlegt", b1.ablehnung_grund === "budget", b1.ablehnung_grund); pruefe("ohne Senden ist nichts verschickt", b1.absage_gesendet_am === null); L("\n11. DER TEXT KOMMT IN DER SPRACHE DES KUNDEN"); for (const [sp, wort] of [["fr", "Bonjour"], ["pt", "Olá"], ["en", "Hello"], ["de-CH", "Grüsse"]]) { const t = A.absageText(sp, "kein_zeitfenster", "Test"); pruefe("Absage auf " + sp, t.includes(wort), t.split("\n")[0]); } pruefe("eine unbekannte Sprache faellt auf Deutsch zurueck", A.absageText("kli", "budget", "Test").includes("Hallo")); pruefe("ein unbekannter Grund ergibt trotzdem einen vollstaendigen Text", A.absageText("de", "gibtesnicht", "Test").includes("Dogfather Webdesign")); L("\n12. EIN LAUFENDES PROJEKT KANN MAN NICHT NACHTRAEGLICH ABLEHNEN"); const abZuSpaet = await ruf(A.anfrageAblehnen, { params: { id: "a1" }, body: { grund: "budget" } }); pruefe("das wird verweigert", abZuSpaet.ok === false, JSON.stringify(abZuSpaet).slice(0, 140)); pruefe("mit Hinweis auf das Projekt", /P-/.test(abZuSpaet.error || ""), abZuSpaet.error); pruefe("und die Anfrage bleibt angenommen", db.prepare(`SELECT status FROM wd_anfragen WHERE id = 'a1'`).get().status === "angenommen"); L("\n13. DIE RECHTEPRUEFUNG GREIFT"); for (const [name, kopf] of [ ["ohne Ausweis kein Annehmen", {}], ["mit gefaelschter Unterschrift kein Annehmen", { authorization: "Bearer " + ausweis().split(".")[0] + ".XXXX" }], ["mit abgelaufenem Ausweis kein Annehmen", { authorization: "Bearer " + ausweis("dogfather", -1000) }], ]) { const f = antwortFaenger(); await A.anfrageAnnehmen({ headers: kopf, cookies: {}, params: { id: "b1" }, body: {} }, f.res); pruefe(name, f.holen() && f.holen().ok === false, JSON.stringify(f.holen()).slice(0, 80)); } L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`); process.exitCode = fehler ? 1 : 0; rmSync(tmp, { recursive: true, force: true });