Files
Webklar-Kundenbereich/test/helpers/auftraegeAb.js
T
2026-10-07 14:11:34 +00:00

265 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <nummer>"), 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)
},
}
}