Wunsch: "ich will dass du es abcheckst" — nicht Stück für Stück (das war
schon geprüft), sondern EINMAL DURCHGÄNGIG als ein zusammenhängender
Ablauf, so wie ein echter Auftrag tatsächlich läuft: Anfrage → Angebot
→ Zusage im Portal → Anzahlung → laufende Uhr → Benachrichtigung →
Arbeit → Restzahlung → Gegenprobe mit Abbruch.
test-kette.mjs bildet genau das ab, gegen die echten Funktionen aus
server-internal/, mit einer Wegwerf-Datenbank. 40 Prüfungen, alle grün.
DABEI GEFUNDEN: Der Liefertermin-Countdown im Portal prüfte nicht, ob
die Uhr überhaupt schon läuft. Ein frisch zugesagtes Projekt hat schon
ein termin_am (aus der Zusage vorgerechnet), aber uhr_start_am steht
noch auf null, solange die Anzahlung nicht da ist. Der Kunde hätte also
direkt nach der Zusage einen tickenden Countdown gesehen — und wenn die
Anzahlung eintrifft, wird der Termin ab dem Zahlungstag NEU berechnet
und springt dann sichtbar nach hinten. Das sieht aus wie ein Fehler,
auch wenn die Zahl rechnerisch stimmt: Kaum zu erklären, warum "noch 8
Tage" plötzlich wieder "noch 10 Tage" werden.
Jetzt zwei klar getrennte Zustände: Vor der Anzahlung steht "Startet,
sobald deine Anzahlung da ist — voraussichtlicher Liefertermin danach:
ca. {datum}" (kein Countdown, aber der Termin bleibt sichtbar, kein
Verstecken). Danach der echte Countdown. Fünf Sprachen ergänzt.
Nebenbei einen irreführenden Kommentar korrigiert: Ein neuer Kunde wird
beim Angebot-Senden SOFORT freigeschaltet (nicht erst bei Zusage, wie
der alte Kommentar behauptete) — sonst könnte er sich gar nicht
einloggen, um sein eigenes Angebot anzusehen. Der Code war richtig,
nur die Erklärung falsch, und mein erster Testentwurf ist genau darauf
hereingefallen.
Bei der Gelegenheit auch die bekannte better-sqlite3-Aussetzer-Eigenart
(zufälliger Absturz beim Prozessende, dokumentiert seit früheren
Commits) noch einmal eingegrenzt: Derselbe Import in test-angebote.mjs
brach mal nach "GEHEIM", mal nach "VORLAGEN" ab — der Absturzpunkt
verschiebt sich zwischen identischen Läufen. Das ist der endgültige
Beweis, dass es reine GC-Zeitfensterflakiness in der nativen
Bibliothek ist, kein Fehler im eigenen Code.
Geprüft: 40 (Kette) + 55 + 56 + 70 + 28 + 49 serverseitig (alle
mehrfach unabhängig grün), 261 im Browser (Portal, Angebot, Übersicht,
Verwaltung, Abbruch, Design). Ein Screenshot bestätigt den neuen
Wartehinweis visuell.
292 lines
16 KiB
JavaScript
292 lines
16 KiB
JavaScript
/* =====================================================================
|
|
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','[email protected]','+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','[email protected]','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 });
|