AB-Kern: Testhilfe und Vertragsprüfung der L0-Module

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:11:34 +00:00
1 parent 8627ed1124
commit 68ffac24c4
2 files changed
+354

No files matched your search

+264
View File
@@ -0,0 +1,264 @@
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)
},
}
}