DER LEITGEDANKE: SO WENIG TIPPEN WIE MOEGLICH Niemand schreibt hier den Leistungsumfang. Er entsteht aus der Aufgabenvorlage des Pakets -- denselben 92 Punkten, nach denen spaeter gearbeitet wird. Der Nebeneffekt ist wichtiger als die gesparte Tipparbeit: Was im Angebot steht, IST die Arbeitsliste. Ein von Hand geschriebenes Angebot und eine getrennt gepflegte Aufgabenliste laufen unweigerlich auseinander -- und dann steht im Angebot etwas, das niemand abarbeitet. Zu tun bleibt: Paket waehlen, Preis bestaetigen. Beides ist vorbelegt, Laufzeit und Ablaufdatum werden gerechnet. DER WORTLAUT WIRD BEIM ABSENDEN EINGEFROREN Ein angenommenes Angebot ist ein Vertrag. Bei einem Streit zaehlt, WAS dem Kunden gezeigt wurde, als er zusagte. Wuerde der Text bei jeder Ansicht neu aus den Vorlagen erzeugt, staende nach der naechsten Vorlagenaenderung etwas anderes da als damals. Die Zusage wird mit Zeitpunkt und (gehashter) Herkunft belegt -- die Beweislast liegt beim Unternehmer. WAS AUTOMATISCH PASSIERT Angebot raus -> Anfrage steht auf 'angebot' (der Wunsch: 'wenn ich ein angebot rausschicke soll der automatisch das erkennen'). Zusage -> Projekt angelegt, Aufgabenliste eingesetzt, Anzahlung als offene Rechnung erzeugt, Anfrage auf 'angenommen', Benachrichtigung an mich. Die UHR startet dabei NICHT. Sie startet erst mit dem Zahlungseingang -- das ist die Regel aus dem vorigen Schritt, und sie gilt auch dann, wenn der Kunde selbst zugesagt hat. Genau dieser Punkt wird ausdruecklich geprueft: Es fuehlt sich richtig an, mit der Zusage loszulegen. ZWEI FEHLER IN DER GELDANZEIGE GEFUNDEN Beide fielen erst auf, als das erste Angebot ueber tausend Euro entstand -- alle frueheren Testbetraege lagen darunter: - Kein Tausenderpunkt: 1490 Euro erschienen als '1490,00 €'. - Das Minuszeichen ging verloren: Math.trunc(-0.5) ergibt -0, und -0 schreibt sich als '0'. Eine ERSTATTUNG von 50 Cent erschien damit als '0,50 €' -- also wie eine Forderung. Ausgerechnet beim Abbruch mit Rueckzahlung waere das der falsche Ort fuer einen Anzeigefehler. Bewusst von Hand statt ueber Intl.NumberFormat: Die Ausgabe muss auf dem Server und im Browser zeichengleich sein -- ein Betrag, der in der Verwaltung anders aussieht als im Portal, saet Zweifel an der Zahl. Geprueft: 56 zu den Angeboten, 53 zur Automatik (inkl. der neun Geldpruefungen), 70 zur Annahme, 28 zur Suche -- alle gegen eine echte Datenbank. Darunter: zweiter Klick legt nichts doppelt an, abgelaufene Angebote lassen sich nicht mehr annehmen (ein Angebot, das man sieht aber nicht annehmen kann, waere eine Falle), ein fremder Kunde erfaehrt nicht einmal, dass ein Angebot existiert. Co-Authored-By: Claude Opus 5 <[email protected]>
245 lines
14 KiB
JavaScript
245 lines
14 KiB
JavaScript
/* =====================================================================
|
|
test-automatik.mjs — die Uhr, die Meldungen und der Abbruch
|
|
|
|
Der Kern: Die zugesagte Zeit darf NICHT bei der Annahme zu laufen
|
|
beginnen, sondern erst, wenn die Anzahlung da ist. Sonst verbraucht
|
|
ein Kunde, der zehn Tage bis zur Zahlung braucht, zehn Tage der
|
|
zugesagten Zeit — und am Ende steht der Anbieter als der da, der
|
|
seinen Termin reisst.
|
|
|
|
Das ist nicht mit den Augen prüfbar: Ein Termin, der zehn Tage zu
|
|
früh liegt, sieht aus wie ein richtiger.
|
|
===================================================================== */
|
|
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");
|
|
|
|
/* better-sqlite3 bricht beim Beenden hart ab (bekannter Fehler in seiner
|
|
Aufraeumphase) und verschluckt dabei jede Ausgabe -- auch eine
|
|
Fehlermeldung. Ein Absturz im Test saehe dann aus wie "der Test lief
|
|
gar nicht": Migrationen, dann Stille. Genau das ist hier passiert.
|
|
Deshalb werden Fehler ausdruecklich abgefangen und SOFORT geschrieben. */
|
|
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-auto-"));
|
|
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 Auto = await import("./lib/webdesign-automatik.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 ruf(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;
|
|
}
|
|
|
|
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','[email protected]','website','neu','de',?)`).run(nun);
|
|
|
|
L("\n1. ANNEHMEN — die Uhr steht noch");
|
|
const an = await ruf(A.anfrageAnnehmen, { params: { id: "a1" }, body: { preisEuro: 2000 } });
|
|
pruefe("die Annahme gelingt", an && an.ok === true, JSON.stringify(an).slice(0, 120));
|
|
let p = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(an.projektId);
|
|
pruefe("es gibt einen geplanten Liefertermin", !!p.termin_am, p.termin_am);
|
|
pruefe("aber die Uhr laeuft NOCH NICHT", p.uhr_start_am === null, String(p.uhr_start_am));
|
|
pruefe("und der Status steht auf briefing", p.status === "briefing", p.status);
|
|
let terminBeiAnnahme = p.termin_am; // wird gleich auf den zurueckdatierten Wert gesetzt
|
|
|
|
L("\n2. EINE OFFENE RECHNUNG AENDERT NICHTS");
|
|
db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,projekt_id,art,zweck_text,betrag_cent,status,erstellt_am)
|
|
VALUES ('z1','Z-1',?,?,'anzahlung','Anzahlung',60000,'offen',?)`).run(an.kundeId, an.projektId, nun);
|
|
p = db.prepare(`SELECT uhr_start_am FROM wd_projekte WHERE id = ?`).get(an.projektId);
|
|
pruefe("eine offene Rechnung startet die Uhr nicht", p.uhr_start_am === null);
|
|
|
|
L("\n3. ZAHLUNGSEINGANG — jetzt laeuft sie, Termin ab HEUTE neu gerechnet");
|
|
/* Die Annahme wird kuenstlich zehn Tage zurueckdatiert. Waere der Termin
|
|
an die Annahme gebunden, blieben jetzt zehn Tage weniger uebrig --
|
|
genau der Fehler, um den es hier geht. */
|
|
/* Beides zurueckdatieren: die Annahme UND den damals errechneten Termin.
|
|
|
|
Die erste Fassung setzte nur angenommen_am zurueck. Der Termin war
|
|
aber schon "heute + 20 Werktage" -- und die Neuberechnung beim
|
|
Zahlungseingang kam auf denselben Tag heraus. Die Pruefung
|
|
"der Termin liegt jetzt spaeter" schlug fehl, obwohl der Code
|
|
richtig rechnete: Der Testaufbau bildete den Fall gar nicht ab, um
|
|
den es geht. */
|
|
const vor10 = new Date(Date.now() - 10 * 864e5).toISOString();
|
|
const { werktageAddieren } = await import("./lib/webdesign-termine.js");
|
|
const terminVonDamals = werktageAddieren(vor10, 20);
|
|
db.prepare(`UPDATE wd_projekte SET angenommen_am = ?, termin_am = ? WHERE id = ?`)
|
|
.run(vor10, terminVonDamals, an.projektId);
|
|
terminBeiAnnahme = terminVonDamals;
|
|
db.prepare(`UPDATE wd_zahlungen SET status = 'bezahlt', bezahlt_am = ? WHERE id = 'z1'`).run(nun);
|
|
|
|
const erg = Auto.zahlungVerbucht("z1", "dogfather");
|
|
pruefe("die Automatik meldet den Uhrstart",
|
|
erg.getan.includes("uhr_gestartet"), JSON.stringify(erg));
|
|
|
|
p = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(an.projektId);
|
|
pruefe("die Uhr laeuft jetzt", !!p.uhr_start_am, String(p.uhr_start_am));
|
|
pruefe("die Anzahlung ist am Projekt vermerkt", p.anzahlung_bezahlt === 1);
|
|
pruefe("der Status wechselte von briefing auf design", p.status === "design", p.status);
|
|
pruefe("jetzt bin ICH am Zug, nicht der Kunde", p.wartet_auf === "dogfather", p.wartet_auf);
|
|
pruefe("der urspruenglich geplante Termin bleibt erhalten",
|
|
p.termin_geplant_am === terminBeiAnnahme, p.termin_geplant_am + " vs. " + terminBeiAnnahme);
|
|
/* DER KERN dieser ganzen Aenderung. */
|
|
pruefe("der Liefertermin ist ab HEUTE neu gerechnet: volle 20 Werktage",
|
|
werktageBis(p.termin_am, new Date()) === 20,
|
|
"noch " + werktageBis(p.termin_am, new Date()) + " Werktage (erwartet 20)");
|
|
pruefe("er liegt damit spaeter als der bei der Annahme genannte",
|
|
p.termin_am > terminBeiAnnahme, p.termin_am + " > " + terminBeiAnnahme);
|
|
|
|
L("\n4. BENACHRICHTIGUNG");
|
|
const m = Auto.meldungenLesen(true, 10);
|
|
pruefe("es gibt eine ungelesene Meldung", m.length >= 1, m.length);
|
|
const anz = m.find((x) => x.art === "anzahlung_da");
|
|
pruefe("sie meldet die Anzahlung", !!anz, JSON.stringify(m.map((x) => x.art)));
|
|
pruefe("sie nennt die Projektnummer", /P-/.test(anz.titel), anz.titel);
|
|
pruefe("sie fuehrt zum Projekt", anz.ziel === "projekte" && anz.ziel_id === an.projektId);
|
|
pruefe("sie ist als dringend gekennzeichnet", anz.dringend === 1);
|
|
/* Beide Termine im Text: Nur so sieht man, dass sich etwas verschoben
|
|
hat -- und muss nicht selbst nachrechnen, warum. */
|
|
pruefe("sie nennt beide Termine, damit der Unterschied sichtbar ist",
|
|
anz.text.includes(p.termin_am) && anz.text.includes(terminBeiAnnahme), anz.text);
|
|
|
|
L("\n5. EINE ZWEITE VERBUCHUNG STARTET NICHTS ERNEUT");
|
|
const vorher = p.uhr_start_am;
|
|
Auto.zahlungVerbucht("z1", "dogfather");
|
|
const p2 = db.prepare(`SELECT uhr_start_am, termin_am FROM wd_projekte WHERE id = ?`).get(an.projektId);
|
|
pruefe("die Uhr bleibt beim ersten Start", p2.uhr_start_am === vorher);
|
|
pruefe("und der Termin verschiebt sich nicht noch einmal", p2.termin_am === p.termin_am);
|
|
|
|
L("\n6. ERSTATTUNGSVORSCHLAG AUS DEM FORTSCHRITT");
|
|
const alle = db.prepare(`SELECT id FROM wd_aufgaben WHERE projekt_id = ? ORDER BY reihenfolge`).all(an.projektId);
|
|
const wieViele = Math.floor(alle.length / 4);
|
|
for (let i = 0; i < wieViele; i++) {
|
|
db.prepare(`UPDATE wd_aufgaben SET status = 'erledigt' WHERE id = ?`).run(alle[i].id);
|
|
}
|
|
const r = Auto.erstattungsVorschlag(an.projektId);
|
|
pruefe("der Fortschritt wird gezaehlt", r.fertig === wieViele, r.fertig + "/" + r.gesamt);
|
|
pruefe("bezahlt sind 600 EUR", r.bezahltCent === 60000, r.bezahltCent);
|
|
pruefe("behalten plus erstatten ergibt genau das Bezahlte",
|
|
r.behaltenCent + r.erstattungCent === r.bezahltCent,
|
|
r.behaltenCent + " + " + r.erstattungCent + " = " + r.bezahltCent);
|
|
pruefe("man kann nie mehr behalten als bezahlt wurde", r.behaltenCent <= r.bezahltCent);
|
|
|
|
L("\n7. DIE ABBRUCH-VORSCHAU VERAENDERT NICHTS");
|
|
const vs = await ruf(A.abbruchVorschau, { params: { id: an.projektId } });
|
|
pruefe("die Vorschau antwortet", vs.ok === true);
|
|
pruefe("sie nennt denselben Fortschritt", vs.rechnung.prozent === r.prozent, vs.rechnung.prozent);
|
|
pruefe("das Projekt ist danach noch NICHT abgebrochen",
|
|
db.prepare(`SELECT status FROM wd_projekte WHERE id = ?`).get(an.projektId).status === "design");
|
|
|
|
L("\n8. ABBRECHEN");
|
|
db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,projekt_id,art,zweck_text,betrag_cent,status,erstellt_am)
|
|
VALUES ('z2','Z-2',?,?,'rest','Restzahlung',140000,'offen',?)`).run(an.kundeId, an.projektId, nun);
|
|
const ab = await ruf(A.projektAbbrechen, {
|
|
params: { id: an.projektId },
|
|
body: { grund: "keine_zahlung", wer: "kunde", erstattungEuro: 100 },
|
|
});
|
|
pruefe("das Abbrechen gelingt", ab.ok === true, JSON.stringify(ab).slice(0, 140));
|
|
const pa = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(an.projektId);
|
|
pruefe("der Status steht auf abgebrochen", pa.status === "abgebrochen");
|
|
pruefe("der Grund ist auswertbar hinterlegt", pa.abbruch_grund === "keine_zahlung");
|
|
pruefe("wer abgebrochen hat, steht dabei", pa.abbruch_wer === "kunde");
|
|
/* Der eingegebene Betrag muss gelten -- sonst waere das Eingabefeld im
|
|
Dialog eine Attrappe. */
|
|
pruefe("der eingegebene Betrag gilt, nicht der vorgeschlagene",
|
|
pa.abbruch_erstattung_cent === 10000, pa.abbruch_erstattung_cent);
|
|
pruefe("der Fortschritt beim Abbruch ist festgehalten",
|
|
pa.abbruch_fortschritt === r.prozent, pa.abbruch_fortschritt);
|
|
pruefe("offene Rechnungen wurden storniert",
|
|
db.prepare(`SELECT status FROM wd_zahlungen WHERE id = 'z2'`).get().status === "storniert",
|
|
db.prepare(`SELECT status FROM wd_zahlungen WHERE id = 'z2'`).get().status);
|
|
pruefe("die bereits BEZAHLTE Zahlung bleibt unangetastet",
|
|
db.prepare(`SELECT status FROM wd_zahlungen WHERE id = 'z1'`).get().status === "bezahlt");
|
|
pruefe("der Hinweis sagt, dass die Rueckzahlung von Hand erfolgt",
|
|
/nicht selbst/.test(ab.hinweis || ""), ab.hinweis);
|
|
|
|
L("\n9. NACH DEM ABBRUCH");
|
|
const ab2 = await ruf(A.projektAbbrechen, { params: { id: an.projektId }, body: { grund: "sonstiges" } });
|
|
pruefe("ein zweiter Abbruch meldet keinen Fehler",
|
|
ab2.ok === true && ab2.bereitsAbgebrochen === true, JSON.stringify(ab2).slice(0, 90));
|
|
db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,projekt_id,art,zweck_text,betrag_cent,status,erstellt_am)
|
|
VALUES ('z3','Z-3',?,?,'anzahlung','Nachzuegler',1000,'bezahlt',?)`).run(an.kundeId, an.projektId, nun);
|
|
Auto.zahlungVerbucht("z3", "system");
|
|
pruefe("eine spaet verbuchte Zahlung belebt es NICHT wieder",
|
|
db.prepare(`SELECT status FROM wd_projekte WHERE id = ?`).get(an.projektId).status === "abgebrochen");
|
|
|
|
L("\n10. WER SEIT EINER WOCHE AUF SEINE ANZAHLUNG WARTET");
|
|
db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,sprache,erstellt_am)
|
|
VALUES ('a2','A-2','Bert Beispiel','[email protected]','onepager','neu','de',?)`).run(nun);
|
|
const an2 = await ruf(A.anfrageAnnehmen, { params: { id: "a2" } });
|
|
db.prepare(`UPDATE wd_projekte SET angenommen_am = ? WHERE id = ?`)
|
|
.run(new Date(Date.now() - 9 * 864e5).toISOString(), an2.projektId);
|
|
const wartet = Auto.wartetAufAnzahlung(7);
|
|
pruefe("das seit neun Tagen wartende Projekt erscheint",
|
|
wartet.some((x) => x.id === an2.projektId), JSON.stringify(wartet.map((x) => x.nummer)));
|
|
pruefe("das abgebrochene erscheint NICHT",
|
|
!wartet.some((x) => x.id === an.projektId));
|
|
|
|
L("\n11. MELDUNGEN WEGKLICKEN");
|
|
const offenVorher = Auto.meldungenOffen();
|
|
pruefe("es gibt ungelesene Meldungen", offenVorher > 0, offenVorher);
|
|
db.prepare(`UPDATE wd_meldungen SET gelesen = 1, gelesen_am = ? WHERE gelesen = 0`)
|
|
.run(new Date().toISOString());
|
|
pruefe("danach sind es null", Auto.meldungenOffen() === 0);
|
|
/* Nur als gelesen markieren, nicht loeschen: Ein versehentliches
|
|
Wegklicken darf nichts endgueltig verschwinden lassen. */
|
|
pruefe("aber geloescht wurde nichts",
|
|
db.prepare(`SELECT COUNT(*) AS n FROM wd_meldungen`).get().n >= offenVorher);
|
|
|
|
L("\n12. GELDBETRAEGE — zwei Fehler, die man nur bei bestimmten Zahlen sieht");
|
|
const { centZuText } = await import("./lib/webdesign-helfer.js");
|
|
/* Beide fielen erst auf, als das erste Angebot über tausend Euro
|
|
entstand — alle früheren Testbeträge lagen darunter, und da sieht man
|
|
weder den fehlenden Tausenderpunkt noch das verlorene Minuszeichen. */
|
|
pruefe("Tausender bekommen einen Punkt", centZuText(149000) === "1.490,00 €", centZuText(149000));
|
|
pruefe("Millionen auch", centZuText(100000000) === "1.000.000,00 €", centZuText(100000000));
|
|
pruefe("darunter bleibt es unverändert", centZuText(99900) === "999,00 €", centZuText(99900));
|
|
/* Math.trunc(-0.5) ergibt -0, und -0 schreibt sich als "0": Eine
|
|
Erstattung von 50 Cent erschien damit als Forderung. */
|
|
pruefe("kleine NEGATIVE Beträge behalten ihr Minus", centZuText(-50) === "-0,50 €", centZuText(-50));
|
|
pruefe("größere negative auch", centZuText(-149000) === "-1.490,00 €", centZuText(-149000));
|
|
pruefe("null bleibt null", centZuText(0) === "0,00 €", centZuText(0));
|
|
pruefe("nichts bleibt nichts", centZuText(null) === null);
|
|
pruefe("die Nachkommastellen stimmen", centZuText(1490) === "14,90 €", centZuText(1490));
|
|
pruefe("eine andere Währung wird angehängt",
|
|
centZuText(1000, "CHF") === "10,00 CHF", centZuText(1000, "CHF"));
|
|
|
|
L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`);
|
|
process.exitCode = fehler ? 1 : 0;
|
|
rmSync(tmp, { recursive: true, force: true });
|