diff --git a/server/mail-templates/K04 Eingang Widerruf.html b/server/mail-templates/K04 Eingang Widerruf.html index f7580ec..b530d33 100644 --- a/server/mail-templates/K04 Eingang Widerruf.html +++ b/server/mail-templates/K04 Eingang Widerruf.html @@ -8,7 +8,8 @@ Absender: „WEBklar“ Empfänger: E-Mail aus dem Formular · BCC intern Platzhalter: {{name}}, {{produkt}}, {{bestellung}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen, projekt, projekt_unternehmen + projekt = Widerruf eines Projektauftrags (Aufträge und Rechnungen): wir prüfen und melden uns, ohne Zusagen zu Erstattung oder Wertersatz; projekt_unternehmen (nur innerhalb von projekt) = die Auftragsbestätigung gehört zu einem Unternehmen, der Zusatz nennt, dass kein gesetzliches Widerrufsrecht besteht. produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo); beim Widerruf einer Funktion ihr Name aus dem Register (News-Pinnwand, Terminplaner). Folgen je Bestellung wie in den Hinweisen zum Widerruf (Fassung 4): abschluss (Wertersatz bei Sofortbeginn, Zusatzspeicher voll erstattet), wechsel (Hosting-Abo läuft weiter, Mehrbetrag abzüglich Wertersatz), zusatzspeicher (volle Erstattung, kein Wertersatz), pruefen (keine offene Frist). --> @@ -56,6 +57,9 @@ a{color:#111111;} + + + Viele Grüße
Ihr WEBklar-Team diff --git a/server/mail-templates/K31 Auftragsbestaetigung.html b/server/mail-templates/K31 Auftragsbestaetigung.html new file mode 100644 index 0000000..c9b20b9 --- /dev/null +++ b/server/mail-templates/K31 Auftragsbestaetigung.html @@ -0,0 +1,70 @@ + + + + + + + + +Ihre Auftragsbestätigung {{nummer}} – {{titel}} + + + + +Bitte prüfen und bestätigen Sie Ihren Auftrag {{nummer}}. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/K32 Auftrag bestaetigt.html b/server/mail-templates/K32 Auftrag bestaetigt.html new file mode 100644 index 0000000..45a26b7 --- /dev/null +++ b/server/mail-templates/K32 Auftrag bestaetigt.html @@ -0,0 +1,56 @@ + + + + + + + + +Ihre Bestätigung zu {{nummer}} ist eingegangen + + + + +Wir haben Ihre Bestätigung zu {{nummer}} erhalten. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/T09 Widerruf eingegangen.html b/server/mail-templates/T09 Widerruf eingegangen.html index 23dccc2..12d105c 100644 --- a/server/mail-templates/T09 Widerruf eingegangen.html +++ b/server/mail-templates/T09 Widerruf eingegangen.html @@ -9,7 +9,8 @@ Empfänger: kenso@webklar.com, andrej@webklar.com Platzhalter: {{email}}, {{name}}, {{produkt}}, {{preis}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}} preis = Preis der widerrufenen Funktion aus dem Register (nur im Block funktion, sonst leer). - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, funktion, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, funktion, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen, projekt + projekt = Widerruf eines Projektauftrags (Aufträge und Rechnungen), ohne Abo und ohne Vertragsereignis: ereignisse = Ergebnis der Prüfung der Auftragsbestätigung, fristende = Ende der Widerrufsfrist (TT.MM.JJJJ, 24 Uhr), rechtzeitig = Eingang (RECHTZEITIG, NACH Fristende, ungeprüft …); die Zeile „Abo“ zeigt nur die Vertragsart. --> @@ -64,6 +65,9 @@ a{color:#111111;} + + + diff --git a/server/mail-templates/T22 Auftrag bestaetigt.html b/server/mail-templates/T22 Auftrag bestaetigt.html new file mode 100644 index 0000000..3830cb2 --- /dev/null +++ b/server/mail-templates/T22 Auftrag bestaetigt.html @@ -0,0 +1,55 @@ + + + + + + + + +[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}} + + + + +{{kunde}}: {{nummer}} bestätigt. ͏ ͏ ͏ + + + + + diff --git a/server/routes/abo.js b/server/routes/abo.js index 894c5c3..74a083c 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -5,6 +5,8 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js' import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent, updateContractEvent } from '../services/contractEvents.js' import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js' +import { findAuftragByNummer } from '../services/auftraege/auftragStore.js' +import { widerrufeAuftrag } from '../services/auftraege/bestaetigung.js' import { getLedger } from '../services/hourLedger.js' import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js' import { changeStorage, previewStorageChange, recordPaidStorage, storageTaskKind } from '../services/mailStorage.js' @@ -727,7 +729,8 @@ export function publicRateLimitState() { // Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert const CONTRACTS = ['abo', 'zusatzspeicher'] -const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten'] +const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten', 'projekt'] +const AB_NUMMER = /^AB-\d{4}-\d{4}$/ // Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist) const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', zeiten: 'zeiten' } // Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals) @@ -735,7 +738,7 @@ const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspe /** * Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl. - * contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'zeiten'; packages (nur beim + * contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'zeiten' | 'projekt'; packages (nur beim * Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026). */ function validDeclaration(body) { @@ -764,6 +767,7 @@ function validDeclaration(body) { contract, order, packages, + abNummer: String(body?.abNummer || '').trim().toUpperCase().slice(0, 20), } } @@ -804,7 +808,8 @@ function contractLabel(contract, sub) { } /** Beschriftung der widerrufenen Bestellung fuer K04 (an den Kunden) und T09 (intern). */ -function orderLabel(order, sub, { customer = false } = {}) { +function orderLabel(order, sub, { customer = false, abNummer = '' } = {}) { + if (order === 'projekt') return AB_NUMMER.test(abNummer) ? `Projektauftrag (Auftragsbestätigung ${abNummer})` : 'Projektauftrag (Auftragsbestätigung)' if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo' if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher' if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)' @@ -1147,6 +1152,37 @@ async function withdrawFeature(d, receivedAt, featureKey) { return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline } } +/** Projektauftrag (Aufträge und Rechnungen, Spec 2026-10-06 Abschnitt 2): Nummer und E-Mail-Adresse müssen zur gesendeten AB passen. Ohne gültige Nummer wird nichts nachgeschlagen. Wirft bei Datenbankfehlern. */ +async function withdrawProjekt(d, receivedAt) { + const auftrag = AB_NUMMER.test(d.abNummer) ? await findAuftragByNummer(d.abNummer) : null + const adressen = [auftrag?.gesendetAn, auftrag?.kunde?.email].map((adresse) => String(adresse || '').trim().toLowerCase()) + if (!auftrag || !adressen.includes(d.email.toLowerCase())) return { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null } + return widerrufeAuftrag(auftrag, { jetzt: receivedAt }) +} + +const PROJEKT_EINGANG = { widerrufen: 'RECHTZEITIG', nach_frist: 'NACH Fristende, vor einer Ablehnung bitte prüfen', bereits_widerrufen: 'bereits widerrufen' } + +/** Werte für die Zeilen „Ergebnis“, „Widerrufsfrist bis“ und „Eingang“ im Block projekt von T09 (nur das Team sieht sie). */ +function projektWerte({ ergebnis, auftrag, fristEnde }, abNummer) { + const ergebnisText = { + widerrufen: () => `Auftragsbestätigung auf „widerrufen“ gesetzt${auftrag.fruehbeginn ? '; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)' : ''}`, + bereits_widerrufen: () => 'war schon widerrufen, nichts geändert', + kein_widerrufsrecht: () => 'Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert', + nach_frist: () => 'nichts geändert', + nicht_widerrufbar: () => `Status „${auftrag.status}“, nichts geändert, bitte prüfen`, + fehler: () => 'ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen', + }[ergebnis] + return { + ereignisse: ergebnisText + ? ergebnisText() + : AB_NUMMER.test(abNummer) + ? `ungeprüft: keine Auftragsbestätigung zu Nummer „${abNummer}“ und dieser E-Mail-Adresse gefunden, nichts geändert` + : 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen', + fristende: ergebnis === 'kein_widerrufsrecht' ? 'entfällt (Unternehmen)' : fristEnde ? `${fmtDate(fristEnde)}, 24 Uhr` : 'keine Frist gefunden', + rechtzeitig: PROJEKT_EINGANG[ergebnis] ?? 'ungeprüft', + } +} + /** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */ function describeWithdrawn(events) { return events @@ -1168,10 +1204,14 @@ router.post('/public/withdraw', async (req, res) => { // Funktion (News, Terminplaner oder Webshop): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature) const featureKey = FEATURE_OF_ORDER[d.order] ?? null const feature = featureKey !== null + const projekt = d.order === 'projekt' + let pr = { auftrag: null, ergebnis: 'ungeprueft', fristEnde: null } const funktion = feature ? featureConfig(featureKey) : null let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false } try { - if (feature) { + if (projekt) { + pr = await withdrawProjekt(d, receivedAt) + } else if (feature) { fn = await withdrawFeature(d, receivedAt, featureKey) result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) } } else { @@ -1183,8 +1223,11 @@ router.post('/public/withdraw', async (req, res) => { } } catch (err) { console.error('[abo] Widerruf nicht verarbeitbar:', err.message) + if (projekt) pr = { auftrag: null, ergebnis: 'fehler', fristEnde: null } } const { inTime } = result + const projektAngaben = projekt ? projektWerte(pr, d.abNummer) : null + const produktText = projekt ? 'Projektauftrag' : feature ? funktion.label.name : productLabelOf(sub) // Fuer T09: warum sich die Frist nicht pruefen liess const pruefhinweis = result.unreadable ? 'Ereignisse nicht lesbar – bitte von Hand prüfen' : result.startMissing ? 'Abo-Beginn fehlt' : '' @@ -1192,8 +1235,8 @@ router.post('/public/withdraw', async (req, res) => { template: 'K04', values: { name: d.name, - bestellung: orderLabel(d.order, sub, { customer: true }), - produkt: feature ? funktion.label.name : productLabelOf(sub), + bestellung: orderLabel(d.order, sub, { customer: true, abNummer: d.abNummer }), + produkt: produktText, eingang_zeitpunkt: fmtDateTime(receivedAt), email: d.email, kundennummer: d.customerNumber, @@ -1204,7 +1247,9 @@ router.post('/public/withdraw', async (req, res) => { wechsel: inTime && d.order === 'wechsel', zusatzspeicher: inTime && d.order === 'zusatzspeicher', funktion: feature, - pruefen: !inTime && !feature, + pruefen: !inTime && !feature && !projekt, + projekt, + projekt_unternehmen: projekt && pr.ergebnis === 'kein_widerrufsrecht', }, }).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message)) @@ -1215,13 +1260,15 @@ router.post('/public/withdraw', async (req, res) => { email: d.email, name: d.name, kundennummer: d.customerNumber, - bestellung: orderLabel(d.order, sub), - produkt: feature ? funktion.label.name : productLabelOf(sub), + bestellung: orderLabel(d.order, sub, { abNummer: d.abNummer }), + produkt: produktText, preis: feature ? funktion.priceLabel : '', stripe_abo_id: sub?.stripeSubscriptionId || '', abo_beginn: fmtDate(sub?.startedAt), - fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden', - rechtzeitig: feature + fristende: projekt ? projektAngaben.fristende : result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden', + rechtzeitig: projekt + ? projektAngaben.rechtzeitig + : feature ? !fn.customer ? 'ungeprüft (kein Kunde zu dieser E-Mail-Adresse gefunden)' : !fn.orders.length @@ -1242,14 +1289,14 @@ router.post('/public/withdraw', async (req, res) => { : result.deadline ? 'ungeprüft (keine offene Frist: Bestellung gescheitert)' : 'ungeprüft (keine Bestellung dieser Art gefunden)', - ereignisse: feature ? fn.lines : describeWithdrawn(result.withdrawn), + ereignisse: projekt ? projektAngaben.ereignisse : feature ? fn.lines : describeWithdrawn(result.withdrawn), pruefhinweis, }, blocks: { kundennummer: Boolean(d.customerNumber), abo_gefunden: Boolean(sub), auf_kuendigung_gesetzt: result.canceled, - kein_abo_gefunden: !sub && !feature, + kein_abo_gefunden: !sub && !feature && !projekt, kuendigung_fehlgeschlagen: result.cancelFailed, abschluss: inTime && d.order === 'abschluss', wechsel: inTime && d.order === 'wechsel', @@ -1261,6 +1308,7 @@ router.post('/public/withdraw', async (req, res) => { abschluss_verspaetet: Boolean(sub) && d.order === 'abschluss' && result.late && !result.alreadyWithdrawn, nicht_pruefbar: Boolean(sub) && Boolean(pruefhinweis), bereits_widerrufen: Boolean(sub) && result.alreadyWithdrawn, + projekt, }, }) diff --git a/server/routes/auftraegeKunde.js b/server/routes/auftraegeKunde.js index 11c7852..445047f 100644 --- a/server/routes/auftraegeKunde.js +++ b/server/routes/auftraegeKunde.js @@ -1,12 +1,89 @@ /** - * Gerüst von Strang L0: Aufträge des angemeldeten Kunden. Mount: /api/auftraege. requireSession schützt jede später - * ergänzte Route (fail-closed). L5 ergänzt GET /, GET /:id/pdf, POST /:id/bestaetigen; das Bestätigen sperrt der - * Wächter der Kundenansicht von selbst, es kommt kein Eintrag in KUNDENANSICHT_ALLOWED. + * Aufträge des angemeldeten Kunden (Übersicht V3/V5, Strang L5). Mount: /api/auftraege. requireSession schützt jede + * Route (fail-closed). Ein Kunde sieht und bestätigt nur eigene, gesendete Auftragsbestätigungen (keine Entwürfe); + * in der Kundenansicht ist Bestätigen nie angeboten, und der Wächter der Kundenansicht sperrt den POST von selbst + * (es gibt keinen Eintrag in KUNDENANSICHT_ALLOWED). */ import { Router } from 'express' -import { requireSession } from '../middleware/session.js' +import { config } from '../config.js' +import { getSessionCustomerId, requireSession } from '../middleware/session.js' +import { getDocument } from '../services/appwriteAdmin.js' +import { isKundenansicht } from '../services/kundenansicht.js' +import { getAuftrag, listAuftraege } from '../services/auftraege/auftragStore.js' +import { oeffentlicheAnsicht } from '../services/auftraege/auftragSicht.js' +import { bestaetigeAuftrag, GESPERRT_MELDUNG } from '../services/auftraege/bestaetigung.js' +import { AuftragError, sendeFehler } from '../services/auftraege/fehler.js' +import { auftraegeAktiv } from '../services/auftraege/schalter.js' +import { sendePdf } from './auftrag.js' + +const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.' +const HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' +const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ const router = Router() router.use(requireSession) +router.use((_req, res, next) => { + res.set('Cache-Control', 'no-store') + next() +}) + +const ansicht = (a, req) => ({ + ...oeffentlicheAnsicht(a), + id: a.id, + kannBestaetigen: !isKundenansicht(req.session) && (a.status === 'versendet' || (a.status === 'abgerechnet' && !a.bestaetigtAm)), +}) + +/** Eigene, gesendete AB laden; sonst ist 404 schon gesendet und das Ergebnis `null`. */ +async function ladeEigene(req, res) { + const nichtGefunden = () => { + res.status(404).json({ error: NICHT_GEFUNDEN }) + return null + } + if (!ID_RE.test(String(req.params.id))) return nichtGefunden() + const kundenId = getSessionCustomerId(req) + const kunde = await getDocument(config.collections.customers, kundenId) + if (!auftraegeAktiv(kunde)) return nichtGefunden() + const a = await getAuftrag(req.params.id) + if (!a || a.kundenId !== kundenId || a.status === 'entwurf') return nichtGefunden() + return a +} + +router.get('/', async (req, res) => { + try { + const kundenId = getSessionCustomerId(req) + const kunde = await getDocument(config.collections.customers, kundenId) + if (!auftraegeAktiv(kunde)) return res.json({ aktiv: false, auftraege: [] }) + const liste = await listAuftraege({ kundenId }) + res.json({ aktiv: true, auftraege: liste.filter((a) => a.status !== 'entwurf').map((a) => ansicht(a, req)) }) + } catch (err) { + sendeFehler(res, 'auftraege', err) + } +}) + +router.get('/:id/pdf', async (req, res) => { + try { + const a = await ladeEigene(req, res) + if (a) await sendePdf(res, a) + } catch (err) { + sendeFehler(res, 'auftraege', err) + } +}) + +router.post('/:id/bestaetigen', async (req, res) => { + try { + const a = await ladeEigene(req, res) + if (!a) return + if (GESPERRT_MELDUNG[a.status]) return res.status(410).json({ error: GESPERRT_MELDUNG[a.status] }) + const body = req.body && typeof req.body === 'object' ? req.body : {} + if (body.bestaetigt !== true) return res.status(400).json({ error: HAEKCHEN }) + const neu = await bestaetigeAuftrag(a, { ueber: 'portal', fruehbeginn: body.fruehbeginn === true }) + res.json({ auftrag: ansicht(neu, req) }) + } catch (err) { + if (err instanceof AuftragError && err.status === 409 && Object.values(GESPERRT_MELDUNG).includes(err.message)) { + return res.status(410).json({ error: err.message }) + } + sendeFehler(res, 'auftraege', err) + } +}) export default router diff --git a/server/routes/auftrag.js b/server/routes/auftrag.js index 68bcadc..033fa39 100644 --- a/server/routes/auftrag.js +++ b/server/routes/auftrag.js @@ -1,10 +1,118 @@ /** - * Gerüst von Strang L0: öffentliche Bestätigung einer Auftragsbestätigung per Token (ohne Anmeldung, das Token steht - * in der Adresse). Mount: /api/auftrag. Kein Sitzungs- und kein Wächter-Eintrag nötig. L5 ergänzt die Ratenbegrenzung - * im Arbeitsspeicher (keine IP-Adressen speichern) und GET /:token, GET /:token/pdf, POST /:token/bestaetigen. + * Öffentliche Link-Routen einer Auftragsbestätigung (Übersicht V5, Strang L5): ansehen, PDF, bestätigen – ohne + * Anmeldung, das Token steht im Link (Fragment `#t=`, die Seite schickt es hierher). Mount: /api/auftrag. + * Ratenbegrenzung nur im Arbeitsspeicher (GET 120/h, POST 10/h je IP); nichts von der Anfrage wird gespeichert oder + * protokolliert. Ungültige, unbekannte, abgelaufene Links und abgeschalteter Schalter antworten gleich (404). */ import { Router } from 'express' +import { createWriteLimiter } from '../services/writeLimiter.js' +import { findAuftragByTokenHash } from '../services/auftraege/auftragStore.js' +import { lesePdf } from '../services/auftraege/ablage.js' +import { oeffentlicheAnsicht } from '../services/auftraege/auftragSicht.js' +import { bestaetigeAuftrag, GESPERRT_MELDUNG } from '../services/auftraege/bestaetigung.js' +import { AuftragError, sendeFehler } from '../services/auftraege/fehler.js' +import { auftraegeAktiv } from '../services/auftraege/schalter.js' +import { hashToken } from '../services/auftraege/token.js' + +const UNGUELTIG = 'Dieser Link ist ungültig oder abgelaufen.' +const HAEKCHEN = 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' +const ZU_VIELE = 'Zu viele Anfragen. Bitte später erneut versuchen.' + +const echteUhr = () => new Date() +let uhr = echteUhr +const jetztMs = () => uhr().getTime() +const getLimiter = createWriteLimiter({ max: 120, now: jetztMs }) +const postLimiter = createWriteLimiter({ max: 10, now: jetztMs }) +const nachIp = (req) => String(req.ip) + +/** Nur für Tests: feste Uhr der Routen (`null` = echte Uhr). */ +export function setzeUhrFuerTests(fn) { + uhr = typeof fn === 'function' ? fn : echteUhr +} + +/** Nur für Tests: Zähler leeren und echte Uhr. */ +export function resetAuftragRoutesForTests() { + getLimiter.reset() + postLimiter.reset() + uhr = echteUhr +} const router = Router() +router.use((_req, res, next) => { + res.set({ + 'Cache-Control': 'no-store', + 'X-Robots-Tag': 'noindex', + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', + }) + next() +}) + +/** Gemeinsamer Vorlauf der Link-Routen; bei `null` ist die Antwort (404/410) schon gesendet. */ +async function ladeNachToken(req, res) { + const nichtGueltig = () => { + res.status(404).json({ error: UNGUELTIG }) + return null + } + const hash = hashToken(req.params.token) + if (hash === null) return nichtGueltig() + const a = await findAuftragByTokenHash(hash) + if (!a) return nichtGueltig() + if (!auftraegeAktiv({ email: a.kunde?.email || a.gesendetAn })) return nichtGueltig() + if (a.status === 'entwurf') return nichtGueltig() + if (GESPERRT_MELDUNG[a.status]) { + res.status(410).json({ error: GESPERRT_MELDUNG[a.status] }) + return null + } + const bis = a.tokenGueltigBis ? Date.parse(a.tokenGueltigBis) : Number.NaN + if (!Number.isFinite(bis) || jetztMs() > bis) return nichtGueltig() + return a +} + +/** AB-PDF aus der Ablage senden (inline); fehlt die Datei, 404 `PDF nicht gefunden.`. */ +export async function sendePdf(res, auftrag) { + let buffer + try { + buffer = await lesePdf(auftrag.pdfDatei) + } catch (err) { + if (err instanceof AuftragError) throw err + console.error(`[auftraege] PDF zu ${auftrag.nummer} nicht lesbar: ${err.code || err.message}`) + throw new AuftragError(404, 'PDF nicht gefunden.') + } + const nummer = String(auftrag.nummer || 'Auftragsbestaetigung').replace(/[^A-Za-z0-9-]/g, '') || 'Auftragsbestaetigung' + res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="${nummer}.pdf"` }) + res.end(buffer) +} + +router.get('/:token', getLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => { + try { + const a = await ladeNachToken(req, res) + if (a) res.json({ auftrag: oeffentlicheAnsicht(a) }) + } catch (err) { + sendeFehler(res, 'auftrag', err) + } +}) + +router.get('/:token/pdf', getLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => { + try { + const a = await ladeNachToken(req, res) + if (a) await sendePdf(res, a) + } catch (err) { + sendeFehler(res, 'auftrag', err) + } +}) + +router.post('/:token/bestaetigen', postLimiter.middleware(nachIp, ZU_VIELE), async (req, res) => { + try { + const a = await ladeNachToken(req, res) + if (!a) return + const body = req.body && typeof req.body === 'object' ? req.body : {} + if (body.bestaetigt !== true) return res.status(400).json({ error: HAEKCHEN }) + const neu = await bestaetigeAuftrag(a, { ueber: 'link', fruehbeginn: body.fruehbeginn === true, jetzt: uhr() }) + res.json({ auftrag: oeffentlicheAnsicht(neu) }) + } catch (err) { + sendeFehler(res, 'auftrag', err) + } +}) export default router diff --git a/server/services/auftraege/bestaetigung.js b/server/services/auftraege/bestaetigung.js new file mode 100644 index 0000000..c33eedf --- /dev/null +++ b/server/services/auftraege/bestaetigung.js @@ -0,0 +1,76 @@ +/** + * Bestätigen und Widerrufen einer Auftragsbestätigung (Übersicht V2/V4, Strang L5): idempotent, unter der Sperre `sperreAuftrag(id)`, + * Stand innerhalb der Sperre neu gelesen. K32 und T22 gehen erst nach der Freigabe der Sperre raus; Mailfehler stehen + * nur im Protokoll. Die erste Bestätigung zählt, jede weitere ändert nichts. + */ +import { withdrawalDeadline } from '../deadlines.js' +import { withLocks } from '../keyedLock.js' +import { getAuftrag, updateAuftrag } from './auftragStore.js' +import { AuftragError } from './fehler.js' +import { sperreAuftrag } from './konstanten.js' +import { sendeBestaetigtMails } from './mails.js' +import { FRUEHBEGINN_FASSUNG } from './texte.js' + +export const GESPERRT_MELDUNG = { + ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.', + storniert: 'Diese Auftragsbestätigung wurde storniert.', + widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.', +} +const NICHT_BESTAETIGBAR = 'Diese Auftragsbestätigung kann nicht bestätigt werden.' +const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.' +const GESPERRT_STATUS = ['entwurf', 'ersetzt', 'storniert', 'widerrufen'] + +/** 'offen' (bestätigbar), 'bestaetigt' (schon bestätigt) oder 'gesperrt' (Entwurf, ersetzt, storniert, widerrufen). */ +export function bestaetigungsLage(auftrag) { + if (GESPERRT_STATUS.includes(auftrag.status)) return 'gesperrt' + return auftrag.bestaetigtAm || auftrag.status === 'bestaetigt' ? 'bestaetigt' : 'offen' +} + +function pruefeZeitpunkt(jetzt) { + if (!(jetzt instanceof Date) || Number.isNaN(jetzt.getTime())) throw new TypeError('jetzt muss ein gültiges Date sein') +} + +export async function bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() } = {}) { + // synchron vor der Sperre: der zuerst gestartete Aufruf bekommt die Sperre zuerst + if (ueber !== 'link' && ueber !== 'portal') throw new TypeError('bestaetigeAuftrag: ueber muss „link“ oder „portal“ sein') + pruefeZeitpunkt(jetzt) + const { aktuell, bestaetigtJetzt } = await withLocks(sperreAuftrag(auftrag.id), async () => { + const frisch = await getAuftrag(auftrag.id) + if (!frisch) throw new AuftragError(404, NICHT_GEFUNDEN) + const lage = bestaetigungsLage(frisch) + if (lage === 'gesperrt') throw new AuftragError(409, GESPERRT_MELDUNG[frisch.status] ?? NICHT_BESTAETIGBAR) + if (lage === 'bestaetigt') return { aktuell: frisch, bestaetigtJetzt: false } + const privat = frisch.kunde?.kundenart === 'privat' + const patch = { + bestaetigtAm: jetzt.toISOString(), + bestaetigtUeber: ueber, + bestaetigtPdfSha256: frisch.pdfSha256, + fruehbeginn: privat && fruehbeginn === true, + fruehbeginnFassung: privat ? FRUEHBEGINN_FASSUNG : '', + } + if (frisch.status === 'versendet') patch.status = 'bestaetigt' + return { aktuell: await updateAuftrag(frisch.id, patch), bestaetigtJetzt: true } + }) + if (bestaetigtJetzt) await sendeBestaetigtMails(aktuell) + return aktuell +} + +/** + * Widerruf einer Privatperson innerhalb der Frist (V2: versendet|bestaetigt → widerrufen), unter derselben Sperre. + * Verschickt selbst keine Mails (das tut abo.js nach der Rückkehr, also nach der Freigabe der Sperre). + * Ergebnis: 'widerrufen' | 'bereits_widerrufen' | 'kein_widerrufsrecht' | 'nach_frist' | 'nicht_widerrufbar'. + */ +export async function widerrufeAuftrag(auftrag, { jetzt = new Date() } = {}) { + pruefeZeitpunkt(jetzt) + return withLocks(sperreAuftrag(auftrag.id), async () => { + const frisch = await getAuftrag(auftrag.id) + if (!frisch) throw new AuftragError(404, NICHT_GEFUNDEN) + const fristEnde = frisch.gesendetAm ? withdrawalDeadline(new Date(frisch.gesendetAm)).endsAt : null + const ergebnis = (e, a = frisch) => ({ auftrag: a, ergebnis: e, fristEnde }) + if (frisch.status === 'widerrufen') return ergebnis('bereits_widerrufen') + if (frisch.kunde?.kundenart !== 'privat') return ergebnis('kein_widerrufsrecht') + if (!['versendet', 'bestaetigt'].includes(frisch.status) || fristEnde === null) return ergebnis('nicht_widerrufbar') + if (jetzt.getTime() > fristEnde.getTime()) return ergebnis('nach_frist') + return ergebnis('widerrufen', await updateAuftrag(frisch.id, { status: 'widerrufen', widerrufenAm: jetzt.toISOString() })) + }) +} diff --git a/server/services/auftraege/mails.js b/server/services/auftraege/mails.js new file mode 100644 index 0000000..c0247bf --- /dev/null +++ b/server/services/auftraege/mails.js @@ -0,0 +1,161 @@ +/** + * Mails zur Auftragsbestätigung (Übersicht V4/V6, Strang L5): K31 (AB mit PDF und AGB an den Kunden, Teamkopie ohne + * Link), K32 (Eingangsbestätigung an den Kunden) und T22 (Hinweis ans Team). Der Bestätigungslink steht nur in der + * Mail an den Kunden: K31 geht ohne BCC raus, das Team bekommt eine Kopie ohne Link, Token und Anhänge. + * Kein Datenbankzugriff (außer lesePdf als Rückfall für die AB-PDF). + */ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { config } from '../../config.js' +import { berlinDate, formatDe } from '../finance/dates.js' +import { euroDe } from '../finance/format.js' +import { sendAdminNotice, sendCustomerMail } from '../mailer.js' +import { lesePdf } from './ablage.js' +import { fruehesterBeginn } from './auftragSicht.js' +import { AGB_FASSUNG } from './konstanten.js' +import { FRUEHBEGINN_TEXT } from './texte.js' +import { hashToken } from './token.js' + +// server/services/auftraege/ -> drei Ebenen nach oben = Repo +const STANDARD_AGB_VERZEICHNIS = fileURLToPath(new URL('../../../public/rechtliches/', import.meta.url)) +let agbVerzeichnis = STANDARD_AGB_VERZEICHNIS +const FASSUNG_FORMAT = /^[A-Za-z0-9._-]{1,20}$/ + +/** Nur für Tests: anderes Verzeichnis für die AGB-PDFs (`null` = Vorgabe). */ +export function setzeAgbVerzeichnisFuerTests(verzeichnis) { + agbVerzeichnis = verzeichnis || STANDARD_AGB_VERZEICHNIS +} + +export function agbPdfPfad(fassung = AGB_FASSUNG) { + if (typeof fassung !== 'string' || !FASSUNG_FORMAT.test(fassung) || fassung.includes('..')) throw new TypeError('agbPdfPfad: ungültige AGB-Fassung') + return path.join(agbVerzeichnis, `agb-${fassung}.pdf`) +} + +const zeitFormat = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) + +function zeitpunkt(iso) { + const datum = new Date(iso) + if (!iso || Number.isNaN(datum.getTime())) throw new TypeError('Zeitpunkt fehlt oder ist ungültig') + return datum +} +const tagText = (iso) => formatDe(berlinDate(zeitpunkt(iso))) +const zeitpunktText = (iso) => `${tagText(iso)}, ${zeitFormat.format(zeitpunkt(iso))}` +const preisText = (auftrag) => (auftrag.art === 'aufwand' ? `${euroDe(auftrag.stundensatzCents)} pro Stunde` : euroDe(auftrag.summeCents)) +const empfaenger = (auftrag) => String(auftrag.gesendetAn || auftrag.kunde?.email || '').trim() + +export function k31Mail(auftrag, { token, erinnerung = false, teamkopie = false } = {}) { + const kunde = auftrag.kunde ?? {} + return { + template: 'K31', + values: { + name: kunde.name, + nummer: auftrag.nummer, + titel: auftrag.titel, + datum: tagText(auftrag.gesendetAm), + betrag: preisText(auftrag), + // die Teamkopie enthält den Link nie, auch wenn ein Token mitgegeben wird + link: teamkopie ? '' : `${config.publicBaseUrl}/auftrag.html#t=${token}`, + gueltig_bis: tagText(auftrag.tokenGueltigBis), + }, + blocks: { + erinnerung: Boolean(erinnerung), + privat: kunde.kundenart === 'privat' && !teamkopie, + festpreis: auftrag.art === 'festpreis', + aufwand: auftrag.art === 'aufwand', + teamkopie: Boolean(teamkopie), + kundenmail: !teamkopie, + }, + } +} + +function frueherBeginn(auftrag) { + const privat = auftrag.kunde?.kundenart === 'privat' + const tag = fruehesterBeginn(auftrag) + return { privat, fruehbeginn: privat && Boolean(auftrag.fruehbeginn), ohne: privat && !auftrag.fruehbeginn, beginnText: tag ? formatDe(tag) : '' } +} + +export function k32Mail(auftrag) { + const kunde = auftrag.kunde ?? {} + const f = frueherBeginn(auftrag) + return { + template: 'K32', + values: { + name: kunde.name, + nummer: auftrag.nummer, + titel: auftrag.titel, + zeitpunkt: zeitpunktText(auftrag.bestaetigtAm), + fruehester_beginn: f.beginnText, + fruehbeginn_text: f.fruehbeginn ? FRUEHBEGINN_TEXT : '', + }, + blocks: { fruehbeginn: f.fruehbeginn, ohne_fruehbeginn: f.ohne }, + } +} + +const UEBER = { link: 'Bestätigungslink', portal: 'Kundenportal' } + +export function t22Mail(auftrag) { + const kunde = auftrag.kunde ?? {} + const f = frueherBeginn(auftrag) + return { + template: 'T22', + values: { + kunde: kunde.firma || kunde.name || kunde.email || '', + nummer: auftrag.nummer, + titel: auftrag.titel, + zeitpunkt: zeitpunktText(auftrag.bestaetigtAm), + ueber: UEBER[auftrag.bestaetigtUeber] ?? '', + woms_link: `${config.womsBaseUrl}/customers/${auftrag.kundenId}?tab=invoices`, + fruehester_beginn: f.beginnText, + }, + blocks: { privat_fruehbeginn: f.fruehbeginn, privat_ohne_fruehbeginn: f.ohne }, + } +} + +/** + * K31 an den Kunden (ohne BCC, mit AB-PDF und AGB), danach die Teamkopie ohne Link und Anhänge. Wirft bei jedem Fehler + * der Kundenmail (L3 setzt dann mailStatus „fehler“); eine gescheiterte Teamkopie gilt nicht als Fehler. + */ +export async function sendeAuftragMail(auftrag, { pdf, token, erinnerung = false } = {}) { + const adresse = empfaenger(auftrag) + if (!adresse) throw new Error('Keine E-Mail-Adresse für die Auftragsbestätigung') + if (hashToken(token) === null) throw new TypeError('sendeAuftragMail: token fehlt oder hat ein falsches Format') + const abPdf = Buffer.isBuffer(pdf) ? pdf : await lesePdf(auftrag.pdfDatei) + const fassung = auftrag.agbFassung || AGB_FASSUNG + let agbPdf + try { + agbPdf = await readFile(agbPdfPfad(fassung)) + } catch (err) { + throw new Error(`AGB-PDF ${fassung} nicht lesbar: ${err.code || err.message}`) + } + await sendCustomerMail(adresse, { + ...k31Mail(auftrag, { token, erinnerung }), + attachments: [ + { filename: `${auftrag.nummer}.pdf`, content: abPdf, contentType: 'application/pdf' }, + { filename: `WEBklar-AGB-${fassung}.pdf`, content: agbPdf, contentType: 'application/pdf' }, + ], + bccTeam: false, + }) + const kopie = await sendAdminNotice(k31Mail(auftrag, { erinnerung, teamkopie: true })) + if (!kopie.sent) console.error(`[auftraege] Teamkopie der Auftragsbestätigung ${auftrag.nummer} nicht gesendet: ${kopie.reason}`) +} + +/** K32 an den Kunden (Kopie ans Team wie üblich: die Mail trägt keinen Link), danach T22 ans Team. Wirft nie. */ +export async function sendeBestaetigtMails(auftrag) { + const adresse = empfaenger(auftrag) + if (!adresse) { + console.error(`[auftraege] K32 für ${auftrag.nummer} entfällt: keine E-Mail-Adresse`) + } else { + try { + await sendCustomerMail(adresse, k32Mail(auftrag)) + } catch (err) { + console.error(`[auftraege] K32 für ${auftrag.nummer} fehlgeschlagen: ${err.message}`) + } + } + try { + const t22 = await sendAdminNotice(t22Mail(auftrag)) + if (!t22.sent) console.error(`[auftraege] T22 für ${auftrag.nummer} nicht gesendet: ${t22.reason}`) + } catch (err) { + console.error(`[auftraege] T22 für ${auftrag.nummer} fehlgeschlagen: ${err.message}`) + } +} diff --git a/server/services/auftraege/token.js b/server/services/auftraege/token.js new file mode 100644 index 0000000..aec22e4 --- /dev/null +++ b/server/services/auftraege/token.js @@ -0,0 +1,23 @@ +/** + * Bestätigungs-Token einer Auftragsbestätigung (Übersicht V4 „L5“): 32 Zufallsbytes als base64url (43 Zeichen), + * gespeichert wird nur der SHA-256-Hash in Hex. Gültig 60 Tage ab dem letzten Versand. + * Importiert bewusst nur node:crypto und konstanten.js (nie server/config.js). + */ +import { createHash, randomBytes } from 'node:crypto' +import { TOKEN_GUELTIG_TAGE } from './konstanten.js' + +const TAG_MS = 86_400_000 +const TOKEN_FORMAT = /^[A-Za-z0-9_-]{43}$/ + +const sha256Hex = (text) => createHash('sha256').update(text).digest('hex') + +export function neuerToken(jetzt = new Date()) { + if (!(jetzt instanceof Date) || !Number.isFinite(jetzt.getTime())) throw new TypeError('neuerToken: jetzt muss ein gültiges Date sein') + const token = randomBytes(32).toString('base64url') + return { token, hash: sha256Hex(token), gueltigBis: new Date(jetzt.getTime() + TOKEN_GUELTIG_TAGE * TAG_MS) } +} + +/** Hash eines Tokens aus einem Link; `null`, wenn der Text nicht das Format eines Tokens hat. */ +export function hashToken(token) { + return typeof token === 'string' && TOKEN_FORMAT.test(token) ? sha256Hex(token) : null +} diff --git a/test/auftraegeBestaetigung.test.js b/test/auftraegeBestaetigung.test.js new file mode 100644 index 0000000..778753f --- /dev/null +++ b/test/auftraegeBestaetigung.test.js @@ -0,0 +1,259 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { mailSink } from './support/mailSink.js' +import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' + +// bestaetigung.js: Bestätigen (idempotent, unter Sperre) und Widerrufen einer AB; Mails über die Senke +const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js') +const { AuftragError } = await import('../server/services/auftraege/fehler.js') +const { FRUEHBEGINN_FASSUNG, FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js') +const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js') +const { withLocks } = await import('../server/services/keyedLock.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { bestaetigeAuftrag, bestaetigungsLage, widerrufeAuftrag } = await import('../server/services/auftraege/bestaetigung.js') +after(() => { + setMailTransportForTests(null) + return aw.close() +}) + +const JETZT = new Date('2026-10-07T08:30:00.000Z') // 10:30 Uhr in Berlin (MESZ) +const ms = (iso) => new Date(iso).getTime() +let sink +let protokoll +beforeEach(() => { + sink = mailSink() + setMailTransportForTests(sink) + protokoll = mock.method(console, 'error', () => {}) +}) +afterEach(() => mock.restoreAll()) +const seed = (over = {}) => { + const ab = abDokument(over) + aw.seed('auftraege', { $id: ab.id, ...auftragToDoc(ab) }) + return ab +} +const betreffe = () => sink.sent.map((mail) => mail.subject) +const K32 = 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen' +const T22 = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster' +const norm = (text) => text.replace(/\s+/g, ' ').trim() + +test('bestaetigungsLage: offen, bestaetigt oder gesperrt', () => { + const lage = (over) => bestaetigungsLage(abDokument(over)) + assert.equal(lage({}), 'offen') + assert.equal(lage({ status: 'abgerechnet' }), 'offen') + assert.equal(lage({ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt') + assert.equal(lage({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt') + for (const status of ['entwurf', 'ersetzt', 'storniert', 'widerrufen']) assert.equal(lage({ status, bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'gesperrt', status) +}) + +test('bestaetigeAuftrag: Privatperson über den Link mit Frühbeginn – Protokoll am Dokument, K32 und T22', async () => { + const ab = seed() + const neu = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT }) + assert.equal(neu.status, 'bestaetigt') + assert.equal(ms(neu.bestaetigtAm), JETZT.getTime()) + assert.equal(neu.bestaetigtUeber, 'link') + assert.equal(neu.bestaetigtPdfSha256, ab.pdfSha256) + assert.equal(neu.fruehbeginn, true) + assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG) + const doc = await getAuftrag('ab1') + assert.deepEqual([doc.status, doc.bestaetigtUeber, doc.fruehbeginn, ms(doc.bestaetigtAm)], ['bestaetigt', 'link', true, JETZT.getTime()]) + assert.deepEqual(betreffe(), [K32, T22]) + assert.ok(norm(sink.sent[0].text).includes(norm(FRUEHBEGINN_TEXT)), 'K32 gibt die Erklärung wörtlich wieder') + assert.ok(sink.sent[1].text.includes(`${config.womsBaseUrl}/customers/c1?tab=invoices`)) +}) + +test('bestaetigeAuftrag: Privatperson über das Portal ohne Frühbeginn – frühester Beginn nach der Widerrufsfrist', async () => { + const neu = await bestaetigeAuftrag(seed(), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT }) + assert.equal(neu.bestaetigtUeber, 'portal') + assert.equal(neu.fruehbeginn, false) + assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG, 'die angezeigte Fassung der Erklärung wird auch ohne Haken festgehalten') + assert.ok(sink.sent[0].text.includes('Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.')) + assert.ok(sink.sent[1].text.includes('Arbeitsbeginn frühestens am 21.10.2026')) + assert.ok(sink.sent[1].text.includes('Weg: Kundenportal')) +}) + +test('bestaetigeAuftrag: Unternehmen – fruehbeginn wird ignoriert, keine Beginn-Hinweise in K32 und T22', async () => { + const neu = await bestaetigeAuftrag(seed({ kunde: kunde('unternehmen') }), { ueber: 'link', fruehbeginn: true, jetzt: JETZT }) + assert.equal(neu.fruehbeginn, false) + assert.equal(neu.fruehbeginnFassung, '') + for (const mail of sink.sent) assert.doesNotMatch(mail.text, /Beginn der Arbeiten|frühestens|Erklärung zum früheren Beginn|Die Arbeit darf sofort/) + assert.equal(sink.sent[1].subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH') +}) + +test('bestaetigeAuftrag: abgerechnete AB ohne frühere Bestätigung – Status bleibt abgerechnet', async () => { + const neu = await bestaetigeAuftrag(seed({ status: 'abgerechnet' }), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT }) + assert.equal(neu.status, 'abgerechnet') + assert.equal(ms(neu.bestaetigtAm), JETZT.getTime()) + assert.equal((await getAuftrag('ab1')).status, 'abgerechnet') + assert.equal(sink.sent.length, 2) +}) + +test('bestaetigeAuftrag: die zweite Bestätigung ändert nichts und verschickt nichts (die erste zählt, auch bei veraltetem Eingabeobjekt)', async () => { + const ab = seed() + const erste = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT }) + const zweite = await bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 3_600_000) }) + assert.equal(ms(zweite.bestaetigtAm), ms(erste.bestaetigtAm)) + assert.equal(zweite.bestaetigtUeber, 'link') + assert.equal(zweite.fruehbeginn, true) + assert.equal(sink.sent.length, 2, 'nur die Mails der ersten Bestätigung') +}) + +test('bestaetigeAuftrag: zwei gleichzeitige Bestätigungen (Doppelklick, Link und Portal) – eine zählt, genau zwei Mails', async () => { + const ab = seed() + const [a, b] = await Promise.all([ + bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), + bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 1000) }), + ]) + assert.equal(ms(a.bestaetigtAm), ms(b.bestaetigtAm)) + assert.equal(a.bestaetigtUeber, 'link', 'der zuerst gestartete Aufruf gewinnt') + assert.equal(b.bestaetigtUeber, 'link') + assert.equal(sink.sent.length, 2) +}) + +test('bestaetigeAuftrag: läuft unter der Sperre sperreAuftrag(id); K32 und T22 gehen erst nach ihrer Freigabe raus', async () => { + const ab = seed() + let freigeben + const gehalten = new Promise((resolve) => { + freigeben = resolve + }) + const halter = withLocks(sperreAuftrag('ab1'), () => gehalten) + let fertig = false + const bestaetigt = bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }).then((neu) => { + fertig = true + return neu + }) + await new Promise((resolve) => setTimeout(resolve, 100)) + assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält') + assert.equal((await getAuftrag('ab1')).status, 'versendet') + freigeben() + await halter + assert.equal((await bestaetigt).status, 'bestaetigt') + // Mails erst nach der Freigabe: im Versand ist die Sperre der AB wieder frei (sonst käme das Rennen nach 300 ms mit false zurück) + seed() + sink.sent.length = 0 + const frei = [] + setMailTransportForTests({ + async sendMail(mail) { + frei.push(await Promise.race([withLocks(sperreAuftrag('ab1'), async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 300))])) + return sink.sendMail(mail) + }, + }) + await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }) + assert.deepEqual(frei, [true, true]) + assert.equal(sink.sent.length, 2) +}) + +test('bestaetigeAuftrag: Entwurf, ersetzt, storniert und widerrufen sind nicht bestätigbar (409), nichts ändert sich, keine Mail', async () => { + const faelle = [['entwurf', 'Diese Auftragsbestätigung kann nicht bestätigt werden.'], ['ersetzt', 'Diese Auftragsbestätigung wurde ersetzt.'], ['storniert', 'Diese Auftragsbestätigung wurde storniert.'], ['widerrufen', 'Diese Auftragsbestätigung wurde widerrufen.']] + for (const [status, meldung] of faelle) { + const ab = seed({ status }) + await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 409 && err.message === meldung, status) + const doc = await getAuftrag('ab1') + assert.deepEqual([doc.status, doc.bestaetigtAm], [status, null], status) + } + assert.equal(sink.sent.length, 0) +}) + +test('bestaetigeAuftrag: scheitert der Mailversand, bleibt die Bestätigung bestehen (Fehler nur im Protokoll)', async () => { + setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } }) + const neu = await bestaetigeAuftrag(seed(), { ueber: 'link', fruehbeginn: false, jetzt: JETZT }) + assert.equal(neu.status, 'bestaetigt') + assert.equal((await getAuftrag('ab1')).status, 'bestaetigt') + assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('AB-2026-0001')), 'Fehler mit Nummer im Protokoll') +}) + +test('bestaetigeAuftrag: ungültiger Weg oder Zeitpunkt wirft TypeError, unbekannte AB 404', async () => { + const ab = seed() + await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'telefon', fruehbeginn: false, jetzt: JETZT }), TypeError) + await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: 'gestern' }), TypeError) + await assert.rejects(bestaetigeAuftrag(abDokument({ id: 'gibtsnicht' }), { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404) + assert.equal(sink.sent.length, 0) +}) + +// gesendet am 06.10.2026 12:00 Berlin: die Frist endet am 20.10.2026 um 24 Uhr (= 21:59:59,999 UTC) +const FRIST_ENDE = new Date('2026-10-20T21:59:59.999Z') + +test('widerrufeAuftrag: Privatperson – am letzten Tag bis 24 Uhr (eingeschlossen) widerrufen, eine Millisekunde später nach_frist', async () => { + const ab = seed() + const ok = await widerrufeAuftrag(ab, { jetzt: FRIST_ENDE }) + assert.equal(ok.ergebnis, 'widerrufen') + assert.equal(ok.auftrag.status, 'widerrufen') + assert.equal(ms(ok.auftrag.widerrufenAm), FRIST_ENDE.getTime()) + assert.equal(ok.fristEnde.getTime(), FRIST_ENDE.getTime()) + assert.equal((await getAuftrag('ab1')).status, 'widerrufen') + seed() + const spaet = await widerrufeAuftrag(ab, { jetzt: new Date(FRIST_ENDE.getTime() + 1) }) + assert.equal(spaet.ergebnis, 'nach_frist') + assert.equal(spaet.auftrag.status, 'versendet') + assert.equal((await getAuftrag('ab1')).widerrufenAm, null) +}) + +test('widerrufeAuftrag: bestätigte AB wird widerrufen, bestaetigtAm bleibt als Beleg; Unternehmen: kein_widerrufsrecht', async () => { + const bestaetigt = seed({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', fruehbeginn: true }) + const r = await widerrufeAuftrag(bestaetigt, { jetzt: JETZT }) + assert.equal(r.ergebnis, 'widerrufen') + assert.equal(r.auftrag.fruehbeginn, true) + assert.equal(ms((await getAuftrag('ab1')).bestaetigtAm), JETZT.getTime()) + const firma = seed({ kunde: kunde('unternehmen') }) + const k = await widerrufeAuftrag(firma, { jetzt: JETZT }) + assert.equal(k.ergebnis, 'kein_widerrufsrecht') + assert.equal((await getAuftrag('ab1')).status, 'versendet') +}) + +test('widerrufeAuftrag: bereits widerrufen, abgerechnet, ersetzt, storniert, Entwurf – nichts ändert sich', async () => { + const faelle = [['widerrufen', 'bereits_widerrufen'], ['abgerechnet', 'nicht_widerrufbar'], ['ersetzt', 'nicht_widerrufbar'], ['storniert', 'nicht_widerrufbar'], ['entwurf', 'nicht_widerrufbar']] + for (const [status, ergebnis] of faelle) { + const ab = seed({ status, widerrufenAm: status === 'widerrufen' ? '2026-10-08T08:00:00.000Z' : null }) + const r = await widerrufeAuftrag(ab, { jetzt: JETZT }) + assert.equal(r.ergebnis, ergebnis, status) + const doc = await getAuftrag('ab1') + assert.equal(doc.status, status, status) + assert.equal(doc.widerrufenAm === null ? null : ms(doc.widerrufenAm), status === 'widerrufen' ? new Date('2026-10-08T08:00:00.000Z').getTime() : null, status) + } +}) + +test('widerrufeAuftrag: wartet auf die Sperre sperreAuftrag(id) und liest den Stand erst darin (veraltetes Eingabeobjekt)', async () => { + const veraltet = seed() + let freigeben + const gehalten = new Promise((resolve) => { + freigeben = resolve + }) + const halter = withLocks(sperreAuftrag('ab1'), () => gehalten) + let fertig = false + const widerruf = widerrufeAuftrag(veraltet, { jetzt: JETZT }).then((r) => { + fertig = true + return r + }) + await new Promise((resolve) => setTimeout(resolve, 100)) + assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält') + seed({ status: 'storniert' }) // währenddessen von Hand storniert (L3) + freigeben() + await halter + const r = await widerruf + assert.equal(r.ergebnis, 'nicht_widerrufbar', 'maßgeblich ist der Stand innerhalb der Sperre') + assert.equal((await getAuftrag('ab1')).status, 'storniert') + assert.equal(sink.sent.length, 0, 'widerrufeAuftrag verschickt keine Mails') +}) + +test('Bestätigen und Widerrufen gleichzeitig: der Widerruf geht nie verloren, in jeder Reihenfolge', async () => { + const ab = seed() + const [bestaetigt, widerrufen] = await Promise.all([ + bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), + widerrufeAuftrag(ab, { jetzt: JETZT }), + ]) + assert.equal(bestaetigt.status, 'bestaetigt') + assert.equal(widerrufen.ergebnis, 'widerrufen') + assert.equal((await getAuftrag('ab1')).status, 'widerrufen') + seed() + const umgekehrt = await Promise.allSettled([ + widerrufeAuftrag(ab, { jetzt: JETZT }), + bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), + ]) + assert.equal(umgekehrt[0].value.ergebnis, 'widerrufen') + assert.ok(umgekehrt[1].reason instanceof AuftragError && umgekehrt[1].reason.status === 409) + assert.equal((await getAuftrag('ab1')).status, 'widerrufen') +}) diff --git a/test/auftraegeBestaetigungHilfe.js b/test/auftraegeBestaetigungHilfe.js new file mode 100644 index 0000000..91919f3 --- /dev/null +++ b/test/auftraegeBestaetigungHilfe.js @@ -0,0 +1,44 @@ +import { AUFTRAEGE_COLLECTIONS } from '../server/services/auftraege/schema.js' +import { hashToken } from '../server/services/auftraege/token.js' + +// Testdaten der Strang-L5-Tests: gesendete AB, Token, Schema und Datumsfelder für den Appwrite-Nachbau. +// Importiert nie server/config.js: die Tests setzen die Umgebung erst danach (useTestEnv). +const attribute = AUFTRAEGE_COLLECTIONS.auftraege.attributes +export const AUFTRAEGE_SCHEMA = { auftraege: attribute.map((a) => a.key) } +export const AUFTRAEGE_DATETIMES = { auftraege: attribute.filter((a) => a.type === 'datetime').map((a) => a.key) } + +export const TOKEN = 'T'.repeat(43) +export const TOKEN_NEU = 'U'.repeat(43) +export const SHA = 'a'.repeat(64) +export const UNGUELTIG = 'Dieser Link ist ungültig oder abgelaufen.' + +export const kunde = (kundenart = 'privat', extra = {}) => ({ + name: 'Erika Muster', + firma: kundenart === 'unternehmen' ? 'Muster GmbH' : '', + strasse: 'Hauptstr. 1', + plz: '67722', + ort: 'Winnweiler', + land: 'DE', + email: 'k@example.invalid', + kundenart, + kundennummer: 'K-100', + ...extra, +}) + +/** Gesendete Festpreis-AB AB-2026-0001 des Kunden c1 (Form „Auftrag“ aus V3), gesendet am 06.10.2026 (Berlin 12:00), Link bis 05.12.2026. */ +export const abDokument = (over = {}) => ({ + id: 'ab1', nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1, status: 'versendet', + kundenId: 'c1', kunde: kunde(), projektId: 'p1', titel: 'Website für Musterbäckerei', zeitquelle: [], + art: 'festpreis', positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000 }], + stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, + zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' }, zahlungszielTage: 14, + auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '', + ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30', + pdfDatei: 'c1/ab1.pdf', pdfSha256: SHA, + gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'k@example.invalid', mailStatus: 'gesendet', mailFehler: '', + tokenHash: hashToken(TOKEN), tokenGueltigBis: '2026-12-05T10:00:00.000Z', + bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '', + storniertAm: null, widerrufenAm: null, + erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-06T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z', + ...over, +}) diff --git a/test/auftraegeKundeRoutes.test.js b/test/auftraegeKundeRoutes.test.js new file mode 100644 index 0000000..2b29d01 --- /dev/null +++ b/test/auftraegeKundeRoutes.test.js @@ -0,0 +1,143 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import os from 'node:os' +import path from 'node:path' +import { mkdtempSync } from 'node:fs' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { mailSink } from './support/mailSink.js' +import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' + +// Portal-Routen /api/auftraege (V5): nur eigene, gesendete ABs; Schalter; Bestätigen mit Portal-Sitzung +process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-kunde-')) +process.env.AUFTRAEGE_ENABLED = 'on' +const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { startTestApp, send } = await import('./support/testApp.js') +const { seedFixtures, customerCookie, adminCookie } = await import('./support/fixtures.js') +const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js') +const { speicherePdf } = await import('../server/services/auftraege/ablage.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { default: kundeRoutes } = await import('../server/routes/auftraegeKunde.js') +const app = await startTestApp({ '/api/auftraege': kundeRoutes }) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close()]) +}) +seedFixtures(aw) + +const PDF = '%PDF-1.4 Kunden-AB' +for (const [kundenId, id] of [['c1', 'ab1'], ['c2', 'ab3'], ['c1', 'ab4']]) await speicherePdf(kundenId, id, Buffer.from(PDF)) +const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.' +const ENTWURF = { id: 'ab2', nummer: '', jahr: null, laufnummer: null, status: 'entwurf', gesendetAm: null, gesendetAn: '', tokenHash: '', tokenGueltigBis: null, pdfDatei: '', pdfSha256: '' } +const AB4 = { id: 'ab4', nummer: 'AB-2026-0003', status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', pdfDatei: 'c1/ab4.pdf', tokenHash: '' } +const AB3 = { id: 'ab3', nummer: 'AB-2026-0002', kundenId: 'c2', pdfDatei: 'c2/ab3.pdf', kunde: kunde('privat', { email: 'k2@example.invalid' }), tokenHash: '' } + +let sink +const seed = (over = {}) => aw.seed('auftraege', { $id: over.id || 'ab1', ...auftragToDoc(abDokument(over)) }) +beforeEach(() => { + config.auftraege.mode = 'on' + config.auftraege.pilotEmails = [] + sink = mailSink() + setMailTransportForTests(sink) + for (const over of [{}, ENTWURF, AB3, AB4]) seed(over) +}) +const cookie = customerCookie('c1') +const get = (pfad, opts = { cookie }) => send('GET', `/api/auftraege${pfad}`, opts) +const post = (pfad, body, opts = { cookie }) => send('POST', `/api/auftraege${pfad}`, { ...opts, body }) + +test('GET /api/auftraege: nur eigene, nur gesendete ABs, mit id und kannBestaetigen, ohne interne Felder', async () => { + const res = await get('') + assert.equal(res.status, 200) + assert.equal(res.body.aktiv, true) + assert.deepEqual(res.body.auftraege.map((a) => a.id).sort(), ['ab1', 'ab4']) + const ab1 = res.body.auftraege.find((a) => a.id === 'ab1') + const ab4 = res.body.auftraege.find((a) => a.id === 'ab4') + assert.deepEqual([ab1.nummer, ab1.status, ab1.kannBestaetigen, ab4.status, ab4.kannBestaetigen], ['AB-2026-0001', 'versendet', true, 'bestaetigt', false]) + for (const intern of ['kundenId', 'kunde', 'tokenHash', 'tokenGueltigBis', 'pdfDatei', 'pdfSha256', 'gesendetAn']) assert.ok(!(intern in ab1), intern) + assert.equal(res.headers.get('cache-control'), 'no-store') +}) + +test('kannBestaetigen wie in V3: versendet und abgerechnet ohne frühere Bestätigung ja, sonst nein', async () => { + const faelle = [ + [{}, true], + [{ status: 'abgerechnet' }, true], + [{ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }, false], + [{ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }, false], + [{ status: 'ersetzt' }, false], + [{ status: 'storniert' }, false], + [{ status: 'widerrufen' }, false], + ] + for (const [over, erwartet] of faelle) { + seed(over) + const ab1 = (await get('')).body.auftraege.find((a) => a.id === 'ab1') + assert.ok(ab1, `${JSON.stringify(over)}: auch ersetzte, stornierte und widerrufene ABs stehen in der Liste`) + assert.equal(ab1.kannBestaetigen, erwartet, JSON.stringify(over)) + } +}) + +test('ohne Anmeldung und mit alter Mitarbeiter-Sitzung: 401, nichts ändert sich', async () => { + for (const opts of [{}, { cookie: adminCookie() }]) { + assert.equal((await get('', opts)).status, 401) + assert.equal((await get('/ab1/pdf', opts)).status, 401) + assert.equal((await post('/ab1/bestaetigen', { bestaetigt: true }, opts)).status, 401) + } + assert.equal((await getAuftrag('ab1')).bestaetigtAm, null) +}) + +test('Schalter aus: leere Liste, PDF und Bestätigen wie bei unbekannter AB; im Pilot nur für Adressen der Liste', async () => { + config.auftraege.mode = 'off' + assert.deepEqual((await get('')).body, { aktiv: false, auftraege: [] }) + for (const res of [await get('/ab1/pdf'), await post('/ab1/bestaetigen', { bestaetigt: true })]) assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }]) + assert.equal((await getAuftrag('ab1')).bestaetigtAm, null) + config.auftraege.mode = 'pilot' + config.auftraege.pilotEmails = ['k@example.invalid'] + assert.equal((await get('')).body.aktiv, true) + assert.deepEqual((await get('', { cookie: customerCookie('c2') })).body, { aktiv: false, auftraege: [] }) +}) + +test('GET /api/auftraege/:id/pdf: eigene AB (auch ersetzt), nie fremde, Entwürfe, unbekannte oder ungültige IDs', async () => { + const eigene = await get('/ab1/pdf') + assert.deepEqual([eigene.status, eigene.headers.get('content-type'), eigene.body], [200, 'application/pdf', PDF]) + assert.equal(eigene.headers.get('content-disposition'), 'inline; filename="AB-2026-0001.pdf"') + seed({ status: 'ersetzt' }) + assert.equal((await get('/ab1/pdf')).status, 200) + for (const id of ['ab3', 'ab2', 'gibtsnicht', '..%2F..%2Fx', 'a'.repeat(40)]) { + const res = await get(`/${id}/pdf`) + assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }], id) + } +}) + +test('POST …/bestaetigen: bestätigt über das Portal (Protokoll, K32, T22); der zweite Aufruf ändert nichts', async () => { + const vorher = Date.now() + const res = await post('/ab1/bestaetigen', { bestaetigt: true, fruehbeginn: true }) + assert.equal(res.status, 200) + assert.deepEqual([res.body.auftrag.id, res.body.auftrag.status, res.body.auftrag.kannBestaetigen], ['ab1', 'bestaetigt', false]) + const doc = await getAuftrag('ab1') + assert.deepEqual([doc.bestaetigtUeber, doc.fruehbeginn], ['portal', true]) + assert.ok(new Date(doc.bestaetigtAm).getTime() >= vorher - 1000 && new Date(doc.bestaetigtAm).getTime() <= Date.now() + 1000) + assert.equal(sink.sent.length, 2) + const zweite = await post('/ab1/bestaetigen', { bestaetigt: true, fruehbeginn: false }) + assert.deepEqual([zweite.status, zweite.body], [200, res.body]) + assert.equal(sink.sent.length, 2) +}) + +test('POST …/bestaetigen: ohne bestaetigt: true 400; fremde, Entwurf, unbekannte AB 404; ersetzt, storniert, widerrufen 410 – nie eine Änderung', async () => { + for (const body of [{}, { bestaetigt: 'ja' }, { bestaetigt: false }]) { + const res = await post('/ab1/bestaetigen', body) + assert.deepEqual([res.status, res.body], [400, { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }]) + } + for (const id of ['ab3', 'ab2', 'gibtsnicht']) { + const res = await post(`/${id}/bestaetigen`, { bestaetigt: true }) + assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }], id) + } + for (const [status, text] of [['ersetzt', 'ersetzt'], ['storniert', 'storniert'], ['widerrufen', 'widerrufen']]) { + seed({ status }) + const res = await post('/ab1/bestaetigen', { bestaetigt: true }) + assert.deepEqual([res.status, res.body], [410, { error: `Diese Auftragsbestätigung wurde ${text}.` }], status) + assert.equal((await getAuftrag('ab1')).bestaetigtAm, null, status) + } + assert.equal((await getAuftrag('ab3')).bestaetigtAm, null) + assert.equal(sink.sent.length, 0) +}) diff --git a/test/auftraegeKundenansicht.test.js b/test/auftraegeKundenansicht.test.js new file mode 100644 index 0000000..befcf90 --- /dev/null +++ b/test/auftraegeKundenansicht.test.js @@ -0,0 +1,89 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import os from 'node:os' +import path from 'node:path' +import { mkdtempSync } from 'node:fs' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { mailSink } from './support/mailSink.js' +import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument } from './auftraegeBestaetigungHilfe.js' + +// Kundenansicht (Mitarbeiter im Portal eines Kunden): Bestätigen ist eine Erklärung im Namen des Kunden und bleibt gesperrt +process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-kundenansicht-')) +process.env.AUFTRAEGE_ENABLED = 'on' +const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { startTestApp, send } = await import('./support/testApp.js') +const { seedFixtures, customerCookie, kundenansichtCookie } = await import('./support/fixtures.js') +const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js') +const { speicherePdf } = await import('../server/services/auftraege/ablage.js') +const { kundenansichtGuard, KUNDENANSICHT_ALLOWED } = await import('../server/middleware/kundenansichtGuard.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { default: kundeRoutes } = await import('../server/routes/auftraegeKunde.js') +const { default: tokenRoutes } = await import('../server/routes/auftrag.js') +// Mount-Pfade und Reihenfolge wie in server/index.js: der Wächter steht vor den Routen +const app = await startTestApp({ '/': kundenansichtGuard, '/api/auftraege': kundeRoutes, '/api/auftrag': tokenRoutes }) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close()]) +}) +seedFixtures(aw) +await speicherePdf('c1', 'ab1', Buffer.from('%PDF-1.4 Test')) + +const VIEW = 'a'.repeat(32) +const kv = () => ({ cookie: kundenansichtCookie({ customerId: 'c1', viewId: VIEW }), headers: { 'x-kundenansicht': VIEW } }) +let sink +beforeEach(() => { + sink = mailSink() + setMailTransportForTests(sink) + aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument()) }) +}) +const unveraendert = async () => { + const doc = await getAuftrag('ab1') + assert.deepEqual([doc.status, doc.bestaetigtAm, sink.sent.length], ['versendet', null, 0]) +} + +test('kein Eintrag der Erlaubnisliste gilt für Aufträge', () => { + for (const pfad of ['/api/auftraege', '/api/auftraege/ab1/bestaetigen', '/api/auftraege/ab1/pdf', `/api/auftrag/${TOKEN}`, `/api/auftrag/${TOKEN}/bestaetigen`]) { + assert.ok(!KUNDENANSICHT_ALLOWED.some(([, muster]) => muster.test(pfad)), pfad) + } +}) + +test('POST /api/auftraege/:id/bestaetigen in der Kundenansicht: 403 view_blocked, die AB bleibt unverändert', async () => { + const res = await send('POST', '/api/auftraege/ab1/bestaetigen', { ...kv(), body: { bestaetigt: true, fruehbeginn: true } }) + assert.equal(res.status, 403) + assert.equal(res.body.code, 'view_blocked') + await unveraendert() +}) + +test('alle Schreib-Routen des Kunden-Routers sind in der Kundenansicht gesperrt (nur Bestätigen ist eine)', async () => { + const schreibRouten = kundeRoutes.stack + .filter((schicht) => schicht.route) + .flatMap((schicht) => Object.keys(schicht.route.methods).filter((m) => !['get', 'head', 'options', '_all'].includes(m)).map((m) => [m.toUpperCase(), `/api/auftraege${String(schicht.route.path).replace(/:[A-Za-z]+/g, 'ab1')}`])) + assert.deepEqual(schreibRouten, [['POST', '/api/auftraege/ab1/bestaetigen']]) + for (const [methode, pfad] of schreibRouten) { + const res = await send(methode, pfad, { ...kv(), body: { bestaetigt: true } }) + assert.equal(res.status, 403, `${methode} ${pfad}`) + } + await unveraendert() +}) + +test('auch der öffentliche Link bestätigt nichts, wenn der Browser eine Kundenansicht-Sitzung trägt', async () => { + const mitKennung = await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { ...kv(), body: { bestaetigt: true } }) + assert.deepEqual([mitKennung.status, mitKennung.body.code], [403, 'view_blocked']) + const ohneKennung = await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { cookie: kv().cookie, body: { bestaetigt: true } }) + assert.deepEqual([ohneKennung.status, ohneKennung.body.code], [409, 'view_replaced']) + await unveraendert() +}) + +test('Lesen bleibt erlaubt, kannBestaetigen ist in der Kundenansicht false; der Kunde selbst darf bestätigen', async () => { + const liste = await send('GET', '/api/auftraege', kv()) + assert.equal(liste.status, 200) + assert.deepEqual(liste.body.auftraege.map((a) => [a.id, a.kannBestaetigen]), [['ab1', false]]) + assert.equal((await send('GET', '/api/auftraege/ab1/pdf', kv())).status, 200) + const selbst = await send('GET', '/api/auftraege', { cookie: customerCookie('c1') }) + assert.deepEqual(selbst.body.auftraege.map((a) => [a.id, a.kannBestaetigen]), [['ab1', true]]) + const res = await send('POST', '/api/auftraege/ab1/bestaetigen', { cookie: customerCookie('c1'), body: { bestaetigt: true } }) + assert.equal(res.status, 200) + assert.equal((await getAuftrag('ab1')).status, 'bestaetigt') +}) diff --git a/test/auftraegeMails.test.js b/test/auftraegeMails.test.js new file mode 100644 index 0000000..d1131ed --- /dev/null +++ b/test/auftraegeMails.test.js @@ -0,0 +1,204 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { useTestEnv } from './support/testEnv.js' +import { mailSink } from './support/mailSink.js' +import { TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' + +// mails.js: K31 (mit Teamkopie), K32, T22 aufbauen und über die Senke verschicken. Kein Datenbankzugriff: jede versehentliche Anfrage scheitert sofort. +process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-mails-')) +useTestEnv({ appwriteUrl: 'http://127.0.0.1:1/v1', stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js') +const { euroDe } = await import('../server/services/finance/format.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { AGB_FASSUNG } = await import('../server/services/auftraege/konstanten.js') +const { FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js') +const { speicherePdf } = await import('../server/services/auftraege/ablage.js') +const { agbPdfPfad, k31Mail, k32Mail, sendeAuftragMail, sendeBestaetigtMails, setzeAgbVerzeichnisFuerTests, t22Mail } = await import('../server/services/auftraege/mails.js') + +const agbVerzeichnis = mkdtempSync(path.join(os.tmpdir(), 'auftraege-agb-')) +const AB_PDF = Buffer.from('%PDF-1.4 AB-Test') +const AGB_PDF = Buffer.from('%PDF-1.4 AGB-Test') +writeFileSync(path.join(agbVerzeichnis, 'agb-2026-09-30.pdf'), AGB_PDF) +setzeAgbVerzeichnisFuerTests(agbVerzeichnis) +after(() => { + setzeAgbVerzeichnisFuerTests(null) + setMailTransportForTests(null) +}) + +let sink +let protokoll +beforeEach(() => { + sink = mailSink() + setMailTransportForTests(sink) + protokoll = mock.method(console, 'error', () => {}) +}) +afterEach(() => mock.restoreAll()) +const adressen = (liste) => liste.map((a) => a.address) + +const AB = abDokument() +const AUFWAND = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, kunde: kunde('unternehmen') } +const BESTAETIGT = (over = {}) => abDokument({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', ...over }) +const K31_BETREFF = 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei' +const T22_BETREFF = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster' +const WERTE_K31 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', datum: '06.10.2026', betrag: euroDe(80000), gueltig_bis: '05.12.2026' } + +test('agbPdfPfad: Vorgabe ist public/rechtliches/agb-.pdf; eine ungültige Fassung wirft', () => { + setzeAgbVerzeichnisFuerTests(null) + try { + const rechtliches = fileURLToPath(new URL('../public/rechtliches/', import.meta.url)) + assert.equal(agbPdfPfad(), path.join(rechtliches, `agb-${AGB_FASSUNG}.pdf`)) + assert.equal(agbPdfPfad('2025-01-01'), path.join(rechtliches, 'agb-2025-01-01.pdf')) + for (const fassung of ['', '../x', 'a/b', 'x'.repeat(21)]) assert.throws(() => agbPdfPfad(fassung), TypeError, fassung) + } finally { + setzeAgbVerzeichnisFuerTests(agbVerzeichnis) + } +}) + +test('k31Mail: Link im Fragment; als Teamkopie ohne Link und ohne Kundenblöcke; Aufwand als Erinnerung (Berliner Tag des Sendens)', () => { + assert.deepEqual(k31Mail(AB, { token: TOKEN }), { + template: 'K31', + values: { ...WERTE_K31, link: `${config.publicBaseUrl}/auftrag.html#t=${TOKEN}` }, + blocks: { erinnerung: false, privat: true, festpreis: true, aufwand: false, teamkopie: false, kundenmail: true }, + }) + // auch wenn man das Token mitgibt: die Teamkopie enthält es nie + assert.deepEqual(k31Mail(AB, { token: TOKEN, teamkopie: true }), { + template: 'K31', + values: { ...WERTE_K31, link: '' }, + blocks: { erinnerung: false, privat: false, festpreis: true, aufwand: false, teamkopie: true, kundenmail: false }, + }) + const nachts = k31Mail(abDokument({ ...AUFWAND, gesendetAm: '2026-10-06T22:30:00.000Z' }), { token: TOKEN, erinnerung: true }) + assert.equal(nachts.values.datum, '07.10.2026') + assert.equal(nachts.values.betrag, `${euroDe(10000)} pro Stunde`) + assert.deepEqual(nachts.blocks, { erinnerung: true, privat: false, festpreis: false, aufwand: true, teamkopie: false, kundenmail: true }) +}) + +test('k32Mail und t22Mail: Zeitpunkt in Berliner Zeit, Frühbeginn-Erklärung wörtlich, Blöcke je Kundenart und Wunsch', () => { + const ja = BESTAETIGT({ fruehbeginn: true }) + assert.deepEqual(k32Mail(ja), { + template: 'K32', + values: { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', fruehester_beginn: '', fruehbeginn_text: FRUEHBEGINN_TEXT }, + blocks: { fruehbeginn: true, ohne_fruehbeginn: false }, + }) + const nein = BESTAETIGT({ bestaetigtUeber: 'portal' }) + assert.equal(k32Mail(nein).values.fruehester_beginn, '21.10.2026') + assert.equal(k32Mail(nein).values.fruehbeginn_text, '') + assert.deepEqual(k32Mail(nein).blocks, { fruehbeginn: false, ohne_fruehbeginn: true }) + const firma = BESTAETIGT({ kunde: kunde('unternehmen') }) + assert.deepEqual(k32Mail(firma).blocks, { fruehbeginn: false, ohne_fruehbeginn: false }) + assert.deepEqual([k32Mail(firma).values.fruehester_beginn, k32Mail(firma).values.fruehbeginn_text], ['', '']) + assert.deepEqual(t22Mail(nein), { + template: 'T22', + values: { kunde: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', ueber: 'Kundenportal', woms_link: `${config.womsBaseUrl}/customers/c1?tab=invoices`, fruehester_beginn: '21.10.2026' }, + blocks: { privat_fruehbeginn: false, privat_ohne_fruehbeginn: true }, + }) + assert.equal(t22Mail(firma).values.kunde, 'Muster GmbH') + assert.equal(t22Mail(ja).values.ueber, 'Bestätigungslink') + assert.deepEqual(t22Mail(ja).blocks, { privat_fruehbeginn: true, privat_ohne_fruehbeginn: false }) +}) + +test('Builder und Vorlagen passen zusammen: alle Platzhalter und Blöcke belegt, keine Unstimmigkeiten beim Rendern', () => { + const nachrichten = [ + k31Mail(AB, { token: TOKEN }), + k31Mail(AB, { token: TOKEN, teamkopie: true }), + k31Mail(abDokument(AUFWAND), { token: TOKEN, erinnerung: true }), + k31Mail(abDokument(AUFWAND), { erinnerung: true, teamkopie: true }), + k32Mail(BESTAETIGT({ fruehbeginn: true })), + k32Mail(BESTAETIGT()), + t22Mail(BESTAETIGT({ fruehbeginn: true })), + t22Mail(BESTAETIGT({ kunde: kunde('unternehmen') })), + ] + for (const { template, values, blocks } of nachrichten) { + const vorlage = loadTemplate(template) + assert.deepEqual(Object.keys(values).sort(), [...vorlage.placeholders].sort(), template) + assert.deepEqual(Object.keys(blocks).sort(), [...vorlage.blocks].sort(), template) + assert.deepEqual(renderMail(template, values, blocks).problems, [], template) + } +}) + +test('sendeAuftragMail: K31 an den Kunden ohne BCC (Link im Fragment, AB-PDF und AGB), danach die Teamkopie ohne Link, Token und Anhänge', async () => { + await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN }) + assert.equal(sink.sent.length, 2) + const [kundenmail, teamkopie] = sink.sent + assert.deepEqual(adressen(kundenmail.to), ['k@example.invalid']) + assert.ok(!kundenmail.bcc || kundenmail.bcc.length === 0, 'kein BCC ans Team: die Mail trägt den Bestätigungslink') + assert.equal(kundenmail.subject, K31_BETREFF) + assert.ok(kundenmail.text.includes(`Auftrag bestätigen: ${config.publicBaseUrl}/auftrag.html#t=${TOKEN}`)) + assert.deepEqual(kundenmail.attachments.map((a) => [a.filename, a.contentType]), [['AB-2026-0001.pdf', 'application/pdf'], ['WEBklar-AGB-2026-09-30.pdf', 'application/pdf']]) + assert.ok(Buffer.from(kundenmail.attachments[0].content, 'base64').equals(AB_PDF)) + assert.ok(Buffer.from(kundenmail.attachments[1].content, 'base64').equals(AGB_PDF)) + assert.deepEqual(adressen(teamkopie.to), config.chatNotify.emails) + assert.equal(teamkopie.subject, K31_BETREFF) + assert.ok(teamkopie.text.split('\n').includes('Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.')) + assert.ok(!teamkopie.attachments || teamkopie.attachments.length === 0, 'die AB-PDF liegt im Ticketsystem') + const geheim = JSON.stringify(teamkopie) + for (const spur of [TOKEN, 'auftrag.html#t=', 'auftrag.html?t=']) assert.ok(!geheim.includes(spur), `die Teamkopie enthält ${spur}`) +}) + +test('sendeAuftragMail: die Erinnerung geht auch als Teamkopie raus; ohne pdf wird die gespeicherte Datei aus der Ablage gelesen', async () => { + const { pdfDatei } = await speicherePdf('c1', 'ab-erinnerung', AB_PDF) + await sendeAuftragMail(abDokument({ ...AUFWAND, pdfDatei }), { token: TOKEN, erinnerung: true }) + assert.equal(sink.sent.length, 2) + for (const mail of sink.sent) assert.ok(mail.text.includes('Wir haben Ihnen diese Auftragsbestätigung am 06.10.2026 schon einmal geschickt')) + assert.ok(Buffer.from(sink.sent[0].attachments[0].content, 'base64').equals(AB_PDF)) + assert.ok(!JSON.stringify(sink.sent[1]).includes(TOKEN)) +}) + +test('sendeAuftragMail: wirft und verschickt nichts bei fehlender AGB-Datei, falschem Token oder fehlender Adresse; scheitert die Kundenmail, geht keine Teamkopie raus', async () => { + await assert.rejects(sendeAuftragMail(abDokument({ agbFassung: '1999-01-01' }), { pdf: AB_PDF, token: TOKEN }), /AGB-PDF 1999-01-01 nicht lesbar/) + await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: 'kurz' }), TypeError) + await assert.rejects(sendeAuftragMail(abDokument({ gesendetAn: '', kunde: kunde('privat', { email: '' }) }), { pdf: AB_PDF, token: TOKEN }), /E-Mail-Adresse/) + assert.equal(sink.sent.length, 0) + let versuche = 0 + setMailTransportForTests({ async sendMail() { versuche += 1; throw new Error('SMTP 451') } }) + await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN }), /SMTP 451/) + assert.equal(versuche, 1, 'nach dem Fehler der Kundenmail keine Teamkopie') +}) + +test('sendeAuftragMail: scheitert nur die Teamkopie, gilt der Versand als erfolgreich (nur Protokoll mit der Nummer)', async () => { + let aufrufe = 0 + setMailTransportForTests({ + async sendMail(mail) { + aufrufe += 1 + if (aufrufe === 2) throw new Error('SMTP 451') + return sink.sendMail(mail) + }, + }) + await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN }) + assert.equal(aufrufe, 2) + assert.deepEqual(sink.sent.map((m) => adressen(m.to)), [['k@example.invalid']]) + assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('Teamkopie') && c.arguments.join(' ').includes('AB-2026-0001'))) +}) + +test('sendeBestaetigtMails: K32 an den Kunden (Kopie ans Team), danach T22 an das Team', async () => { + await sendeBestaetigtMails(BESTAETIGT({ fruehbeginn: true })) + assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', T22_BETREFF]) + const [k32, t22] = sink.sent + assert.deepEqual(adressen(k32.to), ['k@example.invalid']) + assert.deepEqual(adressen(k32.bcc), config.chatNotify.emails) + assert.deepEqual(adressen(t22.to), config.chatNotify.emails) + assert.ok(k32.text.includes('Bestätigt am: 07.10.2026, 10:30 Uhr')) + assert.ok(t22.text.includes(`Im Ticketsystem öffnen: ${config.womsBaseUrl}/customers/c1?tab=invoices`)) +}) + +test('sendeBestaetigtMails wirft nie: scheitert K32, geht T22 trotzdem raus; ohne Adresse entfällt K32; Fehler stehen mit der Nummer im Protokoll', async () => { + let aufrufe = 0 + setMailTransportForTests({ + async sendMail(mail) { + aufrufe += 1 + if (aufrufe === 1) throw new Error('SMTP 451') + return sink.sendMail(mail) + }, + }) + await sendeBestaetigtMails(BESTAETIGT()) + assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF]) + assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('K32') && c.arguments.join(' ').includes('AB-2026-0001'))) + sink.sent.length = 0 + setMailTransportForTests(sink) + await sendeBestaetigtMails(BESTAETIGT({ gesendetAn: '', kunde: kunde('privat', { email: '' }) })) + assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF]) +}) diff --git a/test/auftraegeToken.test.js b/test/auftraegeToken.test.js new file mode 100644 index 0000000..757035b --- /dev/null +++ b/test/auftraegeToken.test.js @@ -0,0 +1,35 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { hashToken, neuerToken } from '../server/services/auftraege/token.js' + +const TAG_MS = 24 * 60 * 60 * 1000 + +test('neuerToken: 32 Zufallsbytes als base64url (43 Zeichen), Hash = SHA-256 in Hex, gültig 60 Tage ab jetzt', () => { + const jetzt = new Date('2026-10-06T10:00:00.000Z') + const { token, hash, gueltigBis } = neuerToken(jetzt) + assert.match(token, /^[A-Za-z0-9_-]{43}$/) + assert.equal(hash, createHash('sha256').update(token).digest('hex')) + assert.ok(gueltigBis instanceof Date) + assert.equal(gueltigBis.toISOString(), '2026-12-05T10:00:00.000Z') + assert.equal(jetzt.toISOString(), '2026-10-06T10:00:00.000Z', 'das übergebene Datum bleibt unverändert') +}) + +test('neuerToken: jedes Token ist neu; ohne Argument zählt die aktuelle Zeit; ein ungültiger Zeitpunkt wirft', () => { + const vorher = Date.now() + const a = neuerToken() + const b = neuerToken() + const nachher = Date.now() + assert.notEqual(a.token, b.token) + assert.notEqual(a.hash, b.hash) + assert.ok(a.gueltigBis.getTime() >= vorher + 60 * TAG_MS && a.gueltigBis.getTime() <= nachher + 60 * TAG_MS) + assert.throws(() => neuerToken(new Date('kein Datum')), TypeError) + assert.throws(() => neuerToken('2026-10-06'), TypeError) +}) + +test('hashToken: gleicher Hash wie neuerToken, null bei falschem Format', () => { + const { token, hash } = neuerToken() + assert.equal(hashToken(token), hash) + const falsch = [undefined, null, 42, {}, [], '', 'kurz', `${token}x`, token.slice(1), ` ${token.slice(0, 42)}`, `${token.slice(0, 42)}+`, `${token.slice(0, 42)}=`, `../${token.slice(3)}`, `${token.slice(0, 42)}\n`] + for (const wert of falsch) assert.equal(hashToken(wert), null, JSON.stringify(wert)) +}) diff --git a/test/auftraegeTokenRoutes.test.js b/test/auftraegeTokenRoutes.test.js new file mode 100644 index 0000000..2ea5c8c --- /dev/null +++ b/test/auftraegeTokenRoutes.test.js @@ -0,0 +1,213 @@ +import { test, after, beforeEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import os from 'node:os' +import path from 'node:path' +import { mkdtempSync } from 'node:fs' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { mailSink } from './support/mailSink.js' +import { hashToken } from '../server/services/auftraege/token.js' +import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, SHA, TOKEN, TOKEN_NEU, UNGUELTIG, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' + +// Öffentliche Link-Routen /api/auftrag/:token (V5): ansehen, PDF, bestätigen; Ratenbegrenzung, Schalter, Ablauf, Status +process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-token-')) +process.env.AUFTRAEGE_ENABLED = 'on' +const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { startTestApp, send } = await import('./support/testApp.js') +const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js') +const { speicherePdf } = await import('../server/services/auftraege/ablage.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { default: tokenRoutes, resetAuftragRoutesForTests, setzeUhrFuerTests } = await import('../server/routes/auftrag.js') +const app = await startTestApp({ '/api/auftrag': tokenRoutes }) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close()]) +}) + +const PDF = '%PDF-1.4 Test-AB' +await speicherePdf('c1', 'ab1', Buffer.from(PDF)) +const JETZT = new Date('2026-10-10T09:00:00.000Z') +const GRUND = { ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.', storniert: 'Diese Auftragsbestätigung wurde storniert.', widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.' } +const ZU_VIELE = { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' } + +let sink +const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) }) +beforeEach(() => { + resetAuftragRoutesForTests() + setzeUhrFuerTests(() => JETZT) + config.auftraege.mode = 'on' + config.auftraege.pilotEmails = [] + sink = mailSink() + setMailTransportForTests(sink) + seed() +}) +const get = (token = TOKEN, rest = '', opts = {}) => send('GET', `/api/auftrag/${token}${rest}`, opts) +const post = (body, token = TOKEN, opts = {}) => send('POST', `/api/auftrag/${token}/bestaetigen`, { ...opts, body }) +const ip = (adresse) => ({ headers: { 'x-forwarded-for': adresse } }) + +test('GET /api/auftrag/:token: öffentliche Ansicht der AB ohne interne Felder, nie zwischengespeichert', async () => { + const res = await get() + assert.equal(res.status, 200) + assert.deepEqual(Object.keys(res.body), ['auftrag']) + const a = res.body.auftrag + assert.deepEqual([a.nummer, a.status, a.titel, a.summeCents, a.kundenart], ['AB-2026-0001', 'versendet', 'Website für Musterbäckerei', 80000, 'privat']) + assert.deepEqual(a.positionen, [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000, summeCents: 80000 }]) + assert.match(a.agbUrl, /^\/rechtliches\/agb-.+\.pdf$/) + for (const intern of ['id', 'kundenId', 'kunde', 'tokenHash', 'tokenGueltigBis', 'pdfDatei', 'pdfSha256', 'bestaetigtPdfSha256', 'gesendetAn', 'zeitquelle', 'erstelltVon']) assert.ok(!(intern in a), `${intern} darf nicht öffentlich sein`) + assert.equal(res.headers.get('cache-control'), 'no-store') + assert.equal(res.headers.get('x-robots-tag'), 'noindex') +}) + +test('ungültige, unbekannte und abgelaufene Links: 404 mit immer derselben Meldung; am letzten Moment gilt der Link noch', async () => { + for (const token of [TOKEN_NEU, 'kurz', `${TOKEN}x`, `${'T'.repeat(42)}%2B`, `${'T'.repeat(40)}%2e%2e%2f`]) { + for (const res of [await get(token), await get(token, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }], token) + } + seed({ tokenGueltigBis: new Date(JETZT.getTime() - 1).toISOString() }) + for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }]) + seed({ tokenGueltigBis: JETZT.toISOString() }) + assert.equal((await get()).status, 200) +}) + +test('Erinnerung mit neuem Link: der alte Link gilt nicht mehr (404), der neue schon', async () => { + seed({ tokenHash: hashToken(TOKEN_NEU) }) + assert.deepEqual([(await get(TOKEN)).status, (await get(TOKEN_NEU)).status], [404, 200]) +}) + +test('Schalter: bei aus 404 wie bei unbekanntem Link; im Pilot nur für Adressen der Liste', async () => { + config.auftraege.mode = 'off' + for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }]) + config.auftraege.mode = 'pilot' + config.auftraege.pilotEmails = ['andere@example.invalid'] + assert.equal((await get()).status, 404) + config.auftraege.pilotEmails = ['k@example.invalid'] + assert.equal((await get()).status, 200) +}) + +test('ersetzte, stornierte und widerrufene AB: 410 mit dem Grund – für Ansicht und PDF, auch bei abgelaufenem Link; Entwurf 404', async () => { + for (const [status, meldung] of Object.entries(GRUND)) { + seed({ status, tokenGueltigBis: '2026-01-01T00:00:00.000Z' }) + for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [410, { error: meldung }], status) + } + seed({ status: 'entwurf' }) + assert.equal((await get()).status, 404) +}) + +test('GET …/pdf liefert die gespeicherte Datei unverändert und inline; fehlt die Datei, 404', async () => { + const res = await get(TOKEN, '/pdf') + assert.equal(res.status, 200) + assert.equal(res.headers.get('content-type'), 'application/pdf') + assert.equal(res.headers.get('content-disposition'), 'inline; filename="AB-2026-0001.pdf"') + assert.equal(res.headers.get('cache-control'), 'no-store') + assert.equal(res.body, PDF) + seed({ pdfDatei: 'c1/fehlt.pdf' }) + const fehlt = await get(TOKEN, '/pdf') + assert.deepEqual([fehlt.status, fehlt.body], [404, { error: 'PDF nicht gefunden.' }]) +}) + +test('Ratenbegrenzung: 120 Aufrufe (GET) je Stunde und IP, danach 429; eine andere IP ist nicht betroffen', async () => { + for (let i = 1; i <= 120; i += 1) assert.equal((await get(TOKEN, '', ip('203.0.113.9'))).status, 200, `Anfrage ${i}`) + const zuViele = await get(TOKEN, '', ip('203.0.113.9')) + assert.deepEqual([zuViele.status, zuViele.body], [429, ZU_VIELE]) + assert.equal((await get(TOKEN, '', ip('203.0.113.10'))).status, 200) +}) + +const HAEKCHEN = { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' } + +test('POST …/bestaetigen ohne bestaetigt: true → 400, nichts geändert, keine Mail', async () => { + for (const body of [{}, { bestaetigt: false }, { bestaetigt: 'true' }, { bestaetigt: 1 }, { fruehbeginn: true }]) { + const res = await post(body) + assert.deepEqual([res.status, res.body], [400, HAEKCHEN], JSON.stringify(body)) + } + const doc = await getAuftrag('ab1') + assert.deepEqual([doc.status, doc.bestaetigtAm], ['versendet', null]) + assert.equal(sink.sent.length, 0) + // die Prüfung des Links geht dem Häkchen vor: bei einem unbekannten Link 404, nie 400 + const unbekannt = await post({}, TOKEN_NEU) + assert.deepEqual([unbekannt.status, unbekannt.body], [404, { error: UNGUELTIG }]) +}) + +test('POST mit unbekanntem, falschem, altem oder abgelaufenem Link, bei ersetzter AB oder abgeschaltetem Schalter: 404 bzw. 410, nichts ändert sich', async () => { + const pruefe = async (erwartet, token = TOKEN) => { + const res = await post({ bestaetigt: true }, token) + assert.deepEqual([res.status, res.body], erwartet) + const doc = await getAuftrag('ab1') + assert.deepEqual([doc.status, doc.bestaetigtAm, sink.sent.length], ['versendet', null, 0]) + } + await pruefe([404, { error: UNGUELTIG }], TOKEN_NEU) + await pruefe([404, { error: UNGUELTIG }], 'kurz') + seed({ tokenHash: hashToken(TOKEN_NEU) }) // wie nach „Erinnerung senden“: nur der neue Link gilt + await pruefe([404, { error: UNGUELTIG }], TOKEN) + seed({ tokenGueltigBis: new Date(JETZT.getTime() - 1).toISOString() }) + await pruefe([404, { error: UNGUELTIG }]) + seed({ status: 'ersetzt' }) + const ersetzt = await post({ bestaetigt: true }) + assert.deepEqual([ersetzt.status, ersetzt.body], [410, { error: GRUND.ersetzt }]) + assert.deepEqual([(await getAuftrag('ab1')).bestaetigtAm, sink.sent.length], [null, 0]) + seed() + config.auftraege.mode = 'off' + await pruefe([404, { error: UNGUELTIG }]) +}) + +test('POST bestätigt über den Link: Protokoll am Dokument, öffentliche Antwort, K32 und T22; keine IP-Adresse gespeichert', async () => { + const res = await post({ bestaetigt: true, fruehbeginn: true }, TOKEN, ip('203.0.113.77')) + assert.equal(res.status, 200) + assert.deepEqual([res.body.auftrag.status, res.body.auftrag.fruehbeginn, new Date(res.body.auftrag.bestaetigtAm).getTime()], ['bestaetigt', true, JETZT.getTime()]) + assert.ok(!('tokenHash' in res.body.auftrag) && !('pdfSha256' in res.body.auftrag)) + const doc = await getAuftrag('ab1') + assert.deepEqual([doc.bestaetigtUeber, doc.bestaetigtPdfSha256, doc.fruehbeginn], ['link', SHA, true]) + assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster']) + assert.ok(!JSON.stringify([...aw.docs('auftraege').values()]).includes('203.0.113.77'), 'keine IP-Adresse im Dokument') +}) + +test('fruehbeginn zählt nur bei Privatpersonen und nur als true', async () => { + for (const [kundenart, eingabe, erwartet] of [['privat', 'ja', false], ['privat', 1, false], ['privat', true, true], ['unternehmen', true, false]]) { + seed({ kunde: kunde(kundenart) }) + const res = await post({ bestaetigt: true, fruehbeginn: eingabe }) + assert.equal(res.status, 200, `${kundenart} ${eingabe}`) + assert.equal((await getAuftrag('ab1')).fruehbeginn, erwartet, `${kundenart} ${eingabe}`) + } +}) + +test('zweite und gleichzeitige Bestätigungen: 200, die erste zählt, genau zwei Mails', async () => { + const erste = await post({ bestaetigt: true, fruehbeginn: true }) + setzeUhrFuerTests(() => new Date(JETZT.getTime() + 3_600_000)) + const zweite = await post({ bestaetigt: true, fruehbeginn: false }) + assert.equal(zweite.status, 200) + assert.deepEqual(zweite.body, erste.body) + assert.equal(sink.sent.length, 2) + seed() + sink.sent.length = 0 + const [a, b] = await Promise.all([post({ bestaetigt: true }), post({ bestaetigt: true })]) + assert.deepEqual([a.status, b.status], [200, 200]) + assert.equal(a.body.auftrag.bestaetigtAm, b.body.auftrag.bestaetigtAm) + assert.equal(sink.sent.length, 2) +}) + +test('abgerechnete AB ohne frühere Bestätigung: 200, der Status bleibt abgerechnet', async () => { + seed({ status: 'abgerechnet' }) + const res = await post({ bestaetigt: true }) + assert.deepEqual([res.status, res.body.auftrag.status], [200, 'abgerechnet']) + assert.notEqual((await getAuftrag('ab1')).bestaetigtAm, null) +}) + +test('Mailfehler: die Bestätigung gilt trotzdem (200), der Fehler steht nur im Protokoll', async () => { + setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } }) + const log = mock.method(console, 'error', () => {}) + const res = await post({ bestaetigt: true }) + assert.equal(res.status, 200) + assert.equal((await getAuftrag('ab1')).status, 'bestaetigt') + assert.ok(log.mock.callCount() > 0) + mock.restoreAll() +}) + +test('Ratenbegrenzung: höchstens 10 POST je Stunde und IP, danach 429; GET und andere IP sind nicht betroffen', async () => { + const kopf = ip('203.0.113.20') + for (let i = 1; i <= 10; i += 1) assert.equal((await post({}, TOKEN, kopf)).status, 400, `Anfrage ${i}`) + const zuViele = await post({ bestaetigt: true }, TOKEN, kopf) + assert.deepEqual([zuViele.status, zuViele.body], [429, ZU_VIELE]) + assert.equal((await getAuftrag('ab1')).bestaetigtAm, null) + assert.equal((await get(TOKEN, '', kopf)).status, 200) + assert.equal((await post({ bestaetigt: true }, TOKEN, ip('203.0.113.21'))).status, 200) +}) diff --git a/test/auftraegeVorlagen.test.js b/test/auftraegeVorlagen.test.js new file mode 100644 index 0000000..4441859 --- /dev/null +++ b/test/auftraegeVorlagen.test.js @@ -0,0 +1,156 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { config } from '../server/config.js' +import { loadTemplate, renderMail } from '../server/services/mailTemplates.js' +import { euroDe } from '../server/services/finance/format.js' + +// K31, K32, T22 (Aufträge und Rechnungen, Strang L5) und der Block projekt in K04 und T09 (Task 11). Die allgemeinen +// Vorlagenregeln (Kopf, Platzhalter, Blöcke, Logo, Text ohne Entities, Team-Fuß) prüft test/mailTemplates.test.js. +const eur = (cent) => euroDe(cent).replace(/\u{a0}/gu, ' ') // die Textfassung macht geschützte Leerzeichen zu normalen +const DU_FORM = /(^|[^\p{L}])(du|dein\p{L}*|dir|dich)(?![\p{L}-])/iu +const SIE_FORM = /(^|[^\p{L}])(Sie|Ihr\p{L}*|Ihnen)(?![\p{L}-])/u +const alle = (id) => Object.fromEntries(loadTemplate(id).blocks.map((name) => [name, true])) +const pruefeZeilen = (mail, erwartet) => { + const zeilen = mail.text.split('\n') + for (const zeile of erwartet) assert.ok(zeilen.includes(zeile), `Zeile fehlt: ${zeile}\n${mail.text}`) +} + +const K31 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', datum: '06.10.2026', betrag: eur(80000), link: 'https://project.webklar.com/auftrag.html#t=TOKEN', gueltig_bis: '05.12.2026' } +const K32 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', fruehester_beginn: '21.10.2026', fruehbeginn_text: 'Ich verlange ausdrücklich den Beginn vor Ablauf der Widerrufsfrist.' } +const T22 = { kunde: 'Muster GmbH', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', ueber: 'Bestätigungslink', woms_link: 'https://ticket.webklar.com/customers/c1?tab=invoices', fruehester_beginn: '21.10.2026' } + +test('K31, K32, T22: Datei, Betreff, Platzhalter und Blöcke wie im Vertrag V6 (K31 mit dem Gegenblock kundenmail)', () => { + const erwartet = { + K31: { file: 'K31 Auftragsbestaetigung.html', subject: 'Ihre Auftragsbestätigung {{nummer}} – {{titel}}', placeholders: ['name', 'nummer', 'titel', 'datum', 'betrag', 'link', 'gueltig_bis'], blocks: ['erinnerung', 'privat', 'festpreis', 'aufwand', 'teamkopie', 'kundenmail'] }, + K32: { file: 'K32 Auftrag bestaetigt.html', subject: 'Ihre Bestätigung zu {{nummer}} ist eingegangen', placeholders: ['name', 'nummer', 'titel', 'zeitpunkt', 'fruehester_beginn', 'fruehbeginn_text'], blocks: ['fruehbeginn', 'ohne_fruehbeginn'] }, + T22: { file: 'T22 Auftrag bestaetigt.html', subject: '[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}', placeholders: ['kunde', 'nummer', 'titel', 'zeitpunkt', 'ueber', 'woms_link', 'fruehester_beginn'], blocks: ['privat_fruehbeginn', 'privat_ohne_fruehbeginn'] }, + } + for (const [id, soll] of Object.entries(erwartet)) { + const vorlage = loadTemplate(id) + assert.equal(vorlage.file, soll.file, id) + assert.equal(vorlage.subject, soll.subject, id) + assert.deepEqual(vorlage.placeholders, soll.placeholders, id) + assert.deepEqual(vorlage.blocks, soll.blocks, id) + } +}) + +test('K31 Festpreis an eine Privatperson: Angaben, Knopf mit Link im Fragment, Anhänge, Widerrufshinweis; keine Erinnerung, kein Teamhinweis', () => { + const mail = renderMail('K31', K31, { festpreis: true, privat: true, kundenmail: true }) + assert.deepEqual(mail.problems, []) + assert.equal(mail.subject, 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei') + pruefeZeilen(mail, [ + 'Guten Tag Erika Muster,', + 'Auftrag: AB-2026-0001 vom 06.10.2026', + 'Projekt: Website für Musterbäckerei', + 'Gesamtpreis: 800,00 € (Festpreis, gemäß § 19 UStG ohne Umsatzsteuer)', + 'Bestätigung möglich bis: 05.12.2026', + 'Auftrag bestätigen: https://project.webklar.com/auftrag.html#t=TOKEN', + 'Viele Grüße', + 'Ihr WEBklar-Team', + ]) + assert.ok(mail.text.includes('Die Auftragsbestätigung (AB-2026-0001.pdf) und unsere Allgemeinen Geschäftsbedingungen hängen als PDF an dieser E-Mail.')) + assert.ok(mail.text.includes('Sie sind Verbraucher: Ihr Widerrufsrecht und das Muster-Widerrufsformular finden Sie in der angehängten Auftragsbestätigung;')) + assert.ok(mail.text.includes(`Widerrufsformular auf unserer Website (${config.publicBaseUrl}/widerrufen.html)`)) + assert.doesNotMatch(mail.text, /Erinnerung|Vergütung:|Kopie für das Team/) +}) + +test('K31 Aufwand an ein Unternehmen als Erinnerung: Stundensatz, Hinweis auf den früheren Link, kein Widerrufshinweis', () => { + const mail = renderMail('K31', { ...K31, betrag: `${eur(10000)} pro Stunde` }, { aufwand: true, erinnerung: true, kundenmail: true }) + assert.deepEqual(mail.problems, []) + pruefeZeilen(mail, ['Vergütung: 100,00 € pro Stunde (nach Aufwand in Schritten von 15 Minuten, gemäß § 19 UStG ohne Umsatzsteuer)', 'Auftrag bestätigen: https://project.webklar.com/auftrag.html#t=TOKEN']) + assert.ok(mail.text.includes('Wir haben Ihnen diese Auftragsbestätigung am 06.10.2026 schon einmal geschickt und noch keine Bestätigung von Ihnen erhalten. Der Link in der früheren E-Mail gilt nicht mehr.')) + assert.doesNotMatch(mail.text, /Gesamtpreis|Widerruf|Verbraucher/) +}) + +test('K31 als Kopie für das Team: Hinweis statt Knopf, kein Link, keine Anhänge, kein Widerrufshinweis; die Erinnerung bleibt erkennbar', () => { + const mail = renderMail('K31', { ...K31, link: '' }, { aufwand: true, erinnerung: true, teamkopie: true }) + assert.deepEqual(mail.problems, []) + pruefeZeilen(mail, ['Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.', 'Auftrag: AB-2026-0001 vom 06.10.2026', 'Bestätigung möglich bis: 05.12.2026']) + assert.ok(mail.text.includes('Der Link in der früheren E-Mail gilt nicht mehr.')) + assert.doesNotMatch(mail.text, /Auftrag bestätigen|auftrag\.html|hängen als PDF|Anhänge|Verbraucher/) + assert.doesNotMatch(mail.html, /auftrag\.html|#t=|class="btn"/) +}) + +test('K32: Eingangsbestätigung mit Datum und Uhrzeit; Frühbeginn ja (wörtliche Erklärung), nein und Unternehmen', () => { + const ja = renderMail('K32', K32, { fruehbeginn: true }) + assert.deepEqual(ja.problems, []) + assert.equal(ja.subject, 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen') + pruefeZeilen(ja, ['Guten Tag Erika Muster,', 'Auftrag: AB-2026-0001', 'Projekt: Website für Musterbäckerei', 'Bestätigt am: 07.10.2026, 10:30 Uhr', 'Ihre Erklärung zum früheren Beginn', K32.fruehbeginn_text, 'Viele Grüße', 'Ihr WEBklar-Team']) + assert.ok(ja.text.includes('wir haben Ihre Bestätigung zu AB-2026-0001 am 07.10.2026, 10:30 Uhr erhalten.')) + assert.doesNotMatch(ja.text, /21\.10\.2026|nicht verlangt|Widerrufsrecht bleibt/) + const nein = renderMail('K32', K32, { ohne_fruehbeginn: true }) + assert.ok(nein.text.includes('Sie haben nicht verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.')) + assert.doesNotMatch(nein.text, /Erklärung zum früheren Beginn/) + assert.doesNotMatch(renderMail('K32', K32, {}).text, /Beginn der Arbeiten|21\.10\.2026|Widerruf|Erklärung/) +}) + +test('T22: Betreff mit Präfix, Angaben, Knopf ins Ticketsystem; Hinweise nur bei Privatpersonen', () => { + const ja = renderMail('T22', T22, { privat_fruehbeginn: true }) + assert.deepEqual(ja.problems, []) + assert.equal(ja.subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH') + pruefeZeilen(ja, ['Kunde: Muster GmbH', 'Auftrag: AB-2026-0001', 'Projekt: Website für Musterbäckerei', 'Bestätigt am: 07.10.2026, 10:30 Uhr', 'Weg: Bestätigungslink', 'Im Ticketsystem öffnen: https://ticket.webklar.com/customers/c1?tab=invoices']) + assert.ok(ja.text.includes('Der Kunde hat ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Die Arbeit darf sofort starten. Bei einem Widerruf ist Wertersatz zu prüfen (§ 357a Abs. 2 BGB).')) + assert.doesNotMatch(ja.text, /keinen früheren Beginn|21\.10\.2026/) + const nein = renderMail('T22', T22, { privat_ohne_fruehbeginn: true }) + assert.ok(nein.text.includes('Der Kunde hat keinen früheren Beginn verlangt. Arbeitsbeginn frühestens am 21.10.2026, dem Tag nach Ablauf der Widerrufsfrist.')) + assert.doesNotMatch(renderMail('T22', T22, {}).text, /Privatperson|Wertersatz|frühestens/) +}) + +test('K31 und K32 stehen in der Sie-Form; Werte werden HTML-sicher eingesetzt', () => { + for (const [id, werte] of [['K31', K31], ['K32', K32]]) { + const mail = renderMail(id, werte, alle(id)) + assert.doesNotMatch(mail.text, DU_FORM, `${id}: Du-Form\n${mail.text}`) + assert.match(mail.text, SIE_FORM, `${id}: spricht den Kunden mit Sie an`) + } + const mail = renderMail('K31', { ...K31, titel: 'Café &"' }, { festpreis: true }) + assert.match(mail.html, /Café <b>&"/) + assert.doesNotMatch(mail.html, /&/) +}) + +const K04 = { name: 'Erika Muster', produkt: 'Projektauftrag', bestellung: 'Projektauftrag (Auftragsbestätigung AB-2026-0001)', eingang_zeitpunkt: '07.10.2026, 10:30', email: 'k@example.invalid', kundennummer: 'KD-9' } +const T09 = { email: 'k@example.invalid', name: 'Erika Muster', produkt: 'Projektauftrag', preis: '', kundennummer: 'KD-9', bestellung: 'Projektauftrag (Auftragsbestätigung AB-2026-0001)', stripe_abo_id: '', abo_beginn: '–', fristende: '20.10.2026, 24 Uhr', rechtzeitig: 'RECHTZEITIG', ereignisse: 'Auftragsbestätigung auf „widerrufen“ gesetzt', pruefhinweis: '' } + +test('K04 und T09: Block projekt ist deklariert (K04 mit projekt_unternehmen), die Platzhalter bleiben, die anderen Blöcke auch', () => { + assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen']) + assert.deepEqual(loadTemplate('K04').placeholders, ['name', 'produkt', 'bestellung', 'eingang_zeitpunkt', 'email', 'kundennummer']) + assert.deepEqual(loadTemplate('T09').blocks.slice(-2), ['bereits_widerrufen', 'projekt']) + assert.deepEqual(loadTemplate('T09').placeholders, ['email', 'name', 'produkt', 'preis', 'kundennummer', 'bestellung', 'stripe_abo_id', 'abo_beginn', 'fristende', 'rechtzeitig', 'ereignisse', 'pruefhinweis']) +}) + +test('K04 Block projekt: Vertragsart, „wir prüfen“; bei Unternehmen zusätzlich der Hinweis ohne gesetzliches Widerrufsrecht; keine Zusagen', () => { + const privat = renderMail('K04', K04, { kundennummer: true, projekt: true }) + assert.deepEqual(privat.problems, []) + assert.equal(privat.subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Projektauftrag') + pruefeZeilen(privat, [ + 'wir haben Ihren Widerruf der Bestellung „Projektauftrag (Auftragsbestätigung AB-2026-0001)“ am 07.10.2026, 10:30 Uhr erhalten.', + 'Bestellung: Projektauftrag (Auftragsbestätigung AB-2026-0001)', + 'Kundennummer: KD-9', + 'Wie es weitergeht', + 'Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.', + ]) + assert.doesNotMatch(privat.text, /Unternehmen|gesetzliches Widerrufsrecht|Abbuchungen|erstatt|Wertersatz|Widerrufsfrist läuft/) + const firma = renderMail('K04', K04, { projekt: true, projekt_unternehmen: true }) + assert.deepEqual(firma.problems, []) + pruefeZeilen(firma, ['Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen. Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.']) + // der Zusatz gehört zum Block projekt: allein erscheint er nie, und ohne projekt gibt es keinen Kasten + assert.doesNotMatch(renderMail('K04', K04, { projekt_unternehmen: true }).text, /Unternehmen|Wie es weitergeht/) + assert.doesNotMatch(renderMail('K04', K04, {}).text, /Wie es weitergeht|Projektauftrags/) +}) + +test('T09 Block projekt: Ergebnis, Widerrufsfrist und Eingang als eigene Zeilen, Hinweis auf die Handarbeit; ohne den Block nichts davon', () => { + const mail = renderMail('T09', T09, { kundennummer: true, projekt: true }) + assert.deepEqual(mail.problems, []) + assert.equal(mail.subject, '[Abo] WIDERRUF eingegangen: k@example.invalid') + pruefeZeilen(mail, [ + 'Bestellung: Projektauftrag (Auftragsbestätigung AB-2026-0001)', + 'Abo: Projektauftrag', + 'Kundennummer: KD-9', + 'Zu tun', + 'Ergebnis: Auftragsbestätigung auf „widerrufen“ gesetzt', + 'Widerrufsfrist bis: 20.10.2026, 24 Uhr', + 'Eingang: RECHTZEITIG', + ]) + assert.ok(mail.text.includes('Der Kunde hat „wir prüfen“ bekommen. Erstattung, Wertersatz und das Ende der Arbeit sind Handarbeit (Ticketsystem: Kunde → Aufträge und Rechnungen).')) + assert.doesNotMatch(mail.text, /KEIN aktives Abo|Stripe-Abo|Abo in Stripe/) + assert.doesNotMatch(renderMail('T09', T09, { kundennummer: true }).text, /Ergebnis:|Eingang:|Handarbeit|Zu tun/) +}) diff --git a/test/auftraegeWiderruf.test.js b/test/auftraegeWiderruf.test.js new file mode 100644 index 0000000..088106c --- /dev/null +++ b/test/auftraegeWiderruf.test.js @@ -0,0 +1,185 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { mailSink } from './support/mailSink.js' +import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js' + +// Widerruf eines Projektauftrags über /api/abo/public/withdraw (order = 'projekt', abNummer optional); K04 und T09 mit dem Block projekt +process.env.AUFTRAEGE_ENABLED = 'on' +const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 }) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, post, send } = await import('./support/testApp.js') +const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js') +const { withdrawalDeadline } = await import('../server/services/deadlines.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { default: aboRoutes, resetPublicRateLimit } = await import('../server/routes/abo.js') +const { default: tokenRoutes, resetAuftragRoutesForTests } = await import('../server/routes/auftrag.js') +const app = await startTestApp({ '/api/abo': aboRoutes, '/api/auftrag': tokenRoutes }) +const sink = mailSink() +setMailTransportForTests(sink) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) + +const vor = (tage) => new Date(Date.now() - tage * 86_400_000).toISOString() +const tag = (zeit) => new Date(zeit).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) +const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) }) +let protokoll +beforeEach(() => { + resetPublicRateLimit() + resetAuftragRoutesForTests() + sink.sent.length = 0 + config.auftraege.mode = 'on' + protokoll = mock.method(console, 'error', () => {}) +}) +afterEach(() => mock.restoreAll()) +const widerruf = (body = {}) => post('/api/abo/public/withdraw', { name: 'Erika Muster', email: 'k@example.invalid', order: 'projekt', abNummer: 'AB-2026-0001', ...body }) +const k04 = () => sink.sent.find((mail) => mail.subject.startsWith('Eingangsbestätigung Ihres Widerrufs')) +const t09 = () => sink.sent.find((mail) => mail.subject.startsWith('[Abo] WIDERRUF eingegangen')) +const zeile = (mail, anfang) => mail.text.split('\n').find((z) => z.startsWith(anfang)) +const LABEL = 'Projektauftrag (Auftragsbestätigung AB-2026-0001)' +const OHNE_NUMMER = 'Projektauftrag (Auftragsbestätigung)' +const UNTERNEHMEN_HINWEIS = 'Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.' +const KEINE_NUMMER = 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen' +const pruefeT09 = (ergebnis, frist, eingang) => { + assert.equal(zeile(t09(), 'Ergebnis: '), `Ergebnis: ${ergebnis}`) + assert.equal(zeile(t09(), 'Widerrufsfrist bis: '), `Widerrufsfrist bis: ${frist}`) + assert.equal(zeile(t09(), 'Eingang: '), `Eingang: ${eingang}`) +} + +test('Privatperson innerhalb der Frist: AB widerrufen, Antwort wie immer, K04 und T09 mit Block projekt; kein Stripe', async () => { + const gesendetAm = vor(3) + seed({ gesendetAm }) + const stripeVorher = st.calls.length + const res = await widerruf({ customerNumber: 'KD-9' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt']) + assert.equal(st.calls.length, stripeVorher, 'kein Stripe-Aufruf') + const ab = await getAuftrag('ab1') + assert.equal(ab.status, 'widerrufen') + assert.equal(new Date(ab.widerrufenAm).getTime(), new Date(res.body.receivedAt).getTime()) + // K04 an die Privatperson: Vertragsart, „wir prüfen“, keine Zusagen, kein Hinweis für Unternehmen + assert.equal(k04().subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Projektauftrag') + assert.deepEqual(k04().to.map((a) => a.address), ['k@example.invalid']) + assert.ok(k04().text.includes(`Widerruf der Bestellung „${LABEL}“ am `), k04().text) + assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${LABEL}`) + assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.')) + assert.ok(k04().text.includes('Kundennummer: KD-9')) + assert.doesNotMatch(k04().text, /Unternehmen|Abbuchungen|Wir prüfen, ob für diese Bestellung/) + // T09 ans Team: Vertragsart, Ergebnis, Frist, Eingang + assert.equal(t09().subject, '[Abo] WIDERRUF eingegangen: k@example.invalid') + assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`) + assert.equal(zeile(t09(), 'Abo: '), 'Abo: Projektauftrag') + pruefeT09('Auftragsbestätigung auf „widerrufen“ gesetzt', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'RECHTZEITIG') + assert.doesNotMatch(t09().text, /KEIN aktives Abo|Stripe-Abo/) +}) + +test('bestätigte AB mit verlangtem Frühbeginn: wird widerrufen, T09 mahnt die Prüfung des Wertersatzes an', async () => { + seed({ gesendetAm: vor(5), status: 'bestaetigt', bestaetigtAm: vor(4), bestaetigtUeber: 'link', fruehbeginn: true }) + assert.equal((await widerruf()).status, 200) + const ab = await getAuftrag('ab1') + assert.equal(ab.status, 'widerrufen') + assert.notEqual(ab.bestaetigtAm, null) + assert.equal(zeile(t09(), 'Ergebnis: '), 'Ergebnis: Auftragsbestätigung auf „widerrufen“ gesetzt; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)') +}) + +test('Unternehmen: kein gesetzliches Widerrufsrecht – AB bleibt unverändert, K04 weist darauf hin, T09 sagt es dem Team', async () => { + seed({ gesendetAm: vor(3), kunde: kunde('unternehmen') }) + assert.equal((await widerruf()).status, 200) + assert.equal((await getAuftrag('ab1')).status, 'versendet') + assert.ok(k04().text.includes(`Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen. ${UNTERNEHMEN_HINWEIS}`), k04().text) + pruefeT09('Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert', 'entfällt (Unternehmen)', 'ungeprüft') +}) + +test('nach Fristende: nichts geändert, T09 nennt das Fristende und bittet um Prüfung; K04 ohne Unternehmen-Hinweis', async () => { + const gesendetAm = vor(20) + seed({ gesendetAm }) + assert.equal((await widerruf()).status, 200) + assert.equal((await getAuftrag('ab1')).status, 'versendet') + pruefeT09('nichts geändert', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'NACH Fristende, vor einer Ablehnung bitte prüfen') + assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags')) + assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS)) +}) + +test('ohne, mit ungültiger oder falscher Nummer und mit fremder E-Mail-Adresse: dieselbe Antwort, nichts geändert, K04 und T09 gehen trotzdem raus (ungeprüft)', async () => { + const faelle = [ + [{ abNummer: 'AB-2026-0002' }, 'Projektauftrag (Auftragsbestätigung AB-2026-0002)', 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0002“ und dieser E-Mail-Adresse gefunden, nichts geändert'], + [{ email: 'fremd@example.invalid' }, LABEL, 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0001“ und dieser E-Mail-Adresse gefunden, nichts geändert'], + [{ abNummer: 'AB-2026-1' }, OHNE_NUMMER, KEINE_NUMMER], + [{ abNummer: '' }, OHNE_NUMMER, KEINE_NUMMER], + [{ abNummer: undefined }, OHNE_NUMMER, KEINE_NUMMER], + [{ abNummer: 'irgendwas' }, OHNE_NUMMER, KEINE_NUMMER], + ] + seed({ gesendetAm: vor(3) }) + for (const [body, label, ergebnis] of faelle) { + sink.sent.length = 0 + resetPublicRateLimit() + const res = await widerruf(body) + const was = JSON.stringify(body) + assert.equal(res.status, 200, was) + assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt'], was) + assert.equal((await getAuftrag('ab1')).status, 'versendet', was) + assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${label}`, was) + assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${label}`, was) + pruefeT09(ergebnis, 'keine Frist gefunden', 'ungeprüft') + assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.'), was) + assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS), was) + } +}) + +test('Groß-/Kleinschreibung und Leerzeichen bei Nummer und E-Mail-Adresse spielen keine Rolle', async () => { + seed({ gesendetAm: vor(3) }) + assert.equal((await widerruf({ abNummer: ' ab-2026-0001 ', email: 'K@Example.INVALID' })).status, 200) + assert.equal((await getAuftrag('ab1')).status, 'widerrufen') + assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`) +}) + +test('bereits widerrufen, abgerechnet, ersetzt, storniert: nichts geändert, T09 sagt warum', async () => { + const faelle = [ + ['widerrufen', 'war schon widerrufen, nichts geändert', 'bereits widerrufen'], + ['abgerechnet', 'Status „abgerechnet“, nichts geändert, bitte prüfen', 'ungeprüft'], + ['ersetzt', 'Status „ersetzt“, nichts geändert, bitte prüfen', 'ungeprüft'], + ['storniert', 'Status „storniert“, nichts geändert, bitte prüfen', 'ungeprüft'], + ] + for (const [status, ergebnis, eingang] of faelle) { + const gesendetAm = vor(3) + seed({ gesendetAm, status, widerrufenAm: status === 'widerrufen' ? vor(1) : null }) + sink.sent.length = 0 + resetPublicRateLimit() + assert.equal((await widerruf()).status, 200, status) + pruefeT09(ergebnis, `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, eingang) + assert.equal((await getAuftrag('ab1')).status, status, status) + } +}) + +test('nach dem Widerruf bestätigt der Link nichts mehr: 410 mit Grund', async () => { + seed({ gesendetAm: vor(3) }) + assert.equal((await widerruf()).status, 200) + const lesen = await send('GET', `/api/auftrag/${TOKEN}`) + assert.deepEqual([lesen.status, lesen.body], [410, { error: 'Diese Auftragsbestätigung wurde widerrufen.' }]) + assert.equal((await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { body: { bestaetigt: true } })).status, 410) + assert.equal((await getAuftrag('ab1')).bestaetigtAm, null) +}) + +test('Schalter aus: der Widerruf wird trotzdem verarbeitet (Verbraucherrecht), der Link antwortet wie bei unbekannter AB', async () => { + seed({ gesendetAm: vor(3) }) + config.auftraege.mode = 'off' + assert.equal((await widerruf()).status, 200) + assert.equal((await getAuftrag('ab1')).status, 'widerrufen') + assert.equal((await send('GET', `/api/auftrag/${TOKEN}`)).status, 404) +}) + +test('Datenbankfehler bei der Prüfung: Antwort 200, nichts geändert, T09 meldet die fehlgeschlagene Prüfung, K04 geht raus', async () => { + seed({ gesendetAm: vor(3) }) + aw.failNext({ collection: 'auftraege', method: 'GET', status: 503 }) + assert.equal((await widerruf()).status, 200) + assert.equal((await getAuftrag('ab1')).status, 'versendet') + pruefeT09('ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen', 'keine Frist gefunden', 'ungeprüft') + assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags')) + assert.ok(protokoll.mock.callCount() > 0) +}) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 9fe42ff..3980367 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -6,9 +6,9 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products. const DIR = new URL('../server/mail-templates/', import.meta.url) // G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb; -// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) +// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9); K31, K32 und T22 Auftragsbestätigung (Aufträge und Rechnungen, Strang L5; K31 und K32 in Sie-Form, T22 mit [Aufträge]) const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`) -const EXPECTED = [...ids('G', 15), ...ids('K', 30), ...ids('T', 21)] +const EXPECTED = [...ids('G', 15), ...ids('K', 32), ...ids('T', 22)] // GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten) const GUEST = ids('G', 8) const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15'] @@ -27,7 +27,7 @@ for (const id of EXPECTED) { const title = source.match(/([\s\S]*?)<\/title>/)[1] assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich') // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail), [Finanzen] (Bankzugang), [Terminplaner] (Betriebsmeldung) oder [Webshop] (neue Anfrage) - assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner] oder [Webshop] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)') + assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop|Aufträge)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner], [Webshop] oder [Aufträge] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)') const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1])) assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter') @@ -446,7 +446,7 @@ test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Au for (const p of ['vertrag', 'pakete', 'pakete_neu']) assert.ok(loadTemplate('K03').placeholders.includes(p), `K03 ${p}`) for (const b of ['zusatzspeicher', 'pruefen', 'abo']) assert.ok(loadTemplate('K03').blocks.includes(b), `K03 ${b}`) assert.ok(loadTemplate('K04').placeholders.includes('bestellung')) - assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen']) + assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen']) for (const p of ['vertrag', 'pakete', 'fehler']) assert.ok(loadTemplate('T08').placeholders.includes(p), `T08 ${p}`) for (const b of ['zusatzspeicher', 'kein_speicher', 'wechsel_ausstehend', 'speicher_fehlgeschlagen', 'nachweis_fehlt']) assert.ok(loadTemplate('T08').blocks.includes(b), `T08 ${b}`) for (const p of ['bestellung', 'ereignisse']) assert.ok(loadTemplate('T09').placeholders.includes(p), `T09 ${p}`) @@ -613,13 +613,13 @@ test('K04: Folgen des Widerrufs je Bestellung (Abschluss, Wechsel, Zusatzspeiche }) // F9: Widerruf je Funktion - T09 kennt preis, die Blöcke bleiben unverändert; K04/T09 nennen Produkt und Folgen der Funktion -test('F9: T09 deklariert preis, Blöcke von K04 und T09 unverändert', () => { +test('F9: T09 deklariert preis, Blöcke von K04 und T09 wie festgelegt (dazu je der Block projekt aus Strang L5)', () => { assert.ok(loadTemplate('T09').placeholders.includes('preis')) assert.ok(loadTemplate('T09').placeholders.includes('produkt')) - assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen']) + assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen']) assert.deepEqual(loadTemplate('T09').blocks, [ 'kundennummer', 'abo_gefunden', 'auf_kuendigung_gesetzt', 'kein_abo_gefunden', 'kuendigung_fehlgeschlagen', 'abschluss', 'wechsel', - 'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt', 'wechsel_offen', 'abschluss_verspaetet', 'nicht_pruefbar', 'bereits_widerrufen', + 'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt', 'wechsel_offen', 'abschluss_verspaetet', 'nicht_pruefbar', 'bereits_widerrufen', 'projekt', ]) }) @@ -715,7 +715,7 @@ test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => { }) // Etappe 4: das Admin-Panel ist abgeschaltet, Team-Mails fuehren ins Ticketsystem -const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21'] +const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21', 'T22'] test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => { for (const id of EXPECTED) {