Annehmen war bisher nur ein Etikett: Man stellte den Status um, und es passierte nichts. Kunde, Projekt, Aufgabenliste, Termin und Zugang musste man danach von Hand in fünf Schritten nachbauen. Der alte Code gab das im Kommentar selbst zu -- schlug der zweite von zwei Aufrufen fehl, stand der Kunde schon in der Datenbank und man durfte es nicht noch einmal versuchen. Jetzt macht das EIN Aufruf, ganz oder gar nicht: Kunde finden oder anlegen, Projekt anlegen, die komplette Aufgabenliste des Pakets einsetzen, Liefertermin berechnen, Einladungslink erzeugen. Der Kunde verfolgt ab diesem Moment alles in seinem Portal. Die Zeit läuft wirklich: - Eigenes Datumsfeld (termin_am) neben dem freien Text. Aus 'Mitte Oktober' kann man keine verbleibenden Tage rechnen. - Werktage statt Kalendertage, inklusive luxemburgischer Feiertage. Die beweglichen werden über die Osterformel berechnet statt gepflegt -- eine Liste ist im übernächsten Jahr lautlos falsch. - Vorschlag je Paket (Onepager 10, Website 20, Shop 30 Werktage), überschreibbar vor dem Bestätigen. - Verbleibende Zeit wird SERVERSEITIG gerechnet. Der Browser kennt die Feiertage nicht; zwei verschiedene Zahlen für denselben Termin wären schlimmer als gar keine. Ablehnen mit Grund und vorbereiteter Absage in fünf Sprachen, Text serverseitig erzeugt und vor dem Abschicken lesbar. Ein laufendes Projekt lässt sich nicht nachträglich als Anfrage ablehnen. Sechs Fehler dabei gefunden: - Ein unbekanntes Paket hätte GAR KEINEN Termin bekommen statt des Ersatzwerts. Zwei Funktionen lasen dieselbe Tabelle, nur eine hatte einen Rückfallwert. Eine fehlende Zahl fällt nirgends auf. - wd_anfragen hat keine Spalte 'firma' -- better-sqlite3 weist undefined ab, die ganze Annahme wäre gescheitert. - Migrationen stehen in einer ausdrücklichen Liste; 0015 fehlte darin. - datumKurz() wurde aufgerufen, gab es aber nicht. Der Fehler wäre erst NACH dem Anlegen aufgetreten. - Der Installations-Hinweis lag fest über dem Annehmen-Knopf und machte ihn auf dem Handy untreffbar. Die Seite reserviert jetzt Platz dafür. - 'richttermin' ist freier Text, lief aber durch einen Datumsformatierer: Der Kunde las 'Invalid Date' an der wichtigsten Stelle seines Projekts. Geprüft: 49 Prüfungen der Terminrechnung gegen nachschlagbare Osterdaten und Wochentage, 70 gegen eine echte Datenbank (darunter: zweiter Klick legt nichts doppelt an, Abbruch mittendrin lässt NICHTS zurück, gesperrter Kunde wird nicht still entsperrt), 42 im Browser auf Computer und Handy, 40 im Portal. Alle bestehenden Prüfungen weiter grün. Co-Authored-By: Claude Opus 5 <[email protected]>
251 lines
12 KiB
JavaScript
251 lines
12 KiB
JavaScript
/* Prüft die Aufgabenliste und das Postfach im Kundenportal.
|
|
|
|
Wichtig sind hier drei Dinge, die man leicht falsch baut:
|
|
|
|
1. Der Kunde darf NUR seine eigenen Punkte anklicken. Ein Knopf, der
|
|
nichts tut, lässt die Seite kaputt wirken.
|
|
2. Offene Kundenpunkte müssen VOR der langen Liste stehen — sonst
|
|
liest man die Liste als Bericht und überliest seinen Teil.
|
|
3. Alle fünf Sprachen müssen vollständig sein. Ein fehlender
|
|
Schlüssel fällt sonst erst einem französischen Kunden auf.
|
|
*/
|
|
import { chromium } from "playwright";
|
|
import { createServer } from "node:http";
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
import { join, extname } from "node:path";
|
|
|
|
const W = process.cwd();
|
|
const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
|
|
".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
|
|
".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" };
|
|
const srv = createServer((q, r) => {
|
|
const p = join(W, decodeURIComponent(q.url.split("?")[0]));
|
|
if (existsSync(p) && !p.endsWith("/")) {
|
|
r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" });
|
|
return r.end(readFileSync(p));
|
|
}
|
|
r.writeHead(404).end("");
|
|
});
|
|
await new Promise((r) => srv.listen(4326, r));
|
|
|
|
const AUFGABEN = [
|
|
{ id: "a1", titel: "Briefing-Gespräch führen", beschreibung: "Ziel und Umfang klären.",
|
|
kategorie: "start", status: "erledigt", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null },
|
|
{ id: "a2", titel: "Texte und Bilder zusammenstellen", beschreibung: "Lieber früh als vollständig.",
|
|
kategorie: "start", status: "offen", wer_dran: "kunde", nicht_enthalten: 0, hinweis: null },
|
|
{ id: "a3", titel: "Entwurf der Startseite", beschreibung: null,
|
|
kategorie: "gestaltung", status: "erledigt", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null },
|
|
{ id: "a4", titel: "Entwurf ansehen und Rückmeldung geben", beschreibung: null,
|
|
kategorie: "gestaltung", status: "offen", wer_dran: "kunde", nicht_enthalten: 0, hinweis: null },
|
|
{ id: "a5", titel: "Alle Unterseiten bauen", beschreibung: null,
|
|
kategorie: "umsetzung", status: "in_arbeit", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null },
|
|
{ id: "a6", titel: "Mehrsprachigkeit", beschreibung: null,
|
|
kategorie: "umsetzung", status: "offen", wer_dran: "dogfather", nicht_enthalten: 1,
|
|
hinweis: "Auf Wunsch als Zusatz." },
|
|
{ id: "a7", titel: "Seite live schalten", beschreibung: null,
|
|
kategorie: "abschluss", status: "offen", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null },
|
|
];
|
|
|
|
const FORTSCHRITT = { gesamt: 6, erledigt: 2, inArbeit: 1, beimKunden: 2, prozent: 33,
|
|
jeKategorie: { start: { gesamt: 2, erledigt: 1 }, gestaltung: { gesamt: 2, erledigt: 1 },
|
|
umsetzung: { gesamt: 1, erledigt: 0 }, abschluss: { gesamt: 1, erledigt: 0 } } };
|
|
|
|
const PROJEKT = { id: "p1", nummer: "P-0001", titel: "Website für Musterbau",
|
|
status: "design", wartet_auf: "kunde", naechster_schritt: "Deine Rückmeldung zum Entwurf.",
|
|
/* Ein echtes Datum UND der freie Text daneben. Genau diese Kombination
|
|
war vorher kaputt: Der freie Text ("Mitte Oktober") wurde durch
|
|
einen Datumsformatierer geschickt und erschien als "Invalid Date". */
|
|
termin_am: "2026-10-15", richttermin: "Mitte Oktober, sobald deine Texte da sind",
|
|
verbleibende_werktage: 12 };
|
|
|
|
const ergebnisse = [];
|
|
function pruefe(name, ok, zusatz) {
|
|
ergebnisse.push({ name, ok });
|
|
console.log((ok ? " ok " : " FEHL ") + name + (zusatz ? " -> " + zusatz : ""));
|
|
}
|
|
|
|
const browser = await chromium.launch();
|
|
|
|
/* ---- 1. Vollständigkeit aller fünf Sprachen ---- */
|
|
{
|
|
const p = await browser.newPage();
|
|
await p.goto("http://127.0.0.1:4326/webdesign/portal.html");
|
|
await p.waitForTimeout(600);
|
|
const luecken = await p.evaluate(() => {
|
|
const w = window.I18N_WD || {};
|
|
const sprachen = ["de", "de-CH", "en", "fr", "pt"];
|
|
const fehlt = [];
|
|
for (const [k, v] of Object.entries(w)) {
|
|
for (const sp of sprachen) {
|
|
if (!v[sp] || !String(v[sp]).trim()) fehlt.push(k + "/" + sp);
|
|
}
|
|
}
|
|
return { fehlt, anzahl: Object.keys(w).length };
|
|
});
|
|
console.log("\n=== Sprachen ===");
|
|
pruefe("alle Texte in allen fünf Sprachen vorhanden", luecken.fehlt.length === 0,
|
|
luecken.fehlt.slice(0, 6).join(", "));
|
|
console.log(" (" + luecken.anzahl + " Texte geprüft)");
|
|
await p.close();
|
|
}
|
|
|
|
for (const [wo, breite, hoehe] of [["Computer", 1280, 950], ["Handy", 390, 844]]) {
|
|
const seite = await browser.newPage({ viewport: { width: breite, height: hoehe } });
|
|
const fehlerImProtokoll = [];
|
|
seite.on("pageerror", (e) => fehlerImProtokoll.push(e.message));
|
|
|
|
let abgehakt = null;
|
|
// Nur die API abfangen, nicht die Seite selbst.
|
|
await seite.route("**/webdesign/portal/**", (route) => {
|
|
/* Auf den PFAD prüfen, nicht auf die ganze Adresse.
|
|
|
|
Der API-Server heisst "postfach.dogfather-universe.com" -- ein
|
|
url.includes("/postfach") trifft also den HOSTNAMEN und damit
|
|
JEDE Anfrage. Beim ersten Lauf bekam deshalb auch die Übersicht
|
|
die Postfach-Antwort, und der Fehler sah aus wie ein Fehler im
|
|
Portal ("Cannot read properties of undefined"). Er lag im Test. */
|
|
const u = new URL(route.request().url()).pathname;
|
|
if (u.includes("/projekt/p1/aufgaben")) {
|
|
return route.fulfill({ json: { ok: true, projekt: PROJEKT, aufgaben: AUFGABEN, fortschritt: FORTSCHRITT } });
|
|
}
|
|
if (u.includes("/aufgabe/") && u.includes("/erledigt")) {
|
|
abgehakt = u.split("/aufgabe/")[1].split("/")[0];
|
|
return route.fulfill({ json: { ok: true, status: "erledigt", fortschritt: FORTSCHRITT } });
|
|
}
|
|
if (u.includes("/projekt/p1")) {
|
|
return route.fulfill({ json: { ok: true, projekt: PROJEKT,
|
|
zahlungen: [], dateien: [], aenderungen: [], nachrichten: [] } });
|
|
}
|
|
if (u.includes("/postfach")) {
|
|
if (route.request().method() === "POST") return route.fulfill({ json: { ok: true, id: "x" } });
|
|
return route.fulfill({ json: { ok: true, projekte: [{ id: "p1", titel: "Website für Musterbau" }],
|
|
nachrichten: [
|
|
{ id: "m1", autor: "kunde", text: "Kurze Frage zur Domain.", projekt_titel: null,
|
|
erstellt_am: new Date(Date.now() - 7200000).toISOString() },
|
|
{ id: "m2", autor: "dogfather", text: "Die läuft schon auf dich, alles gut.",
|
|
projekt_titel: null, erstellt_am: new Date(Date.now() - 3600000).toISOString() },
|
|
] } });
|
|
}
|
|
if (u.includes("/uebersicht")) {
|
|
return route.fulfill({ json: { ok: true, kunde: { name: "Anna" },
|
|
projekte: [PROJEKT], offeneZahlungen: [], abos: [] } });
|
|
}
|
|
return route.fulfill({ json: { ok: true } });
|
|
});
|
|
await seite.addInitScript(() => {
|
|
sessionStorage.setItem("wd-portal-token", "t");
|
|
sessionStorage.setItem("wd-sitzung-aktiv", "1");
|
|
});
|
|
|
|
await seite.goto("http://127.0.0.1:4326/webdesign/portal.html");
|
|
await seite.waitForTimeout(1000);
|
|
console.log("\n=== " + wo + " ===");
|
|
|
|
// ---- Postfach auf der Übersicht ----
|
|
pruefe(wo + ": Postfach steht auf der Übersicht", await seite.locator("#po-pf-senden").count() === 1);
|
|
pruefe(wo + ": bisherige Nachrichten sind da", await seite.locator("#po-postfach .po-nachricht").count() === 2);
|
|
|
|
// Leere Nachricht muss abgelehnt werden, nicht stillschweigend gesendet
|
|
await seite.click("#po-pf-senden");
|
|
await seite.waitForTimeout(300);
|
|
pruefe(wo + ": leere Nachricht wird abgelehnt",
|
|
await seite.locator("#po-pf-feld.wd-feld--fehler").count() === 1);
|
|
|
|
await seite.screenshot({ path: `pruef-portal-postfach-${wo.toLowerCase()}.png`, fullPage: true });
|
|
|
|
// ---- Projekt öffnen ----
|
|
await seite.click("[data-projekt]");
|
|
await seite.waitForTimeout(900);
|
|
|
|
const m = await seite.evaluate(() => {
|
|
const gruppen = Array.from(document.querySelectorAll(".po-aufg-gruppe"));
|
|
const farben = gruppen.map((g) => getComputedStyle(g.querySelector(".po-aufg-kopf")).color);
|
|
const dubist = document.querySelector(".po-dubist");
|
|
const ersteGruppe = gruppen[0];
|
|
const knoepfe = document.querySelectorAll("button.po-aufg-haken");
|
|
const feste = document.querySelectorAll(".po-aufg-haken--fest");
|
|
const aufgKarte = document.querySelector(".po-aufg-stand");
|
|
const zahlKarte = Array.from(document.querySelectorAll(".wd-karte h3"))
|
|
.find((h) => h.textContent.includes("Zahlung"));
|
|
|
|
// Anordnung einer Zeile pruefen (Lehre aus der Verwaltung)
|
|
const z = document.querySelector(".po-aufg-gruppe .po-aufg");
|
|
const k = z?.querySelector(".po-aufg-haken")?.getBoundingClientRect();
|
|
const t = z?.querySelector(".po-aufg-titel")?.getBoundingClientRect();
|
|
|
|
return {
|
|
gruppen: gruppen.map((g) => g.dataset.k),
|
|
farbenVerschieden: new Set(farben).size,
|
|
dubistDa: !!dubist,
|
|
dubistVorListe: dubist && ersteGruppe
|
|
? dubist.getBoundingClientRect().top < ersteGruppe.getBoundingClientRect().top : false,
|
|
knoepfe: knoepfe.length,
|
|
feste: feste.length,
|
|
standDa: !!aufgKarte,
|
|
aufgabenVorZahlungen: aufgKarte && zahlKarte
|
|
? aufgKarte.getBoundingClientRect().top < zahlKarte.getBoundingClientRect().top : false,
|
|
kaestchenVorTitel: k && t ? k.right <= t.left + 1 : false,
|
|
balkenTeile: document.querySelectorAll(".po-fs-teil").length,
|
|
};
|
|
});
|
|
|
|
pruefe(wo + ": alle vier Abschnitte erscheinen",
|
|
m.gruppen.join(",") === "start,gestaltung,umsetzung,abschluss", m.gruppen.join(","));
|
|
pruefe(wo + ": jeder Abschnitt hat eine eigene Farbe", m.farbenVerschieden === 4, "n=" + m.farbenVerschieden);
|
|
pruefe(wo + ": Fortschrittsbalken hat vier Teile", m.balkenTeile === 4);
|
|
pruefe(wo + ": „Das brauche ich von dir“ ist da", m.dubistDa);
|
|
pruefe(wo + ": … und steht VOR der langen Liste", m.dubistVorListe);
|
|
pruefe(wo + ": Aufgabenliste steht vor den Zahlungen", m.aufgabenVorZahlungen);
|
|
pruefe(wo + ": grosse Zahl statt Prozent", m.standDa);
|
|
pruefe(wo + ": Kästchen steht links vom Titel", m.kaestchenVorTitel);
|
|
|
|
/* Der Kunde hat 2 offene eigene Punkte. Sie erscheinen zweimal (oben
|
|
und in der Gruppe) -> 4 Knöpfe. Alle übrigen Kästchen sind fest. */
|
|
pruefe(wo + ": nur eigene Punkte sind anklickbar", m.knoepfe === 4, "Knöpfe=" + m.knoepfe + ", fest=" + m.feste);
|
|
pruefe(wo + ": fremde Punkte haben KEINEN Knopf", m.feste === 5, "n=" + m.feste);
|
|
|
|
// Abhaken loest den richtigen Aufruf aus
|
|
await seite.locator("button.po-aufg-haken").first().click();
|
|
await seite.waitForTimeout(700);
|
|
pruefe(wo + ": Abhaken meldet den richtigen Punkt", abgehakt === "a2", "gemeldet: " + abgehakt);
|
|
|
|
if (breite < 900) {
|
|
const zuKlein = await seite.evaluate(() =>
|
|
Array.from(document.querySelectorAll("button.po-aufg-haken"))
|
|
.filter((b) => { const r = b.getBoundingClientRect(); return r.width < 24 || r.height < 24; }).length);
|
|
pruefe(wo + ": Kästchen sind gross genug zum Antippen", zuKlein === 0, "zu klein: " + zuKlein);
|
|
}
|
|
|
|
|
|
/* ---- Liefertermin und verbleibende Zeit ----
|
|
Der Kunde soll ab der Annahme sehen, WANN es fertig wird und wie viel
|
|
Zeit noch ist. Geprueft wird die Projektkarte, nicht die Detailseite. */
|
|
const karte = await seite.locator("[data-projekt]").first().innerText();
|
|
pruefe(wo + ": der Liefertermin steht lesbar auf der Karte",
|
|
karte.includes("15.10.2026"), karte.slice(0, 220));
|
|
pruefe(wo + ": die verbleibende Zeit steht dabei",
|
|
/12 Werktage/.test(karte), karte.slice(0, 220));
|
|
/* Der wichtigste Punkt: Der freie Text darf NICHT durch einen
|
|
Datumsformatierer laufen. Vorher stand hier "Invalid Date". */
|
|
pruefe(wo + ": der freie Terminhinweis erscheint als Text, nicht als Invalid Date",
|
|
karte.includes("Mitte Oktober") && !/Invalid/i.test(karte),
|
|
karte.slice(0, 220));
|
|
/* Farbe allein traegt die Aussage nicht -- bei 12 Tagen ist es
|
|
entspannt, also darf nichts alarmieren. */
|
|
const fristTon = await seite.locator(".po-frist").first()
|
|
.getAttribute("class").catch(() => "");
|
|
pruefe(wo + ": bei 12 Tagen wird nicht gewarnt",
|
|
!/--rot|--warn/.test(fristTon || ""), fristTon);
|
|
|
|
pruefe(wo + ": keine Fehler im Protokoll", fehlerImProtokoll.length === 0, fehlerImProtokoll.join(" | "));
|
|
|
|
await seite.screenshot({ path: `pruef-portal-aufgaben-${wo.toLowerCase()}.png`, fullPage: true });
|
|
await seite.close();
|
|
}
|
|
|
|
await browser.close();
|
|
srv.close();
|
|
const fehler = ergebnisse.filter((e) => !e.ok).length;
|
|
console.log(`\n===== ${ergebnisse.length - fehler} bestanden, ${fehler} fehlgeschlagen =====`);
|
|
process.exitCode = fehler ? 1 : 0;
|