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

+90
View File
@@ -0,0 +1,90 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { starteUmgebung, entwurfsDaten, sha256, KUNDE_ID, PROJEKT_ID, VON } from './helpers/auftraegeAb.js'
// Vertragsprüfung: Die L0-Module (Fundament) liefern, was der Strang L3 braucht (Übersicht V1 bis V4). Schlägt sie fehl,
// weicht L0 vom Vertrag ab: nichts in fremden Dateien reparieren, sondern stoppen und melden.
const umg = await starteUmgebung()
after(() => umg.schliesse())
const DIENSTE = '../server/services/auftraege'
const store = await import(`${DIENSTE}/auftragStore.js`)
const ablage = await import(`${DIENSTE}/ablage.js`)
const { getSettings } = await import('../server/services/finance/settings.js')
const EXPORTE = {
'konstanten.js': ['AUFTRAG_ARTEN', 'AUFTRAG_WEGE', 'ZAHLUNGSPLAN_ARTEN', 'KUNDENARTEN', 'VEREINBARTE_STATUS', 'STUNDENSATZ_STANDARD_CENTS', 'VIERTELSTUNDE', 'TOKEN_GUELTIG_TAGE', 'AGB_FASSUNG', 'LIMITS', 'SPERRE_NUMMER', 'sperreAuftrag', 'SCHALTER_AUS_MELDUNG'],
'fehler.js': ['AuftragError', 'sendeFehler'],
'schalter.js': ['auftraegeAktiv'],
'auftragStore.js': ['getAuftrag', 'listAuftraege', 'createAuftrag', 'updateAuftrag', 'deleteAuftrag', 'findAuftragByTokenHash', 'findAuftragByNummer'],
'projektRechnungenStore.js': ['getProjektRechnung', 'listProjektRechnungen', 'createProjektRechnung', 'updateProjektRechnung', 'umhaengen'],
'auftragSicht.js': ['kundeSnapshot', 'auftragFuerTeam'],
'ablage.js': ['speicherePdf', 'lesePdf', 'entfernePdf'], // entfernePdf: Nachtrag der Planrunde (Übersicht, Nachträge L0)
'texte.js': ['TEXTE_FASSUNG'],
}
test('L0-Module, Konfiguration und Einstellungen liefern die Namen der Übersicht', async () => {
for (const [datei, namen] of Object.entries(EXPORTE)) {
const modul = await import(`${DIENSTE}/${datei}`)
for (const name of namen) assert.ok(name in modul, `${datei} exportiert ${name} nicht`)
}
for (const feld of ['mode', 'pilotEmails', 'uploadDir']) assert.ok(feld in umg.config.auftraege, `config.auftraege.${feld} fehlt`)
assert.equal(umg.config.auftraege.uploadDir, umg.uploadDir)
assert.equal(umg.config.collections.auftraege, 'auftraege')
assert.equal(umg.config.collections.projektRechnungen, 'projektRechnungen')
assert.equal((await getSettings()).steuernummer, '')
// Sperren (V2): je Sperre ein zusammengesetzter Schlüssel, weil withLocks jedes Listenelement für sich sperrt
const { SPERRE_NUMMER, sperreAuftrag } = await import(`${DIENSTE}/konstanten.js`)
assert.deepEqual(SPERRE_NUMMER, ['auftraege-nummer'])
assert.deepEqual(sperreAuftrag('a_x1'), ['auftraege-ab:a_x1'])
})
test('createAuftrag legt einen Entwurf mit den Leerwerten aus V3 an, updateAuftrag ändert nur die genannten Felder', async () => {
const a = await store.createAuftrag(entwurfsDaten())
assert.deepEqual(
[a.status, a.nummer, a.jahr, a.laufnummer, a.kunde, a.gesendetAm, a.tokenGueltigBis, a.bestaetigtAm, a.bestaetigtUeber, a.mailStatus, a.fruehbeginn, a.erstelltVon],
['entwurf', '', null, null, null, null, null, null, '', '', false, VON],
)
assert.equal(typeof a.id, 'string')
assert.ok(!Number.isNaN(new Date(a.erstelltAm).getTime()))
assert.deepEqual(a.positionen, entwurfsDaten().positionen)
assert.deepEqual(a.zahlungsplan, { art: 'anzahlung', prozent: 50, text: '' })
assert.deepEqual(await store.getAuftrag(a.id), a)
const b = await store.updateAuftrag(a.id, { titel: 'Anderer Titel' })
assert.equal(b.titel, 'Anderer Titel')
assert.deepEqual([b.positionen, b.zeitquelle, b.zahlungsplan, b.summeCents], [a.positionen, a.zeitquelle, a.zahlungsplan, a.summeCents])
assert.deepEqual(await store.getAuftrag(a.id), b)
assert.equal(await store.getAuftrag('a_gibtsnicht'), null)
})
test('listAuftraege liefert alle Treffer, auch mehr als die 25 Dokumente, die Appwrite ohne limit liefert', async () => {
for (let i = 0; i < 30; i += 1) await store.createAuftrag(entwurfsDaten({ kundenId: 'c_liste01' }))
assert.equal((await store.listAuftraege({ kundenId: 'c_liste01' })).length, 30)
})
test('ablage: speicherePdf legt <kundenId>/<id>.pdf an, überschreibt nie; lesePdf liest und lehnt fremde Pfade ab; entfernePdf räumt auf', async () => {
const pdf = Buffer.from('%PDF-1.4 vertrag')
assert.deepEqual(await ablage.speicherePdf('c_ablage', 'a_ablage', pdf), { pdfDatei: 'c_ablage/a_ablage.pdf', pdfSha256: sha256(pdf) })
assert.deepEqual(await ablage.lesePdf('c_ablage/a_ablage.pdf'), pdf)
await assert.rejects(async () => ablage.speicherePdf('c_ablage', 'a_ablage', Buffer.from('%PDF-1.4 anders')))
await assert.rejects(async () => ablage.lesePdf('../etc/passwd'), (err) => err.status === 404)
await ablage.entfernePdf('c_ablage/a_ablage.pdf')
await assert.rejects(async () => ablage.lesePdf('c_ablage/a_ablage.pdf'), (err) => err.status === 404)
assert.equal((await ablage.speicherePdf('c_ablage', 'a_ablage', pdf)).pdfDatei, 'c_ablage/a_ablage.pdf')
})
test('umhaengen hängt alle Dokumente einer AB auf die neue um und zählt sie; ein zweiter Aufruf findet nichts mehr; list und update genügen für die Projektzuordnung', async () => {
const { createProjektRechnung, getProjektRechnung, listProjektRechnungen, updateProjektRechnung, umhaengen } = await import(`${DIENSTE}/projektRechnungenStore.js`)
const daten = (auftragId) => ({ kundenId: KUNDE_ID, projektId: PROJEKT_ID, auftragId, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [], rechnungsnummer: 'WEBKLAR-0001', aktiv: true, erstelltVon: VON, erstelltAm: new Date().toISOString() })
await createProjektRechnung('in_Umhaengen1', daten('a_alt'))
await createProjektRechnung('in_Umhaengen2', daten('a_anders'))
assert.equal(await umhaengen('a_alt', 'a_neu'), 1)
assert.equal((await getProjektRechnung('in_Umhaengen1')).auftragId, 'a_neu')
assert.equal((await getProjektRechnung('in_Umhaengen2')).auftragId, 'a_anders')
assert.equal(await umhaengen('a_alt', 'a_neu'), 0)
// Projektzuordnung (Task 11): alle Dokumente einer AB, auch inaktive; die projektId lässt sich einzeln ändern, sonst bleibt alles
await updateProjektRechnung('in_Umhaengen1', { aktiv: false })
assert.deepEqual((await listProjektRechnungen({ auftragId: 'a_neu', nurAktiv: false })).map((r) => r.invoiceId), ['in_Umhaengen1'])
assert.deepEqual(await listProjektRechnungen({ auftragId: 'a_neu' }), [])
const geaendert = await updateProjektRechnung('in_Umhaengen1', { projektId: 'p_neu01' })
assert.deepEqual([geaendert.projektId, geaendert.auftragId, geaendert.betragCents, geaendert.aktiv], ['p_neu01', 'a_neu', 40000, false])
})
+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)
},
}
}