diff --git a/test/auftraegeAbVertrag.test.js b/test/auftraegeAbVertrag.test.js new file mode 100644 index 0000000..a61654c --- /dev/null +++ b/test/auftraegeAbVertrag.test.js @@ -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 /.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]) +}) diff --git a/test/helpers/auftraegeAb.js b/test/helpers/auftraegeAb.js new file mode 100644 index 0000000..12cbdf1 --- /dev/null +++ b/test/helpers/auftraegeAb.js @@ -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 "), 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) + }, + } +}