/* ===================================================================== test-angebote.mjs — Angebot schicken, Kunde sagt zu Der folgenreichste Klick der ganzen Seite: Mit der Zusage entsteht ein Vertrag. Entsprechend wird hier geprüft, dass - alles oder nichts passiert, - ein zweiter Klick kein zweites Projekt anlegt, - der WORTLAUT eingefroren wird (bei Streit zählt, was gezeigt wurde), - und die Uhr trotz Zusage NOCH NICHT läuft. Der letzte Punkt ist der, den man beim Bauen am ehesten falsch macht: Es fühlt sich richtig an, mit der Zusage loszulegen. Die Regel ist aber, dass die Zeit erst mit der Anzahlung läuft — und sie muss auch dann gelten, wenn der Kunde selbst zugesagt hat. ===================================================================== */ 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-ang-")); 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"); 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 G = await import("./routes/webdesign-angebote.js"); const Auto = await import("./lib/webdesign-automatik.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 ruf(fn, extra, ...rest) { 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() {} }, ...rest); return out; } const nun = new Date().toISOString(); db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,sprache,erstellt_am) VALUES ('a1','A-1','Anna Muster','anna@example.com','website','neu','de',?)`).run(nun); L("\n1. VORSCHAU — alles vorbelegt, nichts zu tippen"); let v = await ruf(G.angebotVorschau, { params: { id: "a1" } }); pruefe("die Vorschau antwortet", v && v.ok === true, JSON.stringify(v).slice(0, 100)); pruefe("das Paket kommt aus der Anfrage", v.paket === "website", v.paket); pruefe("ein Preis ist vorgeschlagen", v.preisEuro > 0, v.preisEuro); pruefe("die Anzahlung sind 30 Prozent davon", Math.abs(v.anzahlungEuro - v.preisEuro * 0.3) < 0.01, v.anzahlungEuro); pruefe("ein Ablaufdatum ist gesetzt", /^\d{4}-\d{2}-\d{2}$/.test(v.gueltigBis || ""), v.gueltigBis); /* DER KERN: Der Leistungstext entsteht aus der Aufgabenvorlage. */ pruefe("der Leistungstext ist nicht leer", (v.leistungen || "").length > 100, (v.leistungen || "").length); pruefe("er ist nach Kategorien gegliedert", /\*\*/.test(v.leistungen)); pruefe("und trennt, was der KUNDE beisteuern muss", /Was ich von dir brauche/.test(v.leistungen), v.leistungen.slice(-120)); pruefe("die Vorschau legt NICHTS an", db.prepare(`SELECT COUNT(*) AS n FROM wd_angebote`).get().n === 0); L("\n2. EIN ANDERES PAKET WAEHLEN"); const v2 = await ruf(G.angebotVorschau, { params: { id: "a1" }, query: { paket: "onepager" } }); pruefe("das Paket laesst sich ueberschreiben", v2.paket === "onepager", v2.paket); pruefe("und der Preis passt sich an", v2.preisEuro !== v.preisEuro, v2.preisEuro); pruefe("auch der Leistungstext ist ein anderer", v2.leistungen !== v.leistungen); L("\n3. ABSENDEN"); const g = await ruf(G.angebotSenden, { params: { id: "a1" }, body: { paket: "website", preisEuro: 1490, leistungen: v.leistungen } }); pruefe("das Absenden gelingt", g && g.ok === true, JSON.stringify(g).slice(0, 140)); pruefe("es gibt eine Angebotsnummer", /^AG-/.test(g.nummer || ""), g.nummer); const ang = db.prepare(`SELECT * FROM wd_angebote WHERE id = ?`).get(g.id); pruefe("der Status steht auf gesendet", ang.status === "gesendet"); /* Eingefroren, nicht bei jeder Ansicht neu erzeugt -- sonst staende nach der naechsten Vorlagenaenderung etwas anderes da als damals. */ pruefe("der Wortlaut ist eingefroren", ang.leistungen_text === v.leistungen); pruefe("die Anzahlung ist berechnet, nicht getippt", ang.anzahlung_cent === Math.round(149000 * 0.3), ang.anzahlung_cent); const anf = db.prepare(`SELECT status, kunde_id FROM wd_anfragen WHERE id = 'a1'`).get(); /* Der Wunsch: "wenn ich ein angebot rausschicke soll der automatisch das erkennen". */ pruefe("die Anfrage steht jetzt automatisch auf 'angebot'", anf.status === "angebot", anf.status); pruefe("und ist mit dem Kunden verknuepft", !!anf.kunde_id); pruefe("das Angebot liegt auch im Postfach", db.prepare(`SELECT COUNT(*) AS n FROM wd_postfach WHERE kunde_id = ?`).get(anf.kunde_id).n === 1); L("\n4. KEIN ZWEITES ANGEBOT ZUR SELBEN ANFRAGE"); const doppelt = await ruf(G.angebotSenden, { params: { id: "a1" }, body: { paket: "website", preisEuro: 999 } }); pruefe("ein zweites wird abgewiesen", doppelt.ok === false, JSON.stringify(doppelt).slice(0, 130)); pruefe("mit Hinweis auf das offene", /AG-/.test(doppelt.error || ""), doppelt.error); L("\n5. WAS DER KUNDE SIEHT"); const kunde = { id: anf.kunde_id, name: "Anna Muster" }; let sicht = G.portalAngebote(kunde.id); pruefe("das Angebot erscheint im Portal", sicht.length === 1); pruefe("mit lesbarem Preis", /1.490/.test(sicht[0].preis_text), sicht[0].preis_text); pruefe("und lesbarer Anzahlung", /447/.test(sicht[0].anzahlung_text), sicht[0].anzahlung_text); pruefe("es ist annehmbar", sicht[0].annehmbar === true); L("\n6. ZUSAGE — hier entsteht der Vertrag"); const z = await ruf(G.portalAngebotZusagen, { params: { id: g.id } }, kunde, "abc123"); pruefe("die Zusage gelingt", z && z.ok === true, JSON.stringify(z).slice(0, 160)); pruefe("es gibt eine Projektnummer", /^P-/.test(z.projektNummer || ""), z.projektNummer); const ang2 = db.prepare(`SELECT * FROM wd_angebote WHERE id = ?`).get(g.id); pruefe("das Angebot steht auf angenommen", ang2.status === "angenommen"); pruefe("mit Zeitpunkt", !!ang2.entschieden_am); /* Die Beweislast liegt beim Unternehmer: Wer, wann, von wo. */ pruefe("und mit Herkunft der Zusage", ang2.zusage_ip_hash === "abc123", ang2.zusage_ip_hash); pruefe("das Projekt haengt am Angebot", ang2.projekt_id === z.projektId); const pr = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(z.projektId); pruefe("das Projekt uebernimmt den Preis", pr.preis_cent === 149000, pr.preis_cent); pruefe("die Aufgabenliste steht", db.prepare(`SELECT COUNT(*) AS n FROM wd_aufgaben WHERE projekt_id = ?`).get(z.projektId).n > 5); /* DER PUNKT, DEN MAN AM EHESTEN FALSCH MACHT. */ pruefe("die UHR laeuft trotz Zusage NOCH NICHT", pr.uhr_start_am === null, String(pr.uhr_start_am)); pruefe("der erste Zug liegt beim Kunden", pr.wartet_auf === "kunde", pr.wartet_auf); pruefe("und der naechste Schritt nennt die Anzahlung", /Anzahlung/.test(pr.naechster_schritt || ""), pr.naechster_schritt); const zg = db.prepare(`SELECT * FROM wd_zahlungen WHERE projekt_id = ?`).get(z.projektId); pruefe("die Anzahlung wurde als offene Rechnung angelegt", !!zg && zg.status === "offen"); pruefe("in der richtigen Hoehe", zg.betrag_cent === Math.round(149000 * 0.3), zg.betrag_cent); pruefe("die Anfrage steht jetzt auf angenommen", db.prepare(`SELECT status FROM wd_anfragen WHERE id = 'a1'`).get().status === "angenommen"); L("\n7. BENACHRICHTIGUNG AN MICH"); const m = Auto.meldungenLesen(true, 10).find((x) => x.art === "zusage"); pruefe("die Zusage wird gemeldet", !!m, JSON.stringify(Auto.meldungenLesen(true, 5).map((x) => x.art))); pruefe("sie ist dringend", m.dringend === 1); pruefe("sie nennt den Betrag", /1.490/.test(m.text), m.text); pruefe("und sagt, dass die Uhr erst mit der Anzahlung laeuft", /Uhr läuft, sobald/.test(m.text), m.text); L("\n8. EIN ZWEITER KLICK LEGT NICHTS DOPPELT AN"); const vorher = db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n; const z2 = await ruf(G.portalAngebotZusagen, { params: { id: g.id } }, kunde, "abc123"); pruefe("er meldet keinen Fehler", z2.ok === true && z2.bereitsAngenommen === true); pruefe("und es entstand kein zweites Projekt", db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n === vorher); L("\n9. EIN ANGENOMMENES ANGEBOT LAESST SICH NICHT ZURUECKZIEHEN"); const zz = await ruf(G.angebotZurueckziehen, { params: { id: g.id } }); pruefe("das wird verweigert", zz.ok === false, JSON.stringify(zz).slice(0, 130)); pruefe("mit Hinweis auf den Projektabbruch", /Projekt ab/.test(zz.error || ""), zz.error); L("\n10. ABGELAUFENE ANGEBOTE"); db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,sprache,erstellt_am) VALUES ('a2','A-2','Bert','bert@example.com','onepager','neu','de',?)`).run(nun); const g2 = await ruf(G.angebotSenden, { params: { id: "a2" }, body: { paket: "onepager", preisEuro: 490 } }); db.prepare(`UPDATE wd_angebote SET gueltig_bis = '2020-01-01' WHERE id = ?`).run(g2.id); const k2 = db.prepare(`SELECT id, name FROM wd_kunden WHERE lower(email) = 'bert@example.com'`).get(); const sicht2 = G.portalAngebote(k2.id); pruefe("ein abgelaufenes Angebot ist NICHT mehr annehmbar", sicht2[0].annehmbar === false); pruefe("und wird als abgelaufen gekennzeichnet", sicht2[0].abgelaufen === true); /* Ein Angebot, das man noch sieht, aber nicht mehr annehmen kann, waere eine Falle -- deshalb muss auch der Versuch scheitern. */ const zAlt = await ruf(G.portalAngebotZusagen, { params: { id: g2.id } }, k2, "x"); pruefe("die Zusage auf ein abgelaufenes Angebot scheitert", zAlt.ok === false, zAlt.error); pruefe("mit einem freundlichen Hinweis statt einer Fehlermeldung", /neues/.test(zAlt.error || ""), zAlt.error); L("\n11. FREMDE ANGEBOTE SIND UNSICHTBAR"); const fremd = await ruf(G.portalAngebotZusagen, { params: { id: g.id } }, k2, "x"); pruefe("ein anderer Kunde kann nicht zusagen", fremd.ok === false, fremd.error); pruefe("und erfaehrt nicht einmal, dass es existiert", /nicht gefunden/.test(fremd.error || ""), fremd.error); L("\n12. DIE RECHTEPRUEFUNG GREIFT"); for (const [name, kopf] of [ ["ohne Ausweis keine Vorschau", {}], ["mit abgelaufenem Ausweis keine Vorschau", { authorization: "Bearer " + ausweis(-1000) }], ]) { let out = null; await G.angebotVorschau({ headers: kopf, cookies: {}, params: { id: "a1" }, query: {} }, { status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {} }); pruefe(name, out && out.ok === false, JSON.stringify(out).slice(0, 70)); } L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`); process.exitCode = fehler ? 1 : 0; rmSync(tmp, { recursive: true, force: true });