/* ===================================================================== test-kette.mjs — die GANZE Kette in einer Sitzung Wunsch: "ich will dass du es abcheckst" — nicht Stück für Stück (das ist schon geprüft: test-annahme, test-angebote, test-automatik, test-suche, test-termine, zusammen 258 Prüfungen), sondern EINMAL DURCHGÄNGIG, so wie ein echter Auftrag tatsächlich abläuft: Anfrage kommt rein → ich schicke ein Angebot → der Kunde sieht es in seinem Portal → der Kunde sagt zu (Projekt + Anzahlungsrechnung entstehen) → die Anzahlung geht ein → die Uhr startet, der Termin wird neu gerechnet → ich bekomme eine Benachrichtigung → ich arbeite ein paar Punkte ab → der Kunde zahlt den Rest → ich breche NICHT ab (Gegenprobe: ein zweites Projekt, das ich abbreche, MUSS aus der Liste verschwinden) Wichtig: Läuft gegen die ECHTEN Funktionen aus server-internal/, nicht gegen Nachbauten. Die Datenbank ist eine frische Wegwerf-Datei, damit nichts von Filipes echten Daten berührt wird. ===================================================================== */ 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"); process.on("uncaughtException", (e) => { writeSync(2, "\nABGEBROCHEN: " + ((e && e.stack) || e) + "\n"); process.exit(1); }); process.on("unhandledRejection", (e) => { writeSync(2, "\nABGELEHNT: " + ((e && e.stack) || e) + "\n"); process.exit(1); }); const tmp = mkdtempSync(join(tmpdir(), "wd-kette-")); 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"); const G = await import("./routes/webdesign-angebote.js"); const Z = await import("./routes/webdesign-zahlungen.js"); const Auto = await import("./lib/webdesign-automatik.js"); const P = await import("./routes/webdesign-portal.js"); const K = await import("./routes/webdesign-kunden.js"); const { tokenHash } = await import("./lib/webdesign-helfer.js"); const { werktageBis } = await import("./lib/webdesign-termine.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(g = 60000) { const n = Buffer.from(JSON.stringify({ bereich: "wd-admin", rolle: "dogfather", exp: Date.now() + g })) .toString("base64url"); return n + "." + createHmac("sha256", process.env.WEBDESIGN_API_SECRET).update(n).digest("base64url"); } async function admin(fn, extra) { let out = null; await fn({ headers: { authorization: "Bearer " + ausweis() }, cookies: {}, params: {}, body: {}, query: {}, ...extra }, { status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {} }); return out; } /* Eine ECHTE Kundensitzung anlegen, damit portalUebersicht ueber den normalen Code-Pfad laeuft -- nicht nachgebaut. */ function kundenToken(kundeId) { const roh = randomBytes(24).toString("hex"); db.prepare( `INSERT INTO wd_kunden_sitzungen (token_hash, kunde_id, erstellt_am, laeuft_ab) VALUES (?,?,?,?)` ).run(tokenHash(roh), kundeId, new Date().toISOString(), new Date(Date.now() + 3600000).toISOString()); return roh; } async function portalAls(kundeId) { let out = null; await P.portalUebersicht( { headers: { authorization: "Bearer " + kundenToken(kundeId) }, cookies: {} }, { status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {} } ); return out; } async function kunde(fn, kundeObj, extra, ...rest) { let out = null; await fn({ headers: {}, cookies: {}, params: {}, body: {}, query: {}, ...extra }, { status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {} }, kundeObj, ...rest); return out; } L("========================================================================"); L("SCHRITT 1 — eine Anfrage kommt rein (wie über das echte Formular)"); L("========================================================================"); const jetztIso = new Date().toISOString(); db.prepare( `INSERT INTO wd_anfragen (id,nummer,name,email,telefon,paket,ziel,details,status,sprache,erstellt_am) VALUES ('a1','A-2608-0099','Vanessa Geske','vanessa@example.com','+352 691 000000', 'onepager','Mehr Sichtbarkeit','Bitte schlicht und modern.','neu','de',?)` ).run(jetztIso); pruefe("die Anfrage steht in der Datenbank", db.prepare(`SELECT status FROM wd_anfragen WHERE id='a1'`).get().status === "neu"); L("\n========================================================================"); L("SCHRITT 2 — ich sehe mir die Angebotsvorschau an (nichts eingetippt)"); L("========================================================================"); const vorschau = await admin(G.angebotVorschau, { params: { id: "a1" } }); pruefe("die Vorschau liefert einen Preisvorschlag", vorschau.preisEuro > 0, vorschau.preisEuro); pruefe("und einen fertigen Leistungstext aus der Standardliste", (vorschau.leistungen || "").length > 50, vorschau.leistungen.length + " Zeichen"); pruefe("noch ist NICHTS angelegt", db.prepare(`SELECT COUNT(*) AS n FROM wd_angebote`).get().n === 0); L("\n========================================================================"); L("SCHRITT 3 — ich schicke das Angebot raus"); L("========================================================================"); const gesendet = await admin(G.angebotSenden, { params: { id: "a1" }, body: { paket: "onepager", preisEuro: 490 } }); pruefe("das Angebot ist raus", gesendet.ok === true, JSON.stringify(gesendet).slice(0, 100)); pruefe("mit einer Nummer", /^AG-/.test(gesendet.nummer), gesendet.nummer); pruefe("die Anfrage erkennt das automatisch (Status 'angebot')", db.prepare(`SELECT status FROM wd_anfragen WHERE id='a1'`).get().status === "angebot"); const kundeId = db.prepare(`SELECT kunde_id FROM wd_anfragen WHERE id='a1'`).get().kunde_id; pruefe("ein Kundenzugang ist entstanden", !!kundeId); /* Erst fuer falsch gehalten, dann verstanden: freigeschaltet steuert das Portal-Login selbst. Waere es 0, koennte Vanessa sich gar nicht einloggen, um ihr eigenes Angebot anzusehen -- es MUSS also sofort auf 1 stehen, sonst ist das Angebot fuer sie unerreichbar. */ pruefe("er ist SOFORT freigeschaltet -- sonst koennte er sein eigenes Angebot nie ansehen", db.prepare(`SELECT freigeschaltet FROM wd_kunden WHERE id=?`).get(kundeId).freigeschaltet === 1); pruefe("das Angebot liegt im Postfach des Kunden", db.prepare(`SELECT COUNT(*) AS n FROM wd_postfach WHERE kunde_id=?`).get(kundeId).n === 1); L("\n========================================================================"); L("SCHRITT 4 — Vanessa öffnet ihr Portal, das Angebot ist da"); L("========================================================================"); const kundeObj = { id: kundeId, name: "Vanessa Geske" }; const angebote = G.portalAngebote(kundeId); pruefe("genau ein Angebot ist sichtbar", angebote.length === 1, angebote.length); pruefe("mit lesbarem Preis", angebote[0].preis_text === "490,00 €", angebote[0].preis_text); pruefe("annehmbar (nicht abgelaufen)", angebote[0].annehmbar === true); L("\n========================================================================"); L("SCHRITT 5 — Vanessa sagt zu (der folgenreichste Klick der Seite)"); L("========================================================================"); const zusage = await kunde(G.portalAngebotZusagen, kundeObj, { params: { id: gesendet.id } }, "gehashter-ip-abdruck"); pruefe("die Zusage gelingt", zusage.ok === true, JSON.stringify(zusage).slice(0, 120)); const projektId = zusage.projektId; const projektNummer = zusage.projektNummer; pruefe("es gibt eine Projektnummer", /^P-/.test(projektNummer), projektNummer); const projekt1 = db.prepare(`SELECT * FROM wd_projekte WHERE id=?`).get(projektId); pruefe("das Projekt übernimmt den Preis aus dem Angebot", projekt1.preis_cent === 49000, projekt1.preis_cent); pruefe("die Aufgabenliste steht bereits", db.prepare(`SELECT COUNT(*) AS n FROM wd_aufgaben WHERE projekt_id=?`).get(projektId).n > 0); pruefe("eine offene Anzahlungsrechnung ist entstanden", !!db.prepare(`SELECT id FROM wd_zahlungen WHERE projekt_id=? AND art='anzahlung' AND status='offen'`).get(projektId)); pruefe("DIE UHR LÄUFT NOCH NICHT — genau die Regel, um die es ging", projekt1.uhr_start_am === null, String(projekt1.uhr_start_am)); pruefe("Vanessa ist jetzt freigeschaltet", db.prepare(`SELECT freigeschaltet FROM wd_kunden WHERE id=?`).get(kundeId).freigeschaltet === 1); /* GEFUNDEN beim ersten Durchlauf dieser Kette: Vor der Zahlung zeigte das Portal einen tickenden Countdown, obwohl noch nichts begonnen hatte -- und der Termin haette sich beim Zahlungseingang sichtbar verschoben. Reparatur in webdesign-portal.js: wartet_auf_anzahlung ersetzt den Countdown, solange die Uhr steht. */ const portalVorZahlung = await portalAls(kundeId); const projektImPortalVorZahlung = portalVorZahlung.projekte.find((p) => p.id === projektId); pruefe("VOR der Zahlung: kein Countdown, sondern der Wartehinweis", projektImPortalVorZahlung.wartet_auf_anzahlung === true && projektImPortalVorZahlung.verbleibende_werktage === null, JSON.stringify({ wartet: projektImPortalVorZahlung.wartet_auf_anzahlung, rest: projektImPortalVorZahlung.verbleibende_werktage })); pruefe("aber der voraussichtliche Termin ist trotzdem sichtbar (kein Verstecken)", !!projektImPortalVorZahlung.termin_am, projektImPortalVorZahlung.termin_am); L("\n========================================================================"); L("SCHRITT 6 — die Anzahlung geht ein (wie von PayPal gemeldet)"); L("========================================================================"); const zahlung = db.prepare(`SELECT id FROM wd_zahlungen WHERE projekt_id=? AND art='anzahlung'`).get(projektId); Auto.zahlungVerbucht(zahlung.id, "system"); // dieselbe Funktion, die der Webhook aufruft const projekt2 = db.prepare(`SELECT * FROM wd_projekte WHERE id=?`).get(projektId); pruefe("JETZT läuft die Uhr", !!projekt2.uhr_start_am, projekt2.uhr_start_am); pruefe("der Termin ist ab HEUTE gerechnet (10 Werktage für Onepager)", werktageBis(projekt2.termin_am, new Date()) === 10, "noch " + werktageBis(projekt2.termin_am, new Date()) + " Werktage"); pruefe("der Status ist von briefing auf design gewechselt", projekt2.status === "design", projekt2.status); pruefe("jetzt bin ich am Zug, nicht Vanessa", projekt2.wartet_auf === "dogfather", projekt2.wartet_auf); L("\n========================================================================"); L("SCHRITT 7 — ich bekomme eine Benachrichtigung im Cockpit"); L("========================================================================"); const meldungen = Auto.meldungenLesen(true, 20); const anzMeldung = meldungen.find((m) => m.art === "anzahlung_da" && m.ziel_id === projektId); pruefe("die Meldung ist da", !!anzMeldung, JSON.stringify(meldungen.map((m) => m.art))); pruefe("sie ist als dringend markiert", anzMeldung.dringend === 1); pruefe("sie nennt Vanessa und den Betrag", /Vanessa/.test(anzMeldung.text) && /147,00/.test(anzMeldung.text), anzMeldung.text); L("\n========================================================================"); L("SCHRITT 8 — die Übersicht im Cockpit zeigt alles konsistent"); L("========================================================================"); const uebersichtsAntwort = await admin(Z.uebersicht, {}); pruefe("die Übersicht antwortet", uebersichtsAntwort.ok === true); pruefe("das Projekt steht bei 'wartet auf Kunde' NICHT mehr drin (ich bin dran)", !uebersichtsAntwort.projekte.some((p) => p.id === projektId && p.wartet_auf === "kunde") , JSON.stringify(uebersichtsAntwort.projekte.map((p) => ({ id: p.id, wartet_auf: p.wartet_auf })))); pruefe("die ungelesene Meldung erscheint im Cockpit", uebersichtsAntwort.meldungen.some((m) => m.id === anzMeldung.id)); L("\n========================================================================"); L("SCHRITT 9 — ich arbeite Punkte ab, der Fortschritt zieht mit"); L("========================================================================"); const aufgaben = db.prepare(`SELECT id FROM wd_aufgaben WHERE projekt_id=? ORDER BY reihenfolge`).all(projektId); const halbe = Math.floor(aufgaben.length / 2); for (let i = 0; i < halbe; i++) { db.prepare(`UPDATE wd_aufgaben SET status='erledigt' WHERE id=?`).run(aufgaben[i].id); } // Fortschritt direkt aus der Datenbank nachrechnen (dieselbe Formel wie im Cockpit) const fertig = db.prepare(`SELECT COUNT(*) AS n FROM wd_aufgaben WHERE projekt_id=? AND status='erledigt'`).get(projektId).n; pruefe("die Hälfte ist erledigt", fertig === halbe, fertig + "/" + aufgaben.length); L("\n========================================================================"); L("SCHRITT 10 — Vanessa zahlt den Rest"); L("========================================================================"); db.prepare( `INSERT INTO wd_zahlungen (id,nummer,kunde_id,projekt_id,art,zweck_text,betrag_cent,waehrung,status,erstellt_am) VALUES ('zrest','Z-2608-0099',?,?,'rest','Restzahlung',34300,'EUR','offen',?)` ).run(kundeId, projektId, jetztIso); Auto.zahlungVerbucht("zrest", "system"); pruefe("die Restzahlung ist am Projekt vermerkt", db.prepare(`SELECT rest_bezahlt FROM wd_projekte WHERE id=?`).get(projektId).rest_bezahlt === 1); /* Der Betrag steht im TITEL der Meldung, der Name im TEXT -- beide Felder zusammen pruefen, nicht nur eines. */ pruefe("es kam eine weitere Meldung mit Betrag und Namen", Auto.meldungenLesen(true, 20).some((m) => /343,00/.test(m.titel) && /Vanessa/.test(m.text)), JSON.stringify(Auto.meldungenLesen(true, 20).map((m) => m.titel))); L("\n========================================================================"); L("SCHRITT 11 — GEGENPROBE: ein zweiter, abgebrochener Auftrag"); L("========================================================================"); db.prepare( `INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,sprache,erstellt_am) VALUES ('a2','A-2608-0100','Testkunde Zwei','zwei@example.com','website','neu','de',?)` ).run(jetztIso); const angebot2 = await admin(G.angebotSenden, { params: { id: "a2" }, body: { paket: "website", preisEuro: 1490 } }); const kundeId2 = db.prepare(`SELECT kunde_id FROM wd_anfragen WHERE id='a2'`).get().kunde_id; const zusage2 = await kunde(G.portalAngebotZusagen, { id: kundeId2, name: "Testkunde Zwei" }, { params: { id: angebot2.id } }, "x"); const projektId2 = zusage2.projektId; const listeVorAbbruch = await admin(K.projekteListe, {}); pruefe("das zweite Projekt steht in der laufenden Liste", listeVorAbbruch.projekte.some((p) => p.id === projektId2)); const abbruch = await admin(A.projektAbbrechen, { params: { id: projektId2 }, body: { grund: "keine_zahlung", wer: "kunde" } }); pruefe("der Abbruch gelingt", abbruch.ok === true, JSON.stringify(abbruch).slice(0, 100)); const listeNachAbbruch = await admin(K.projekteListe, {}); pruefe("das abgebrochene Projekt ist aus der laufenden Liste WEG", !listeNachAbbruch.projekte.some((p) => p.id === projektId2), JSON.stringify(listeNachAbbruch.projekte.map((p) => p.id))); pruefe("das ERSTE Projekt (Vanessa, läuft normal) steht weiter da", listeNachAbbruch.projekte.some((p) => p.id === projektId)); pruefe("archiviert, nicht gelöscht — der Beleg bleibt", !!db.prepare(`SELECT id FROM wd_projekte WHERE id=? AND archiviert=1`).get(projektId2)); L("\n========================================================================"); L(`ENDE DER KETTE: ${ok} bestanden, ${fehler} fehlgeschlagen`); L("========================================================================"); process.exitCode = fehler ? 1 : 0; rmSync(tmp, { recursive: true, force: true });