import assert from 'node:assert/strict' import { createHash } from 'node:crypto' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { mock } from 'node:test' import { startAppwriteMock } from '../support/appwriteMock.js' import { useTestEnv } from '../support/testEnv.js' import { AUFTRAEGE_COLLECTIONS } from '../../server/services/auftraege/schema.js' // Testhilfen des Strangs L3 (AB-Kern). Kein statischer Import von server/config.js: starteUmgebung() setzt zuerst die // Umgebung, erst danach darf config.js geladen werden. const NAMEN = Object.keys(AUFTRAEGE_COLLECTIONS) const attribute = (name) => AUFTRAEGE_COLLECTIONS[name].attributes /** Attribut-Schlüssel je AB-Collection für startAppwriteMock({ schema }). */ export const AUFTRAEGE_SCHEMA = Object.fromEntries(NAMEN.map((name) => [name, attribute(name).map((a) => a.key)])) /** Datumsfelder je AB-Collection für startAppwriteMock({ datetimes }): der Nachbau schreibt sie wie Appwrite ("+00:00"). */ export const AUFTRAEGE_DATETIMES = Object.fromEntries( NAMEN.map((name) => [name, attribute(name).filter((a) => a.type === 'datetime').map((a) => a.key)]), ) export const KUNDE_ID = 'c_kunde01' export const PROJEKT_ID = 'p_projekt01' export const TICKET_ID = 't_ticket01' export const ZWEITES_TICKET = 't_zwei02' export const LEAD_KUNDE = 'c_lead01' export const FREMD = { kunde: 'c_fremd01', projekt: 'p_fremd01', ticket: 't_fremd01' } export const VON = 'kenso@webklar.com' /** Feste Meldungen (wörtlich wie in der Tabelle „Meldungstexte“ des Plans). */ export const MELDUNG = { auftragFehlt: 'Auftragsbestätigung nicht gefunden.', kundeFehlt: 'Kunde nicht gefunden.', positionen: 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.', nurEntwurfAendern: 'Nur ein Entwurf lässt sich ändern.', nurEntwurfLoeschen: 'Nur ein Entwurf lässt sich löschen.', nurEntwurfSenden: 'Nur ein Entwurf lässt sich senden.', keinFesterKunde: 'Auftragsbestätigungen nur für feste Kunden.', keineKundenart: 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).', keineMail: 'Für den Versand fehlt die E-Mail-Adresse des Kunden.', adresse: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', pdf: 'Das PDF konnte nicht erzeugt werden.', pruefsumme: 'Die gespeicherte PDF-Datei stimmt nicht mit der Prüfsumme überein.', ersetzteUngueltig: 'Die ersetzte Auftragsbestätigung ist nicht mehr gültig. Bitte eine neue Fassung anlegen.', keineErinnerung: 'Eine Erinnerung gibt es nur für versendete Auftragsbestätigungen.', keineNeueFassung: 'Eine neue Fassung gibt es nur für versendete, bestätigte oder abgerechnete Aufträge.', schonEntwurf: 'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf für eine neue Fassung.', keinStorno: 'Nur versendete oder bestätigte Auftragsbestätigungen lassen sich stornieren.', projektFehlt: 'Projekt: Bitte ein Projekt angeben.', projektUngueltig: 'Projekt: Ungültige Projekt-Kennung.', projektFremd: 'Projekt: Das Projekt gehört nicht zu diesem Kunden.', schonProjekt: 'Diese Auftragsbestätigung gehört schon zu einem Projekt.', keinProjekt: 'Einer stornierten, ersetzten oder widerrufenen Auftragsbestätigung lässt sich kein Projekt mehr zuordnen.', } /** Kundendokument wie in Appwrite: Unternehmen mit vollständiger Anschrift und E-Mail. */ export const kundeDoc = (patch = {}) => ({ $id: KUNDE_ID, name: 'Anna Beispiel', companyName: 'Beispiel GmbH', email: 'anna@example.invalid', street: 'Hauptstraße 1', postalCode: '67722', city: 'Winnweiler', country: 'DE', code: 'K-1001', customerStatus: 'active', kundenart: 'unternehmen', ...patch, }) export const projektDoc = (patch = {}) => ({ $id: PROJEKT_ID, customerId: KUNDE_ID, projectName: 'Website Beispiel', ticketId: TICKET_ID, ...patch }) export const ticketDoc = (patch = {}) => ({ $id: TICKET_ID, customerId: KUNDE_ID, woid: '100200', topic: 'Website Beispiel', ...patch }) /** Gültige POST-Eingabe: Festpreis, Summe 800,00 € (600 + 2 × 100), Anzahlung 50 % = 400,00 €. */ export const festpreisEingabe = (patch = {}) => ({ kundenId: KUNDE_ID, projektId: PROJEKT_ID, titel: '', art: 'festpreis', positionen: [ { beschreibung: 'Webdesign Startseite', menge: 1, einzelpreisCents: 60000 }, { beschreibung: 'Kontaktformular', menge: 2, einzelpreisCents: 10000 }, ], zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14, auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '', zeitquelle: [], ...patch, }) /** Gültige POST-Eingabe: nach Aufwand zu 100 €/Std., Schätzung 10 Stunden. */ export const aufwandEingabe = (patch = {}) => ({ kundenId: KUNDE_ID, projektId: PROJEKT_ID, titel: '', art: 'aufwand', stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, zahlungszielTage: 14, auftragVom: '2026-10-05', auftragWeg: 'email', startAm: '', fertigBis: '', zeitquelle: [], ...patch, }) /** Genau die Felder, die legeEntwurfAn an createAuftrag übergibt (bereinigt, ohne kundenart). */ export const entwurfsDaten = (patch = {}) => ({ kundenId: KUNDE_ID, projektId: PROJEKT_ID, titel: 'Website Beispiel', zeitquelle: [TICKET_ID], art: 'festpreis', positionen: festpreisEingabe().positionen, stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14, auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '', erstelltVon: VON, ...patch, }) export const sha256 = (daten) => createHash('sha256').update(daten).digest('hex') export const warte = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) /** Offene Zusage, die der Test später einlöst: { promise, freigeben }. */ export function tor() { let freigeben const promise = new Promise((resolve) => { freigeben = resolve }) return { promise, freigeben } } /** Wartet (höchstens eine Sekunde), bis bedingung() wahr ist. */ export async function wartebis(bedingung) { for (let i = 0; i < 200; i += 1) { if (bedingung()) return await warte(5) } throw new Error('Bedingung nicht eingetreten') } /** Fängt eine Zusage ab, damit sie nie unbehandelt scheitert: { wert } oder { fehler }. */ export const ergebnis = (promise) => promise.then((wert) => ({ wert }), (fehler) => ({ fehler })) const pruefeFehler = (status, message, name) => (err) => { assert.equal(err.status, status, `${name} Status ${err.status}: ${err.message}`) if (message !== undefined) assert.equal(err.message, message, name) return true } /** Die Zusage scheitert mit diesem Status und (wenn angegeben) dieser Meldung. */ export const abgelehnt = (promise, status, message, name = '') => assert.rejects(promise, pruefeFehler(status, message, name)) /** Der Aufruf wirft sofort mit diesem Status und (wenn angegeben) dieser Meldung. */ export const wirftFehler = (aufruf, status, message, name = '') => assert.throws(aufruf, pruefeFehler(status, message, name)) let tokenZaehler = 0 /** * Attrappen für deps (kein pdfkit, kein SMTP); calls zeichnet jeden Aufruf auf. Optionen: pdf (feste Antwort, sonst * "%PDF-1.4 "), pdfFehler, pdfWarten (Promise, auf die die PDF-Erzeugung wartet), mailFehler, mailWarten. */ export function baueDeps({ pdf = null, pdfFehler = null, pdfWarten = null, mailFehler = null, mailWarten = null } = {}) { const calls = { pdf: [], token: [], mail: [] } const deps = { async erzeugeAuftragPdf(auftrag, optionen) { calls.pdf.push({ auftrag: structuredClone(auftrag), optionen }) if (pdfWarten) await pdfWarten if (pdfFehler) throw pdfFehler return pdf ?? Buffer.from(`%PDF-1.4 ${auftrag.nummer}`) }, neuerToken(jetzt = new Date()) { tokenZaehler += 1 const token = `t${tokenZaehler}`.padEnd(43, 'x') calls.token.push(token) return { token, hash: sha256(token), gueltigBis: new Date(jetzt.getTime() + 60 * 86_400_000) } }, async sendeAuftragMail(auftrag, optionen) { calls.mail.push({ auftrag: structuredClone(auftrag), pdf: optionen.pdf, token: optionen.token, erinnerung: optionen.erinnerung ?? false }) if (mailWarten) await mailWarten if (mailFehler) throw mailFehler }, } return { deps, calls } } const PRAEFIX = 'auftraege-l3-' function pruefeTestVerzeichnis(dir) { if (path.dirname(dir) !== os.tmpdir() || !path.basename(dir).startsWith(PRAEFIX)) throw new Error(`Kein Testverzeichnis des Strangs L3: ${dir}`) } export const legeUploadVerzeichnisAn = () => fs.mkdtempSync(path.join(os.tmpdir(), PRAEFIX)) export function raeumeUploadVerzeichnisAuf(dir) { pruefeTestVerzeichnis(dir) fs.rmSync(dir, { recursive: true, force: true }) } export function leereVerzeichnis(dir) { pruefeTestVerzeichnis(dir) for (const name of fs.readdirSync(dir)) fs.rmSync(path.join(dir, name), { recursive: true, force: true }) } /** Alle Dateien unterhalb von dir, relativ und sortiert (leere Verzeichnisse zählen nicht). */ export function dateienIn(dir) { const gefunden = [] const durchsuche = (relativ) => { for (const eintrag of fs.readdirSync(path.join(dir, relativ), { withFileTypes: true })) { const pfad = path.join(relativ, eintrag.name) if (eintrag.isDirectory()) durchsuche(pfad) else gefunden.push(pfad) } } durchsuche('') return gefunden.sort() } /** * Startet den Appwrite-Nachbau (mit dem Schema der AB-Collections, wie Appwrite mit 25 Treffern ohne limit), setzt die * Testumgebung (Schalter an, Upload-Verzeichnis im Temp-Ordner) und lädt erst danach config.js. Die Module des Strangs * importiert der Test selbst, danach. */ export async function starteUmgebung() { const uploadDir = legeUploadVerzeichnisAn() process.env.AUFTRAEGE_UPLOAD_DIR = uploadDir 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 { deleteDocument } = await import('../../server/services/appwriteAdmin.js') const stamm = () => [ [config.collections.customers, kundeDoc()], [config.collections.customers, kundeDoc({ $id: FREMD.kunde, name: 'Fritz Fremd', companyName: '', email: 'fritz@example.invalid', kundenart: '' })], [config.collections.customers, kundeDoc({ $id: LEAD_KUNDE, name: 'Lea Lead', customerStatus: 'lead', kundenart: '' })], [config.collections.websiteProjects, projektDoc()], [config.collections.websiteProjects, projektDoc({ $id: FREMD.projekt, customerId: FREMD.kunde, projectName: 'Fremde Website', ticketId: FREMD.ticket })], [config.collections.workorders, ticketDoc()], [config.collections.workorders, ticketDoc({ $id: ZWEITES_TICKET })], [config.collections.workorders, ticketDoc({ $id: FREMD.ticket, customerId: FREMD.kunde })], ] return { aw, config, uploadDir, /** Vor jedem Test: Spione zurück, Schalter an, AB-Collections und Upload-Verzeichnis leer, Stammdaten neu. */ async zuruecksetzen() { mock.restoreAll() config.auftraege.mode = 'on' config.auftraege.pilotEmails = [] for (const collection of [config.collections.auftraege, config.collections.projektRechnungen]) { for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) } leereVerzeichnis(uploadDir) for (const [collection, doc] of stamm()) aw.seed(collection, doc) }, async schliesse() { await aw.close() raeumeUploadVerzeichnisAuf(uploadDir) }, } }