AB-Kern: Testhilfe und Vertragsprüfung der L0-Module
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8627ed1124
commit
68ffac24c4
2 files changed
+354
No files matched your search
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user