Buchungskalender: origin/main (Aufträge) zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
fd971309a8
145 files changed
+42590
-129
No files matched your search
@@ -0,0 +1,189 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { festpreisEingabe, aufwandEingabe, wirftFehler, MELDUNG } from './helpers/auftraegeAb.js'
|
||||
|
||||
// Rein: kein Appwrite-Zugriff. Die Umgebung zeigt trotzdem auf tote lokale Adressen, damit nie ein echtes System erreicht wird.
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1/v1', stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { pruefeEingabe } = await import('../server/services/auftraege/auftraege.js')
|
||||
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||
|
||||
const pos = (patch = {}) => ({ beschreibung: 'Webdesign', menge: 1, einzelpreisCents: 100, ...patch })
|
||||
const neunPositionen = () => Array.from({ length: 9 }, () => pos({ einzelpreisCents: 10_000_000 }))
|
||||
const E = {
|
||||
art: 'Art: Bitte Festpreis oder Nach Aufwand wählen.',
|
||||
beschreibung: (n = 1) => `Position ${n}: Die Beschreibung braucht 1 bis 200 Zeichen.`,
|
||||
menge: 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.',
|
||||
preis: (n = 1) => `Position ${n}: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.`,
|
||||
summe: 'Die Auftragssumme ist zu hoch (höchstens 999.999,99 €).',
|
||||
plan: 'Zahlungsplan: Bitte eine Zahlungsart wählen.',
|
||||
prozent: 'Zahlungsplan: Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.',
|
||||
frei: 'Zahlungsplan: Der freie Text braucht 1 bis 500 Zeichen.',
|
||||
ziel: 'Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.',
|
||||
datum: 'Auftrag vom: Bitte ein gültiges Datum angeben (JJJJ-MM-TT).',
|
||||
titel: 'Titel: Höchstens 200 Zeichen.',
|
||||
zeitquelle: 'Zeitquelle: Eine Liste von Ticket-Kennungen erwartet.',
|
||||
stundensatz: 'Stundensatz: Der Stundensatz muss zwischen 0,01 € und 100.000,00 € liegen.',
|
||||
schaetzung: 'Schätzung: Die Schätzung muss ein Vielfaches von 15 Minuten sein (15 bis 99.960).',
|
||||
}
|
||||
|
||||
test('Festpreis: bereinigte Felder – Texte getrimmt, Summe berechnet, Aufwandsfelder leer, Vorgaben gesetzt', () => {
|
||||
const felder = pruefeEingabe(
|
||||
festpreisEingabe({
|
||||
titel: ' Neue Website ',
|
||||
positionen: [{ beschreibung: ' Webdesign ', menge: 2, einzelpreisCents: 30000 }],
|
||||
zahlungsplan: { art: 'nach_abnahme' },
|
||||
zahlungszielTage: undefined,
|
||||
stundensatzCents: 12345, // gehört nicht zu einem Festpreis und fällt weg
|
||||
schaetzungMinuten: 30,
|
||||
}),
|
||||
)
|
||||
assert.deepEqual(felder, {
|
||||
projektId: 'p_projekt01',
|
||||
titel: 'Neue Website',
|
||||
kundenart: '',
|
||||
art: 'festpreis',
|
||||
positionen: [{ beschreibung: 'Webdesign', menge: 2, einzelpreisCents: 30000 }],
|
||||
stundensatzCents: null,
|
||||
schaetzungMinuten: null,
|
||||
summeCents: 60000,
|
||||
zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' },
|
||||
zahlungszielTage: 14,
|
||||
auftragVom: '2026-10-05',
|
||||
auftragWeg: 'telefon',
|
||||
startAm: '',
|
||||
fertigBis: '',
|
||||
zeitquelle: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('Aufwand: Stundensatz-Vorgabe 100 €, Schätzung optional, Positionen fallen weg, Summe leer', () => {
|
||||
const felder = pruefeEingabe(aufwandEingabe({ stundensatzCents: undefined, schaetzungMinuten: null, positionen: festpreisEingabe().positionen }))
|
||||
assert.deepEqual(felder, {
|
||||
projektId: 'p_projekt01', titel: '', kundenart: '', art: 'aufwand', positionen: [], stundensatzCents: 10000, schaetzungMinuten: null,
|
||||
summeCents: null, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, zahlungszielTage: 14, auftragVom: '2026-10-05',
|
||||
auftragWeg: 'email', startAm: '', fertigBis: '', zeitquelle: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('Zahlungsplan: Anzahlung nimmt 50 % vor, frei trägt den Text, die anderen Arten verwerfen prozent und text', () => {
|
||||
const plan = (zahlungsplan, basis = festpreisEingabe) => pruefeEingabe(basis({ zahlungsplan })).zahlungsplan
|
||||
assert.deepEqual(plan({ art: 'anzahlung' }), { art: 'anzahlung', prozent: 50, text: '' })
|
||||
assert.deepEqual(plan({ art: 'anzahlung', prozent: 30, text: 'ignoriert' }), { art: 'anzahlung', prozent: 30, text: '' })
|
||||
assert.deepEqual(plan({ art: 'frei', prozent: 40, text: ' Zahlbar in zwei Raten. ' }), { art: 'frei', prozent: null, text: 'Zahlbar in zwei Raten.' })
|
||||
assert.deepEqual(plan({ art: 'monatlich', text: 'x' }, aufwandEingabe), { art: 'monatlich', prozent: null, text: '' })
|
||||
assert.deepEqual(plan({ art: 'nach_abnahme', prozent: 10 }, aufwandEingabe), { art: 'nach_abnahme', prozent: null, text: '' })
|
||||
})
|
||||
|
||||
test('Grenzwerte, die noch gelten: 20 Positionen, Summe genau 999.999,99 €, Zahlungsziel 0 und 60, Schätzung 15 und 99.960 Minuten', () => {
|
||||
const lang = (cents = 100, menge = 1) => ({ beschreibung: 'x'.repeat(200), menge, einzelpreisCents: cents })
|
||||
assert.equal(pruefeEingabe(festpreisEingabe({ positionen: Array.from({ length: 20 }, () => lang()) })).positionen.length, 20)
|
||||
assert.equal(pruefeEingabe(festpreisEingabe({ positionen: [...neunPositionen(), pos({ einzelpreisCents: 9_999_999 })] })).summeCents, 99_999_999)
|
||||
assert.equal(pruefeEingabe(festpreisEingabe({ positionen: [lang(100, 999)] })).summeCents, 99_900)
|
||||
assert.equal(pruefeEingabe(festpreisEingabe({ zahlungszielTage: 0 })).zahlungszielTage, 0)
|
||||
assert.equal(pruefeEingabe(festpreisEingabe({ zahlungszielTage: 60 })).zahlungszielTage, 60)
|
||||
assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: 15 })).schaetzungMinuten, 15)
|
||||
assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: 99_960 })).schaetzungMinuten, 99_960)
|
||||
for (const keine of [0, '', null]) assert.equal(pruefeEingabe(aufwandEingabe({ schaetzungMinuten: keine })).schaetzungMinuten, null)
|
||||
assert.equal(pruefeEingabe(aufwandEingabe({ stundensatzCents: 1 })).stundensatzCents, 1)
|
||||
assert.equal(pruefeEingabe(aufwandEingabe({ stundensatzCents: 10_000_000 })).stundensatzCents, 10_000_000)
|
||||
})
|
||||
|
||||
const FESTPREIS_FEHLER = [
|
||||
['Art fehlt', { art: undefined }, E.art],
|
||||
['Art unbekannt', { art: 'pauschal' }, E.art],
|
||||
['keine Positionen', { positionen: [] }, MELDUNG.positionen],
|
||||
['Positionen fehlen', { positionen: undefined }, MELDUNG.positionen],
|
||||
['21 Positionen', { positionen: Array.from({ length: 21 }, () => pos()) }, MELDUNG.positionen],
|
||||
['Position ist kein Objekt', { positionen: ['x'] }, E.beschreibung()],
|
||||
['Beschreibung leer', { positionen: [pos({ beschreibung: ' ' })] }, E.beschreibung()],
|
||||
['Beschreibung 201 Zeichen (zweite Position)', { positionen: [pos(), pos({ beschreibung: 'x'.repeat(201) })] }, E.beschreibung(2)],
|
||||
['Menge 0', { positionen: [pos({ menge: 0 })] }, E.menge],
|
||||
['Menge 1000', { positionen: [pos({ menge: 1000 })] }, E.menge],
|
||||
['Menge 1,5', { positionen: [pos({ menge: 1.5 })] }, E.menge],
|
||||
['Menge als Text', { positionen: [pos({ menge: '2' })] }, E.menge],
|
||||
['Einzelpreis 0 (zweite Position)', { positionen: [pos(), pos({ einzelpreisCents: 0 })] }, E.preis(2)],
|
||||
['Einzelpreis über 100.000 €', { positionen: [pos({ einzelpreisCents: 10_000_001 })] }, E.preis()],
|
||||
['Einzelpreis mit Nachkommastelle', { positionen: [pos({ einzelpreisCents: 99.5 })] }, E.preis()],
|
||||
['Summe zu hoch', { positionen: [pos({ menge: 11, einzelpreisCents: 10_000_000 })] }, E.summe],
|
||||
['Summe ein Cent über dem Höchstwert', { positionen: [...neunPositionen(), pos({ einzelpreisCents: 10_000_000 })] }, E.summe],
|
||||
['Zahlungsplan fehlt', { zahlungsplan: undefined }, E.plan],
|
||||
['Zahlungsart unbekannt', { zahlungsplan: { art: 'bar' } }, E.plan],
|
||||
['Zahlungsplan kein Objekt', { zahlungsplan: 'anzahlung' }, E.plan],
|
||||
['monatlich bei Festpreis', { zahlungsplan: { art: 'monatlich' } }, 'Zahlungsplan: „Monatlich nach Aufwand“ geht nur bei einem Auftrag nach Aufwand.'],
|
||||
['Anzahlung 0 %', { zahlungsplan: { art: 'anzahlung', prozent: 0 } }, E.prozent],
|
||||
['Anzahlung 100 %', { zahlungsplan: { art: 'anzahlung', prozent: 100 } }, E.prozent],
|
||||
['Anzahlung 12,5 %', { zahlungsplan: { art: 'anzahlung', prozent: 12.5 } }, E.prozent],
|
||||
['Anzahlung als Text', { zahlungsplan: { art: 'anzahlung', prozent: '50' } }, E.prozent],
|
||||
['freier Text leer', { zahlungsplan: { art: 'frei', text: ' ' } }, E.frei],
|
||||
['freier Text 501 Zeichen', { zahlungsplan: { art: 'frei', text: 'x'.repeat(501) } }, E.frei],
|
||||
['Zahlungsziel -1', { zahlungszielTage: -1 }, E.ziel],
|
||||
['Zahlungsziel 61', { zahlungszielTage: 61 }, E.ziel],
|
||||
['Zahlungsziel 14,5', { zahlungszielTage: 14.5 }, E.ziel],
|
||||
['Zahlungsziel als Text', { zahlungszielTage: '14' }, E.ziel],
|
||||
['Auftragsdatum fehlt', { auftragVom: undefined }, E.datum],
|
||||
['30. Februar', { auftragVom: '2026-02-30' }, E.datum],
|
||||
['deutsches Datumsformat', { auftragVom: '05.10.2026' }, E.datum],
|
||||
['Datum als Zahl', { auftragVom: 20261005 }, E.datum],
|
||||
['Weg fehlt', { auftragWeg: undefined }, 'Weg: Bitte den Weg der Absprache wählen.'],
|
||||
['Weg unbekannt', { auftragWeg: 'brieftaube' }, 'Weg: Bitte den Weg der Absprache wählen.'],
|
||||
['Start kein Datum', { startAm: '2026-13-01' }, 'Start: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'],
|
||||
['Fertigstellung kein Datum', { fertigBis: 'bald' }, 'Fertigstellung: Bitte ein gültiges Datum angeben (JJJJ-MM-TT) oder leer lassen.'],
|
||||
['Titel 201 Zeichen', { titel: 'x'.repeat(201) }, E.titel],
|
||||
['Titel keine Zeichenkette', { titel: 5 }, E.titel],
|
||||
['Projekt-Kennung mit ../', { projektId: '../x' }, 'Projekt: Ungültige Projekt-Kennung.'],
|
||||
['Kundenart unbekannt', { kundenart: 'firma' }, 'Kundenart: Bitte Unternehmen oder Privatperson wählen.'],
|
||||
['Zeitquelle keine Liste', { zeitquelle: 't_1' }, E.zeitquelle],
|
||||
['11 Tickets', { zeitquelle: Array.from({ length: 11 }, (_, i) => `t_${i}`) }, 'Zeitquelle: Höchstens 10 Tickets.'],
|
||||
['Ticket-Kennung mit ../', { zeitquelle: ['t_ok', '../x'] }, 'Zeitquelle: Ungültige Ticket-Kennung.'],
|
||||
]
|
||||
const AUFWAND_FEHLER = [
|
||||
['Stundensatz 0', { stundensatzCents: 0 }, E.stundensatz],
|
||||
['Stundensatz über 100.000 €', { stundensatzCents: 10_000_001 }, E.stundensatz],
|
||||
['Stundensatz mit Nachkommastelle', { stundensatzCents: 100.5 }, E.stundensatz],
|
||||
['Stundensatz als Text', { stundensatzCents: '100' }, E.stundensatz],
|
||||
['Schätzung 10 Minuten', { schaetzungMinuten: 10 }, E.schaetzung],
|
||||
['Schätzung negativ', { schaetzungMinuten: -15 }, E.schaetzung],
|
||||
['Schätzung über dem Höchstwert', { schaetzungMinuten: 99_975 }, E.schaetzung],
|
||||
['Schätzung als Text', { schaetzungMinuten: '15' }, E.schaetzung],
|
||||
['Anzahlung bei Aufwand', { zahlungsplan: { art: 'anzahlung', prozent: 50 } }, 'Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.'],
|
||||
]
|
||||
|
||||
test('Ablehnungen beim Festpreis: jede Eingabe scheitert mit genau ihrer Meldung (400)', () => {
|
||||
for (const [name, patch, meldung] of FESTPREIS_FEHLER) wirftFehler(() => pruefeEingabe(festpreisEingabe(patch)), 400, meldung, name)
|
||||
})
|
||||
|
||||
test('Ablehnungen bei Aufwand: Stundensatz, Schätzung und Anzahlung', () => {
|
||||
for (const [name, patch, meldung] of AUFWAND_FEHLER) wirftFehler(() => pruefeEingabe(aufwandEingabe(patch)), 400, meldung, name)
|
||||
})
|
||||
|
||||
test('Eingaben, die kein einfaches Objekt sind, ergeben „Ungültige Eingabe.“ als AuftragError', () => {
|
||||
for (const eingabe of [null, undefined, 'x', 42, [], [festpreisEingabe()]]) {
|
||||
for (const teilweise of [false, true]) {
|
||||
assert.throws(() => pruefeEingabe(eingabe, { teilweise }), (err) => err instanceof AuftragError && err.status === 400 && err.message === 'Ungültige Eingabe.')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Unbekannte und geschützte Felder landen nie in den bereinigten Feldern', () => {
|
||||
const sauber = pruefeEingabe(festpreisEingabe())
|
||||
const mitMuell = pruefeEingabe({
|
||||
...festpreisEingabe(), status: 'bestaetigt', nummer: 'AB-2026-0001', jahr: 2026, tokenHash: 'x', ersetztId: 'a_alt',
|
||||
erstelltVon: 'boese@example.invalid', pdfDatei: 'c/a.pdf', kunde: { name: 'x' }, id: 'a_fremd',
|
||||
})
|
||||
assert.deepEqual(mitMuell, sauber)
|
||||
})
|
||||
|
||||
test('teilweise: nur übergebene Felder werden geprüft und bereinigt, nichts wird ergänzt', () => {
|
||||
assert.deepEqual(pruefeEingabe({}, { teilweise: true }), {})
|
||||
assert.deepEqual(pruefeEingabe({ kundenId: 'c_x', status: 'bestaetigt' }, { teilweise: true }), {})
|
||||
assert.deepEqual(pruefeEingabe({ titel: ' Neu ', zeitquelle: ['t_1', 't_1', 't_2'] }, { teilweise: true }), { titel: 'Neu', zeitquelle: ['t_1', 't_2'] })
|
||||
assert.deepEqual(pruefeEingabe({ art: 'aufwand' }, { teilweise: true }), { art: 'aufwand' })
|
||||
assert.deepEqual(pruefeEingabe({ zahlungsplan: { art: 'anzahlung' } }, { teilweise: true }), { zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' } })
|
||||
assert.deepEqual(
|
||||
pruefeEingabe({ positionen: [{ beschreibung: ' A ', menge: 1, einzelpreisCents: 5 }] }, { teilweise: true }),
|
||||
{ positionen: [{ beschreibung: 'A', menge: 1, einzelpreisCents: 5 }] },
|
||||
)
|
||||
wirftFehler(() => pruefeEingabe({ zahlungszielTage: 99 }, { teilweise: true }), 400, E.ziel)
|
||||
wirftFehler(() => pruefeEingabe({ auftragVom: null }, { teilweise: true }), 400, E.datum)
|
||||
})
|
||||
@@ -0,0 +1,388 @@
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, ZWEITES_TICKET, FREMD, VON, MELDUNG, festpreisEingabe, kundeDoc, projektDoc,
|
||||
baueDeps, sha256, abgelehnt, dateienIn, ergebnis, tor, warte, wartebis,
|
||||
} from './helpers/auftraegeAb.js'
|
||||
|
||||
const umg = await starteUmgebung()
|
||||
const { aw, config, uploadDir } = umg
|
||||
after(() => umg.schliesse())
|
||||
beforeEach(umg.zuruecksetzen)
|
||||
const { getAuftrag, updateAuftrag, findAuftragByTokenHash } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { createProjektRechnung, getProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
|
||||
const { lesePdf } = await import('../server/services/auftraege/ablage.js')
|
||||
const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js')
|
||||
const { withLocks } = await import('../server/services/keyedLock.js')
|
||||
const { legeEntwurfAn, aendereEntwurf, loescheEntwurf, sendeAuftrag, erinnereAuftrag, neueFassung, storniereAuftrag, ordneProjektZu } = await import('../server/services/auftraege/auftraege.js')
|
||||
|
||||
const JETZT = new Date('2026-10-06T10:00:00Z')
|
||||
/** Eine versendete AB (Entwurf anlegen und senden); liefert den Auftrag. */
|
||||
async function gesendet(deps, patch) {
|
||||
const entwurf = await legeEntwurfAn(festpreisEingabe(patch), { von: VON })
|
||||
return (await sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: JETZT })).auftrag
|
||||
}
|
||||
|
||||
/**
|
||||
* Hält die Sperre der AB wie L5 beim Bestätigen (sperreAuftrag(id), V2), startet die Aktion, wartet, bis sie an der Sperre
|
||||
* steht, führt waehrend() aus (dort schreibt „L5“) und liefert das Ergebnis der Aktion ({ wert } oder { fehler }) nach dem Freigeben.
|
||||
*/
|
||||
async function unterFremderSperre(id, { aktion, bereit = () => true, waehrend }) {
|
||||
const sperre = tor()
|
||||
const haelt = withLocks(sperreAuftrag(id), () => sperre.promise)
|
||||
const lauf = ergebnis(aktion())
|
||||
await wartebis(bereit)
|
||||
await warte(30) // die Aktion steht jetzt an der Sperre und darf noch nichts geschrieben haben
|
||||
await waehrend()
|
||||
sperre.freigeben()
|
||||
await haelt
|
||||
return lauf
|
||||
}
|
||||
|
||||
test('erinnereAuftrag: neuer Token (der alte gilt nicht mehr), gleiche PDF-Datei, Mail mit erinnerung: true, Versanddatum bleibt', async () => {
|
||||
const { deps, calls } = baueDeps()
|
||||
const erste = await gesendet(deps)
|
||||
const spaeter = new Date('2026-10-20T08:00:00Z')
|
||||
const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps, jetzt: spaeter })
|
||||
assert.equal(mailFehler, false)
|
||||
assert.equal(calls.token.length, 2)
|
||||
assert.notEqual(auftrag.tokenHash, erste.tokenHash)
|
||||
assert.equal(auftrag.tokenHash, sha256(calls.token[1]))
|
||||
assert.equal(new Date(auftrag.tokenGueltigBis).getTime(), spaeter.getTime() + 60 * 86_400_000)
|
||||
assert.equal(await findAuftragByTokenHash(erste.tokenHash), null)
|
||||
assert.equal((await findAuftragByTokenHash(auftrag.tokenHash)).id, erste.id)
|
||||
assert.deepEqual(
|
||||
[auftrag.status, auftrag.nummer, auftrag.gesendetAm, auftrag.pdfDatei, auftrag.pdfSha256, auftrag.mailStatus],
|
||||
['versendet', erste.nummer, erste.gesendetAm, erste.pdfDatei, erste.pdfSha256, 'gesendet'],
|
||||
)
|
||||
assert.equal(calls.pdf.length, 1) // kein neues PDF
|
||||
assert.equal(calls.mail.length, 2)
|
||||
assert.deepEqual(calls.mail[1].pdf, await lesePdf(erste.pdfDatei))
|
||||
assert.deepEqual([calls.mail[1].token, calls.mail[1].erinnerung], [calls.token[1], true])
|
||||
assert.deepEqual(await getAuftrag(erste.id), auftrag)
|
||||
})
|
||||
|
||||
test('erinnereAuftrag nach einem Mail-Fehler: gewöhnliche Mail ohne Erinnerungs-Hinweis an die aktuelle Adresse des Kunden', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const erste = await gesendet(baueDeps({ mailFehler: new Error('Verbindung abgebrochen') }).deps)
|
||||
assert.deepEqual([erste.mailStatus, erste.gesendetAn], ['fehler', 'anna@example.invalid'])
|
||||
aw.seed(config.collections.customers, kundeDoc({ email: 'neu@example.invalid' })) // der Admin hat die Adresse korrigiert
|
||||
const { deps, calls } = baueDeps()
|
||||
const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps })
|
||||
assert.equal(mailFehler, false)
|
||||
assert.deepEqual([auftrag.mailStatus, auftrag.mailFehler, auftrag.gesendetAn], ['gesendet', '', 'neu@example.invalid'])
|
||||
assert.deepEqual([calls.mail[0].erinnerung, calls.mail[0].auftrag.gesendetAn], [false, 'neu@example.invalid'])
|
||||
})
|
||||
|
||||
test('erinnereAuftrag: scheitert die Mail, gilt trotzdem der neue Token; das Ergebnis meldet mailFehler', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const erste = await gesendet(baueDeps().deps)
|
||||
const kaputt = baueDeps({ mailFehler: Object.assign(new Error('x'), { code: 'ECONNREFUSED' }) })
|
||||
const { auftrag, mailFehler } = await erinnereAuftrag(erste.id, { deps: kaputt.deps })
|
||||
assert.equal(mailFehler, true)
|
||||
assert.deepEqual([auftrag.status, auftrag.mailStatus, auftrag.mailFehler], ['versendet', 'fehler', 'E-Mail konnte nicht gesendet werden (ECONNREFUSED).'])
|
||||
assert.equal(auftrag.tokenHash, sha256(kaputt.calls.token[0]))
|
||||
assert.equal(await findAuftragByTokenHash(erste.tokenHash), null)
|
||||
})
|
||||
|
||||
test('erinnereAuftrag: nur versendete (409), unbekannt 404, ohne E-Mail 400, beschädigte PDF-Datei 500 – jeweils ohne neuen Token', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const { deps } = baueDeps()
|
||||
const a = await gesendet(deps)
|
||||
for (const status of ['entwurf', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) {
|
||||
await updateAuftrag(a.id, { status })
|
||||
await abgelehnt(erinnereAuftrag(a.id, { deps }), 409, MELDUNG.keineErinnerung, status)
|
||||
}
|
||||
await abgelehnt(erinnereAuftrag('a_gibtsnicht', { deps }), 404, MELDUNG.auftragFehlt)
|
||||
await updateAuftrag(a.id, { status: 'versendet' })
|
||||
aw.seed(config.collections.customers, kundeDoc({ email: ' ' }))
|
||||
await abgelehnt(erinnereAuftrag(a.id, { deps }), 400, MELDUNG.keineMail)
|
||||
aw.seed(config.collections.customers, kundeDoc())
|
||||
fs.writeFileSync(path.join(uploadDir, a.pdfDatei), 'manipuliert')
|
||||
await abgelehnt(erinnereAuftrag(a.id, { deps }), 500, MELDUNG.pruefsumme)
|
||||
assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash)
|
||||
})
|
||||
|
||||
test('erinnereAuftrag wartet auf die Sperre der AB und prüft den Stand danach (L5 bestätigt gerade: keine Erinnerung mehr)', async () => {
|
||||
const { deps, calls } = baueDeps()
|
||||
const a = await gesendet(deps)
|
||||
const lauf = await unterFremderSperre(a.id, {
|
||||
aktion: () => erinnereAuftrag(a.id, { deps }),
|
||||
waehrend: async () => {
|
||||
assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash) // noch kein neuer Token
|
||||
await updateAuftrag(a.id, { status: 'bestaetigt', bestaetigtAm: new Date().toISOString(), bestaetigtUeber: 'link' })
|
||||
},
|
||||
})
|
||||
assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keineErinnerung])
|
||||
assert.equal(calls.mail.length, 1) // nur die Mail des ersten Sendens
|
||||
assert.equal((await getAuftrag(a.id)).tokenHash, a.tokenHash)
|
||||
})
|
||||
|
||||
test('neueFassung: Kopie als Entwurf mit ersetztId, ohne Nummer, Schnappschuss, PDF und Token; die alte AB bleibt unberührt', async () => {
|
||||
const alt = await gesendet(baueDeps().deps)
|
||||
const neu = await neueFassung(alt.id, { von: 'justin@webklar.com' })
|
||||
assert.deepEqual([neu.status, neu.ersetztId, neu.nummer, neu.kunde, neu.erstelltVon], ['entwurf', alt.id, '', null, 'justin@webklar.com'])
|
||||
assert.ok(!neu.pdfDatei && !neu.tokenHash && !neu.gesendetAm)
|
||||
for (const feld of ['kundenId', 'projektId', 'titel', 'zeitquelle', 'art', 'positionen', 'stundensatzCents', 'schaetzungMinuten', 'summeCents', 'zahlungsplan', 'zahlungszielTage', 'auftragVom', 'auftragWeg', 'startAm', 'fertigBis']) {
|
||||
assert.deepEqual(neu[feld], alt[feld], feld)
|
||||
}
|
||||
assert.deepEqual(await getAuftrag(alt.id), alt)
|
||||
})
|
||||
|
||||
test('neueFassung: nur für versendete, bestätigte und abgerechnete (409); je AB nur ein offener Entwurf', async () => {
|
||||
const alt = await gesendet(baueDeps().deps)
|
||||
for (const status of ['entwurf', 'storniert', 'ersetzt', 'widerrufen']) {
|
||||
await updateAuftrag(alt.id, { status })
|
||||
await abgelehnt(neueFassung(alt.id, { von: VON }), 409, MELDUNG.keineNeueFassung, status)
|
||||
}
|
||||
for (const status of ['bestaetigt', 'abgerechnet', 'versendet']) {
|
||||
await updateAuftrag(alt.id, { status })
|
||||
const neu = await neueFassung(alt.id, { von: VON })
|
||||
await abgelehnt(neueFassung(alt.id, { von: VON }), 409, MELDUNG.schonEntwurf, status)
|
||||
await loescheEntwurf(neu.id)
|
||||
}
|
||||
await abgelehnt(neueFassung('a_gibtsnicht', { von: VON }), 404, MELDUNG.auftragFehlt)
|
||||
})
|
||||
|
||||
test('Senden der neuen Fassung: die alte AB wird ersetzt, ihre Rechnungen wandern mit, der alte Link bleibt auffindbar', async () => {
|
||||
const { deps } = baueDeps()
|
||||
const alt = await gesendet(deps)
|
||||
await createProjektRechnung('in_Anzahlung1', {
|
||||
kundenId: KUNDE_ID, projektId: PROJEKT_ID, auftragId: alt.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [],
|
||||
rechnungsnummer: 'WEBKLAR-0007', aktiv: true, erstelltVon: VON, erstelltAm: new Date().toISOString(),
|
||||
})
|
||||
const entwurf = await neueFassung(alt.id, { von: VON })
|
||||
await aendereEntwurf(entwurf.id, { positionen: [{ beschreibung: 'Webdesign Startseite', menge: 1, einzelpreisCents: 70000 }] })
|
||||
const { auftrag: neu } = await sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: new Date('2026-10-07T10:00:00Z') })
|
||||
assert.deepEqual([neu.nummer, neu.status, neu.ersetztId, neu.summeCents], ['AB-2026-0002', 'versendet', alt.id, 70000])
|
||||
const altNachher = await getAuftrag(alt.id)
|
||||
assert.deepEqual([altNachher.status, altNachher.ersetztDurchId, altNachher.tokenHash], ['ersetzt', neu.id, alt.tokenHash])
|
||||
assert.equal((await findAuftragByTokenHash(alt.tokenHash)).id, alt.id)
|
||||
assert.equal((await getProjektRechnung('in_Anzahlung1')).auftragId, neu.id)
|
||||
})
|
||||
|
||||
test('Senden der neuen Fassung: ist die alte AB inzwischen storniert, gibt es 409 vor der Nummer', async () => {
|
||||
const { deps, calls } = baueDeps()
|
||||
const alt = await gesendet(deps)
|
||||
const neu = await neueFassung(alt.id, { von: VON })
|
||||
await updateAuftrag(alt.id, { status: 'storniert' })
|
||||
await abgelehnt(sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }), 409, MELDUNG.ersetzteUngueltig)
|
||||
assert.equal(calls.pdf.length, 1) // nur die PDF der ersten AB
|
||||
const danach = await getAuftrag(neu.id)
|
||||
assert.deepEqual([danach.status, danach.nummer], ['entwurf', ''])
|
||||
assert.equal((await getAuftrag(alt.id)).status, 'storniert')
|
||||
})
|
||||
|
||||
test('Senden der neuen Fassung: scheitert das Speichern erst nach dem Ersetzen der alten AB, führt die Wiederholung alles zu Ende', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const { deps } = baueDeps()
|
||||
const alt = await gesendet(deps)
|
||||
await createProjektRechnung('in_Anzahlung2', {
|
||||
kundenId: KUNDE_ID, projektId: PROJEKT_ID, auftragId: alt.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [],
|
||||
rechnungsnummer: 'WEBKLAR-0008', aktiv: true, erstelltVon: VON, erstelltAm: new Date().toISOString(),
|
||||
})
|
||||
const neu = await neueFassung(alt.id, { von: VON })
|
||||
// erster PATCH auf auftraege: alte AB ersetzt (geht durch); zweiter PATCH: Speichern der neuen AB (scheitert)
|
||||
aw.failNext({ collection: config.collections.auftraege, method: 'PATCH', status: 500, after: 1 })
|
||||
await assert.rejects(sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }), (err) => err.status === 500)
|
||||
assert.equal((await getAuftrag(neu.id)).status, 'entwurf')
|
||||
assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // die Datei der neuen AB wurde wieder entfernt
|
||||
const { auftrag } = await sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT })
|
||||
assert.equal(auftrag.nummer, 'AB-2026-0002')
|
||||
assert.deepEqual([(await getAuftrag(alt.id)).status, (await getAuftrag(alt.id)).ersetztDurchId], ['ersetzt', neu.id])
|
||||
assert.equal((await getProjektRechnung('in_Anzahlung2')).auftragId, neu.id)
|
||||
})
|
||||
|
||||
test('Senden der neuen Fassung wartet auf die Sperre der alten AB und prüft sie danach noch einmal (L5 widerruft gerade)', async () => {
|
||||
const { deps, calls } = baueDeps()
|
||||
const alt = await gesendet(deps)
|
||||
const neu = await neueFassung(alt.id, { von: VON })
|
||||
const lauf = await unterFremderSperre(alt.id, {
|
||||
aktion: () => sendeAuftrag(neu.id, { von: VON, deps, jetzt: JETZT }),
|
||||
bereit: () => calls.pdf.length === 2, // das PDF der neuen AB ist fertig, als Nächstes kommt das Ersetzen
|
||||
waehrend: async () => {
|
||||
assert.equal((await getAuftrag(alt.id)).status, 'versendet') // noch nicht ersetzt
|
||||
await updateAuftrag(alt.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() })
|
||||
},
|
||||
})
|
||||
assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.ersetzteUngueltig])
|
||||
const danach = await getAuftrag(neu.id)
|
||||
assert.deepEqual([danach.status, danach.nummer], ['entwurf', ''])
|
||||
assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // die Datei der neuen AB wurde wieder entfernt
|
||||
assert.equal((await getAuftrag(alt.id)).status, 'widerrufen')
|
||||
})
|
||||
|
||||
test('Senden: zeigt die ersetztId eines Entwurfs auf ihn selbst (beschädigte Daten), gibt es 409 statt eines Hängers an der eigenen Sperre', { timeout: 5000 }, async () => {
|
||||
const { deps, calls } = baueDeps()
|
||||
const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
await updateAuftrag(entwurf.id, { ersetztId: entwurf.id })
|
||||
await abgelehnt(sendeAuftrag(entwurf.id, { von: VON, deps, jetzt: JETZT }), 409, MELDUNG.ersetzteUngueltig)
|
||||
assert.deepEqual([calls.pdf.length, calls.mail.length], [0, 0])
|
||||
const danach = await getAuftrag(entwurf.id)
|
||||
assert.deepEqual([danach.status, danach.nummer], ['entwurf', ''])
|
||||
assert.deepEqual(dateienIn(uploadDir), [])
|
||||
})
|
||||
|
||||
test('storniereAuftrag: versendet und bestätigt werden storniert (ohne Mail); Token, Nummer und PDF bleiben, damit der Link „storniert“ erklären kann', async () => {
|
||||
const { deps, calls } = baueDeps()
|
||||
const a = await gesendet(deps)
|
||||
const b = await storniereAuftrag(a.id)
|
||||
assert.equal(b.status, 'storniert')
|
||||
assert.ok(Math.abs(new Date(b.storniertAm).getTime() - Date.now()) < 60_000)
|
||||
assert.deepEqual([b.nummer, b.tokenHash, b.pdfDatei], [a.nummer, a.tokenHash, a.pdfDatei])
|
||||
assert.equal((await findAuftragByTokenHash(a.tokenHash)).id, a.id)
|
||||
assert.equal(calls.mail.length, 1) // nur die Mail des Sendens
|
||||
const c = await gesendet(deps)
|
||||
await updateAuftrag(c.id, { status: 'bestaetigt', bestaetigtAm: new Date().toISOString(), bestaetigtUeber: 'link' })
|
||||
assert.equal((await storniereAuftrag(c.id)).status, 'storniert')
|
||||
})
|
||||
|
||||
test('storniereAuftrag: sonst 409 (auch ein zweites Mal), unbekannt 404', async () => {
|
||||
const a = await gesendet(baueDeps().deps)
|
||||
for (const status of ['entwurf', 'abgerechnet', 'ersetzt', 'storniert', 'widerrufen']) {
|
||||
await updateAuftrag(a.id, { status })
|
||||
await abgelehnt(storniereAuftrag(a.id), 409, MELDUNG.keinStorno, status)
|
||||
}
|
||||
await abgelehnt(storniereAuftrag('a_gibtsnicht'), 404, MELDUNG.auftragFehlt)
|
||||
})
|
||||
|
||||
test('storniereAuftrag wartet auf die Sperre der AB (L5 hält sie beim Widerruf) und sieht danach den neuen Stand', async () => {
|
||||
const a = await gesendet(baueDeps().deps)
|
||||
const lauf = await unterFremderSperre(a.id, {
|
||||
aktion: () => storniereAuftrag(a.id),
|
||||
waehrend: async () => {
|
||||
assert.equal((await getAuftrag(a.id)).status, 'versendet') // noch nicht storniert
|
||||
await updateAuftrag(a.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() })
|
||||
},
|
||||
})
|
||||
assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keinStorno])
|
||||
assert.equal((await getAuftrag(a.id)).status, 'widerrufen')
|
||||
})
|
||||
|
||||
const OHNE_PROJEKT = { projektId: '', titel: 'Beratung ohne Projekt' }
|
||||
const ZWEITES_PROJEKT = 'p_zweites02'
|
||||
const legeZweitesProjektAn = () => aw.seed(config.collections.websiteProjects, projektDoc({ $id: ZWEITES_PROJEKT, projectName: 'Zweites Projekt', ticketId: ZWEITES_TICKET }))
|
||||
/** Alle Felder außer den genannten (für den Vergleich „sonst hat sich nichts geändert“). */
|
||||
const ohne = (auftrag, ...felder) => Object.fromEntries(Object.entries(auftrag).filter(([name]) => !felder.includes(name)))
|
||||
|
||||
test('ordneProjektZu: eine versendete AB ohne Projekt bekommt Projekt und Zeitquelle des Projekts; Nummer, Status, PDF, Prüfsumme, Token und Versanddaten bleiben', async () => {
|
||||
const { deps, calls } = baueDeps()
|
||||
const alt = await gesendet(deps, OHNE_PROJEKT)
|
||||
assert.deepEqual([alt.projektId, alt.zeitquelle], ['', []])
|
||||
const neu = await ordneProjektZu(alt.id, PROJEKT_ID)
|
||||
assert.deepEqual([neu.projektId, neu.zeitquelle], [PROJEKT_ID, [TICKET_ID]])
|
||||
assert.deepEqual(ohne(neu, 'projektId', 'zeitquelle', 'geaendertAm'), ohne(alt, 'projektId', 'zeitquelle', 'geaendertAm'))
|
||||
assert.deepEqual(await getAuftrag(alt.id), neu)
|
||||
assert.equal((await findAuftragByTokenHash(alt.tokenHash)).id, alt.id) // der Link gilt weiter
|
||||
assert.deepEqual(dateienIn(uploadDir), [alt.pdfDatei]) // keine neue Datei
|
||||
assert.deepEqual(await lesePdf(alt.pdfDatei), Buffer.from(`%PDF-1.4 ${alt.nummer}`))
|
||||
assert.deepEqual([calls.pdf.length, calls.token.length, calls.mail.length], [1, 1, 1]) // nichts neu erzeugt, keine Mail
|
||||
})
|
||||
|
||||
/** Projektrechnung einer AB, wie L2 sie anlegt (hier ohne Projekt); die Rechnungs-ID muss zu /^in_\w{1,33}$/ passen. */
|
||||
const projektRechnung = (auftragId, patch = {}) => ({
|
||||
kundenId: KUNDE_ID, projektId: '', auftragId, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [],
|
||||
rechnungsnummer: 'WEBKLAR-0007', aktiv: true, erstelltVon: VON, ...patch,
|
||||
})
|
||||
|
||||
test('ordneProjektZu: hängt auch die Projektrechnungen der AB ohne Projekt um (aktive und stornierte); schon gesetzte Projekte, andere ABs und alle übrigen Felder bleiben', async () => {
|
||||
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
|
||||
const andere = await gesendet(baueDeps().deps, OHNE_PROJEKT)
|
||||
await createProjektRechnung('in_OhneProjekt1', projektRechnung(a.id))
|
||||
await createProjektRechnung('in_OhneProjekt2', projektRechnung(a.id, { aktiv: false, rechnungsnummer: '' })) // storniert
|
||||
await createProjektRechnung('in_MitProjekt', projektRechnung(a.id, { projektId: 'p_anderes', art: 'aufwand' }))
|
||||
await createProjektRechnung('in_AndereAb', projektRechnung(andere.id))
|
||||
const mitProjekt = await getProjektRechnung('in_MitProjekt')
|
||||
const vorher = await getProjektRechnung('in_OhneProjekt1')
|
||||
await ordneProjektZu(a.id, PROJEKT_ID)
|
||||
const nachher = await getProjektRechnung('in_OhneProjekt1')
|
||||
assert.equal(nachher.projektId, PROJEKT_ID)
|
||||
assert.deepEqual(ohne(nachher, 'projektId'), ohne(vorher, 'projektId')) // sonst hat sich nichts geändert
|
||||
assert.equal((await getProjektRechnung('in_OhneProjekt2')).projektId, PROJEKT_ID, 'auch eine stornierte Projektrechnung')
|
||||
assert.deepEqual(await getProjektRechnung('in_MitProjekt'), mitProjekt) // schon ein anderes Projekt: bleibt
|
||||
assert.equal((await getProjektRechnung('in_AndereAb')).projektId, '') // andere AB: bleibt
|
||||
assert.equal((await getAuftrag(a.id)).projektId, PROJEKT_ID)
|
||||
})
|
||||
|
||||
test('ordneProjektZu: scheitert das Umhängen einer Projektrechnung, bleibt die AB ohne Projekt, und die Wiederholung führt alles zu Ende', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
|
||||
await createProjektRechnung('in_Wiederholung1', projektRechnung(a.id))
|
||||
await createProjektRechnung('in_Wiederholung2', projektRechnung(a.id))
|
||||
aw.failNext({ collection: config.collections.projektRechnungen, method: 'PATCH', status: 500, after: 1 }) // die erste geht durch, die zweite scheitert
|
||||
await assert.rejects(ordneProjektZu(a.id, PROJEKT_ID), (err) => err.status === 500)
|
||||
assert.deepEqual([(await getAuftrag(a.id)).projektId, (await getProjektRechnung('in_Wiederholung1')).projektId, (await getProjektRechnung('in_Wiederholung2')).projektId], ['', PROJEKT_ID, ''])
|
||||
assert.equal((await ordneProjektZu(a.id, PROJEKT_ID)).projektId, PROJEKT_ID) // kein 409: die AB hatte noch kein Projekt
|
||||
for (const id of ['in_Wiederholung1', 'in_Wiederholung2']) assert.equal((await getProjektRechnung(id)).projektId, PROJEKT_ID, id)
|
||||
})
|
||||
|
||||
test('ordneProjektZu: auch ein Entwurf bekommt das Projekt (der Titel bleibt); eine gewählte Zeitquelle bleibt; ein Projekt ohne Ticket ergibt keine Leereinträge', async () => {
|
||||
const entwurf = await legeEntwurfAn(festpreisEingabe({ ...OHNE_PROJEKT, titel: 'Freier Titel', zeitquelle: [ZWEITES_TICKET] }), { von: VON })
|
||||
const a = await ordneProjektZu(entwurf.id, PROJEKT_ID)
|
||||
assert.deepEqual([a.status, a.nummer, a.projektId, a.titel, a.zeitquelle], ['entwurf', '', PROJEKT_ID, 'Freier Titel', [ZWEITES_TICKET]])
|
||||
aw.seed(config.collections.websiteProjects, projektDoc({ $id: 'p_ohneticket', projectName: 'Projekt ohne Ticket', ticketId: '' }))
|
||||
const frei = await legeEntwurfAn(festpreisEingabe(OHNE_PROJEKT), { von: VON })
|
||||
const b = await ordneProjektZu(frei.id, 'p_ohneticket')
|
||||
assert.deepEqual([b.projektId, b.zeitquelle], ['p_ohneticket', []])
|
||||
})
|
||||
|
||||
test('ordneProjektZu: fehlende oder ungültige Kennung, Projekt eines anderen Kunden und unbekanntes Projekt sind 400, eine unbekannte AB ist 404 – nichts ändert sich', async () => {
|
||||
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
|
||||
const faelle = [
|
||||
['Kennung fehlt', undefined, MELDUNG.projektFehlt],
|
||||
['Kennung nur Leerzeichen', ' ', MELDUNG.projektFehlt],
|
||||
['Kennung keine Zeichenkette', 5, MELDUNG.projektFehlt],
|
||||
['Kennung mit ../', '../x', MELDUNG.projektUngueltig],
|
||||
['Projekt eines anderen Kunden', FREMD.projekt, MELDUNG.projektFremd],
|
||||
['unbekanntes Projekt', 'p_gibtsnicht', MELDUNG.projektFremd],
|
||||
]
|
||||
for (const [name, projektId, meldung] of faelle) await abgelehnt(ordneProjektZu(a.id, projektId), 400, meldung, name)
|
||||
await abgelehnt(ordneProjektZu('a_gibtsnicht', PROJEKT_ID), 404, MELDUNG.auftragFehlt)
|
||||
await abgelehnt(ordneProjektZu('../x', PROJEKT_ID), 404, MELDUNG.auftragFehlt)
|
||||
assert.deepEqual(await getAuftrag(a.id), a)
|
||||
})
|
||||
|
||||
test('ordneProjektZu: eine AB mit Projekt (auch dasselbe) sowie eine stornierte, ersetzte oder widerrufene AB lassen sich nicht zuordnen (409); versendet, bestätigt und abgerechnet gehen', async () => {
|
||||
const mit = await gesendet(baueDeps().deps) // gehört schon zu PROJEKT_ID
|
||||
legeZweitesProjektAn()
|
||||
await abgelehnt(ordneProjektZu(mit.id, PROJEKT_ID), 409, MELDUNG.schonProjekt, 'dasselbe Projekt')
|
||||
await abgelehnt(ordneProjektZu(mit.id, ZWEITES_PROJEKT), 409, MELDUNG.schonProjekt, 'anderes Projekt')
|
||||
await abgelehnt(ordneProjektZu(mit.id, FREMD.projekt), 409, MELDUNG.schonProjekt, 'fremdes Projekt: der Stand der AB gilt zuerst')
|
||||
assert.deepEqual(await getAuftrag(mit.id), mit)
|
||||
const frei = await gesendet(baueDeps().deps, OHNE_PROJEKT)
|
||||
for (const status of ['storniert', 'ersetzt', 'widerrufen']) {
|
||||
await updateAuftrag(frei.id, { status })
|
||||
await abgelehnt(ordneProjektZu(frei.id, PROJEKT_ID), 409, MELDUNG.keinProjekt, status)
|
||||
}
|
||||
assert.equal((await getAuftrag(frei.id)).projektId, '')
|
||||
for (const status of ['versendet', 'bestaetigt', 'abgerechnet']) {
|
||||
await updateAuftrag(frei.id, { status, projektId: '', zeitquelle: [] })
|
||||
assert.equal((await ordneProjektZu(frei.id, PROJEKT_ID)).projektId, PROJEKT_ID, status)
|
||||
}
|
||||
})
|
||||
|
||||
test('ordneProjektZu wartet auf die Sperre der AB und prüft den Stand danach (L5 widerruft gerade: 409)', async () => {
|
||||
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
|
||||
const lauf = await unterFremderSperre(a.id, {
|
||||
aktion: () => ordneProjektZu(a.id, PROJEKT_ID),
|
||||
waehrend: async () => {
|
||||
assert.equal((await getAuftrag(a.id)).projektId, '') // noch nichts geschrieben
|
||||
await updateAuftrag(a.id, { status: 'widerrufen', widerrufenAm: new Date().toISOString() })
|
||||
},
|
||||
})
|
||||
assert.deepEqual([lauf.fehler?.status, lauf.fehler?.message], [409, MELDUNG.keinProjekt])
|
||||
assert.equal((await getAuftrag(a.id)).projektId, '')
|
||||
})
|
||||
|
||||
test('ordneProjektZu: zwei Aufrufe gleichzeitig (zwei Admins) – genau einer gelingt, der andere ist 409', async () => {
|
||||
const a = await gesendet(baueDeps().deps, OHNE_PROJEKT)
|
||||
legeZweitesProjektAn()
|
||||
const ergebnisse = await Promise.allSettled([ordneProjektZu(a.id, PROJEKT_ID), ordneProjektZu(a.id, ZWEITES_PROJEKT)])
|
||||
assert.deepEqual(ergebnisse.map((r) => r.status).sort(), ['fulfilled', 'rejected'])
|
||||
const abgewiesen = ergebnisse.find((r) => r.status === 'rejected').reason
|
||||
assert.deepEqual([abgewiesen.status, abgewiesen.message], [409, MELDUNG.schonProjekt])
|
||||
const gewinner = ergebnisse.find((r) => r.status === 'fulfilled').value
|
||||
assert.deepEqual(await getAuftrag(a.id), gewinner)
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { starteUmgebung, VON, MELDUNG, festpreisEingabe, kundeDoc, baueDeps, abgelehnt, dateienIn } from './helpers/auftraegeAb.js'
|
||||
|
||||
const umg = await starteUmgebung()
|
||||
const { aw, config, uploadDir } = umg
|
||||
after(() => umg.schliesse())
|
||||
beforeEach(umg.zuruecksetzen)
|
||||
const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { kundeSnapshot } = await import('../server/services/auftraege/auftragSicht.js')
|
||||
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||
const { legeEntwurfAn, vorschauPdf, setzeDepsFuerTests } = await import('../server/services/auftraege/auftraege.js')
|
||||
beforeEach(() => setzeDepsFuerTests(null))
|
||||
|
||||
test('vorschauPdf: Entwurf mit „Entwurf“ als Nummer, aktuellem Kunden und den Einstellungen – ohne etwas zu speichern', async () => {
|
||||
aw.seed(config.collections.financeSettings, { $id: 'main', steuernummer: '12/345/67890', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' })
|
||||
const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
aw.seed(config.collections.customers, kundeDoc({ street: 'Neue Straße 5' })) // der Kunde ändert sich nach dem Entwurf
|
||||
const { deps, calls } = baueDeps()
|
||||
assert.deepEqual(await vorschauPdf(entwurf.id, { deps }), Buffer.from('%PDF-1.4 Entwurf'))
|
||||
assert.equal(calls.pdf.length, 1)
|
||||
const { auftrag, optionen } = calls.pdf[0]
|
||||
assert.deepEqual([auftrag.id, auftrag.nummer], [entwurf.id, 'Entwurf'])
|
||||
assert.deepEqual(auftrag.kunde, kundeSnapshot(kundeDoc({ street: 'Neue Straße 5' })))
|
||||
assert.equal(auftrag.kunde.strasse, 'Neue Straße 5')
|
||||
assert.deepEqual(optionen, { settings: { steuernummer: '12/345/67890', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' }, vorschau: true })
|
||||
assert.equal(calls.token.length + calls.mail.length, 0)
|
||||
assert.deepEqual(dateienIn(uploadDir), [])
|
||||
const danach = await getAuftrag(entwurf.id)
|
||||
assert.deepEqual([danach.status, danach.nummer, danach.kunde], ['entwurf', '', null])
|
||||
})
|
||||
|
||||
test('vorschauPdf: nur Entwürfe (409), unbekannt (404); wirft die PDF-Erzeugung oder liefert sie nichts, gibt es 500 mit fester Meldung; ein AuftragError der PDF-Funktion bleibt erhalten', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
await abgelehnt(vorschauPdf('a_gibtsnicht', { deps: baueDeps().deps }), 404, MELDUNG.auftragFehlt)
|
||||
await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps({ pdfFehler: new Error('pdfkit kaputt') }).deps }), 500, MELDUNG.pdf)
|
||||
await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps({ pdf: Buffer.alloc(0) }).deps }), 500, MELDUNG.pdf)
|
||||
const unvollstaendig = new AuftragError(409, 'Entwurf unvollständig: Anschrift fehlt.')
|
||||
await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps({ pdfFehler: unvollstaendig }).deps }), 409, 'Entwurf unvollständig: Anschrift fehlt.')
|
||||
await updateAuftrag(entwurf.id, { status: 'versendet' })
|
||||
await abgelehnt(vorschauPdf(entwurf.id, { deps: baueDeps().deps }), 409, 'Eine Vorschau gibt es nur für Entwürfe.')
|
||||
})
|
||||
|
||||
test('deps: ohne Angabe gilt der Ersatz aus setzeDepsFuerTests, ausdrückliche Angaben haben Vorrang, nötig ist nur die benutzte Funktion', async () => {
|
||||
const entwurf = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
const ersatz = baueDeps({ pdf: Buffer.from('%PDF-1.4 ersatz') })
|
||||
const eigene = baueDeps({ pdf: Buffer.from('%PDF-1.4 eigene') })
|
||||
setzeDepsFuerTests(ersatz.deps)
|
||||
assert.deepEqual(await vorschauPdf(entwurf.id), Buffer.from('%PDF-1.4 ersatz'))
|
||||
assert.deepEqual(await vorschauPdf(entwurf.id, { deps: { erzeugeAuftragPdf: eigene.deps.erzeugeAuftragPdf } }), Buffer.from('%PDF-1.4 eigene'))
|
||||
assert.deepEqual([ersatz.calls.pdf.length, eigene.calls.pdf.length], [1, 1])
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
// test/auftraegeAblage.test.js
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
// Die Ablage liest ihren Ordner aus config.auftraege.uploadDir: AUFTRAEGE_UPLOAD_DIR muss vor dem ersten Import von config.js stehen.
|
||||
// Die Ablage liegt in einem Unterordner der Wurzel, damit „Köder“-Dateien in der Wurzel einen Ausbruch aus der Ablage (../) sichtbar machen.
|
||||
const PRAEFIX = path.join(os.tmpdir(), 'auftraege-ablage-')
|
||||
const wurzel = mkdtempSync(PRAEFIX)
|
||||
const basis = path.join(wurzel, 'ablage')
|
||||
mkdirSync(basis, { mode: 0o700 })
|
||||
process.env.AUFTRAEGE_UPLOAD_DIR = basis
|
||||
after(() => {
|
||||
if (wurzel.startsWith(PRAEFIX)) rmSync(wurzel, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const { speicherePdf, lesePdf, entfernePdf } = await import('../server/services/auftraege/ablage.js')
|
||||
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||
|
||||
const PDF = Buffer.from('%PDF-1.7\n% Auftragsbestätigung\n%%EOF\n')
|
||||
const sha = (buffer) => createHash('sha256').update(buffer).digest('hex')
|
||||
const nichtGefunden = (err) => err instanceof AuftragError && err.status === 404 && err.message === 'PDF nicht gefunden.'
|
||||
|
||||
test('speicherePdf legt <kundenId>/<auftragId>.pdf an: Prüfsumme, Rechte 0700 und 0600, Inhalt unverändert', async () => {
|
||||
const ergebnis = await speicherePdf('c1', 'ab1', PDF)
|
||||
assert.deepEqual(ergebnis, { pdfDatei: 'c1/ab1.pdf', pdfSha256: sha(PDF) })
|
||||
assert.match(ergebnis.pdfSha256, /^[0-9a-f]{64}$/)
|
||||
const datei = path.join(basis, 'c1', 'ab1.pdf')
|
||||
assert.deepEqual(readFileSync(datei), PDF)
|
||||
assert.equal(statSync(datei).mode & 0o777, 0o600)
|
||||
assert.equal(statSync(path.join(basis, 'c1')).mode & 0o777, 0o700)
|
||||
assert.deepEqual(await lesePdf('c1/ab1.pdf'), PDF)
|
||||
})
|
||||
|
||||
test('speicherePdf überschreibt nie: die vorhandene Datei bleibt, der zweite Aufruf ist 409', async () => {
|
||||
await speicherePdf('c2', 'ab1', PDF)
|
||||
await assert.rejects(
|
||||
() => speicherePdf('c2', 'ab1', Buffer.from('%PDF-1.7\nanderer Inhalt\n')),
|
||||
(err) => err instanceof AuftragError && err.status === 409,
|
||||
)
|
||||
assert.deepEqual(readFileSync(path.join(basis, 'c2', 'ab1.pdf')), PDF)
|
||||
assert.equal((await speicherePdf('c3', 'ab1', PDF)).pdfDatei, 'c3/ab1.pdf', 'dieselbe AB-ID bei einem anderen Kunden ist eine andere Datei')
|
||||
})
|
||||
|
||||
test('speicherePdf: ungültige Kennungen und Puffer sind Programmfehler und legen nichts an', async () => {
|
||||
const vorher = readdirSync(basis).sort()
|
||||
const kennungen = [['', 'ab1'], ['c1', ''], ['../x', 'ab1'], ['c1', '../ab1'], ['c1/x', 'ab1'], ['c1', 'a.b'], ['c1', 'a'.repeat(65)], [undefined, 'ab1'], ['c1', 5]]
|
||||
for (const [kunde, ab] of kennungen) await assert.rejects(() => speicherePdf(kunde, ab, PDF), TypeError, JSON.stringify([kunde, ab]))
|
||||
for (const buffer of [Buffer.alloc(0), 'text', undefined, null, new Uint8Array(3)]) {
|
||||
await assert.rejects(() => speicherePdf('c9', 'ab9', buffer), TypeError, String(buffer))
|
||||
}
|
||||
assert.deepEqual(readdirSync(basis).sort(), vorher)
|
||||
})
|
||||
|
||||
test('lesePdf: Pfadtricks, fremde Zeichen und falsche Form sind 404; erlaubt sind 64 Zeichen aus A–Z, a–z, 0–9, _ und -', async () => {
|
||||
await speicherePdf('c4', 'ab1', PDF)
|
||||
writeFileSync(path.join(wurzel, 'geheim.pdf'), 'GEHEIM') // neben der Ablage: ein Ausbruch per ../ würde sie lesen
|
||||
const falsch = [
|
||||
'c4/../geheim.pdf', '../geheim.pdf', 'c4/ab1.pdf/../../geheim.pdf', '/c4/ab1.pdf', `${basis}/c4/ab1.pdf`, 'c4\\ab1.pdf',
|
||||
'c4/ab1.PDF', 'c4/ab1.pdf\0', 'c4/ab1.pdf.exe', 'c4/ab 1.pdf', 'c4/ab1', 'ab1.pdf', 'c4/x/ab1.pdf', '', ' ',
|
||||
`${'a'.repeat(65)}/ab1.pdf`, `c4/${'a'.repeat(65)}.pdf`, 'c4/ä.pdf', undefined, null, 42, {},
|
||||
]
|
||||
for (const pdfDatei of falsch) await assert.rejects(() => lesePdf(pdfDatei), nichtGefunden, JSON.stringify(pdfDatei))
|
||||
await assert.rejects(() => lesePdf('c4/gibtsnicht.pdf'), nichtGefunden)
|
||||
const lang = `${'A'.repeat(32)}_${'b'.repeat(31)}`
|
||||
await speicherePdf('K_1-x', lang, PDF)
|
||||
assert.deepEqual(await lesePdf(`K_1-x/${lang}.pdf`), PDF)
|
||||
})
|
||||
|
||||
test('entfernePdf entfernt eine gespeicherte Datei und prüft den Pfad wie lesePdf; danach lässt sich dieselbe AB erneut speichern', async () => {
|
||||
await speicherePdf('c5', 'ab1', PDF)
|
||||
await entfernePdf('c5/ab1.pdf')
|
||||
assert.equal(existsSync(path.join(basis, 'c5', 'ab1.pdf')), false)
|
||||
await entfernePdf('c5/ab1.pdf')
|
||||
await assert.rejects(() => lesePdf('c5/ab1.pdf'), nichtGefunden)
|
||||
writeFileSync(path.join(wurzel, 'geheim2.pdf'), 'x')
|
||||
for (const pfad of ['../geheim2.pdf', 'c5/../geheim2.pdf']) await assert.rejects(() => entfernePdf(pfad), nichtGefunden, pfad)
|
||||
assert.ok(existsSync(path.join(wurzel, 'geheim2.pdf')))
|
||||
assert.equal((await speicherePdf('c5', 'ab1', PDF)).pdfDatei, 'c5/ab1.pdf')
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,196 @@
|
||||
// test/auftraegeArbeitsblaetter.test.js
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
|
||||
|
||||
// worksheets gehört dem Ticketsystem und hat im Nachbau kein Schema (jedes Feld ist erlaubt).
|
||||
const aw = await startAppwriteMock({ schema: { projektRechnungen: schemaKeys('projektRechnungen') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
|
||||
after(() => aw.close())
|
||||
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 { createProjektRechnung, updateProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
|
||||
const A = await import('../server/services/auftraege/arbeitsblaetter.js')
|
||||
|
||||
const WS = config.collections.worksheets
|
||||
const PR = config.collections.projektRechnungen
|
||||
beforeEach(async () => {
|
||||
for (const col of [WS, PR]) for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id)
|
||||
aw.resetListCalls()
|
||||
})
|
||||
|
||||
const UNTERSTUETZUNG = JSON.stringify([{ employeeId: 'e2', name: 'Justin Klein', task: 'Review' }])
|
||||
const ZWEI_UNTERSTUETZER = JSON.stringify([
|
||||
{ employeeId: 'e2', name: 'Justin Klein', task: 'Review' },
|
||||
{ employeeId: 'e3', name: 'Nico', task: 'Test' },
|
||||
])
|
||||
const blatt = (id, felder = {}) =>
|
||||
aw.seed(WS, {
|
||||
$id: id, workorderId: 't1', woid: '100101', wsid: id, startDate: '06.10.2026', endDate: '06.10.2026', totalTime: 60,
|
||||
details: 'Startseite überarbeitet', serviceType: 'REMOTE', employeeName: 'Kenso Grimm', countsToPlan: false, isComment: false, supporters: '', ...felder,
|
||||
})
|
||||
const nachId = async (ticketIds = ['t1']) => Object.fromEntries((await A.ladeArbeitsblaetter(ticketIds)).map((b) => [b.id, b]))
|
||||
|
||||
test('ladeArbeitsblaetter liefert die normierte Form je Blatt', async () => {
|
||||
blatt('w1')
|
||||
assert.deepEqual(await A.ladeArbeitsblaetter(['t1']), [
|
||||
{ id: 'w1', ticketId: 't1', woid: '100101', datum: '2026-10-06', minuten: 60, text: 'Startseite überarbeitet', aboZeit: false, kommentar: false },
|
||||
])
|
||||
})
|
||||
|
||||
test('Datum eines Arbeitsblatts: TT.MM.JJJJ, ISO als Berliner Tag, leer fällt auf $createdAt, ein unmögliches Datum auch', async () => {
|
||||
const faelle = {
|
||||
d1: [{ startDate: '06.10.2026' }, '2026-10-06'],
|
||||
d2: [{ startDate: '', endDate: '07.10.2026' }, '2026-10-07'],
|
||||
d3: [{ startDate: '2026-10-06T21:59:59.000Z' }, '2026-10-06'], // 23:59:59 in Berlin
|
||||
d4: [{ startDate: '2026-10-06T22:30:00.000Z' }, '2026-10-07'], // 00:30 in Berlin am nächsten Tag
|
||||
d5: [{ startDate: '2026-10-06' }, '2026-10-06'],
|
||||
d6: [{ startDate: '', $createdAt: '2026-10-08T21:30:00.000+00:00' }, '2026-10-08'],
|
||||
d7: [{ startDate: '', $createdAt: '2026-10-08T22:00:00.000+00:00' }, '2026-10-09'],
|
||||
d8: [{ startDate: '31.02.2026', $createdAt: '2026-10-01T10:00:00.000+00:00' }, '2026-10-01'],
|
||||
d9: [{ startDate: 'gestern', $createdAt: '2026-10-02T10:00:00.000+00:00' }, '2026-10-02'],
|
||||
d10: [{ startDate: '12.13.2026', $createdAt: '2026-10-03T10:00:00.000+00:00' }, '2026-10-03'], // nie als Monatsangabe der US-Schreibweise lesen
|
||||
}
|
||||
for (const [id, [felder]] of Object.entries(faelle)) blatt(id, { endDate: '', ...felder })
|
||||
const datum = Object.fromEntries(Object.values(await nachId()).map((b) => [b.id, b.datum]))
|
||||
assert.deepEqual(datum, Object.fromEntries(Object.entries(faelle).map(([id, [, tag]]) => [id, tag])))
|
||||
})
|
||||
|
||||
test('Minuten: je Blatt inklusive Unterstützung auf 15 Minuten aufgerundet', async () => {
|
||||
const faelle = [
|
||||
['m1', { totalTime: 10 }, 15],
|
||||
['m2', { totalTime: 15 }, 15],
|
||||
['m3', { totalTime: 16 }, 30],
|
||||
['m4', { totalTime: 0 }, 0],
|
||||
['m5', { totalTime: 'abc' }, 0],
|
||||
['m6', { totalTime: 80, supporters: UNTERSTUETZUNG }, 135], // 80 + 45 Unterstützung = 125
|
||||
['m7', { totalTime: 20, supporters: UNTERSTUETZUNG }, 45], // 20 + 15 = 35
|
||||
['m8', { totalTime: 60, supporters: ZWEI_UNTERSTUETZER }, 120], // 60 + 30 + 30
|
||||
]
|
||||
for (const [id, felder] of faelle) blatt(id, felder)
|
||||
const minuten = Object.fromEntries(Object.values(await nachId()).map((b) => [b.id, b.minuten]))
|
||||
assert.deepEqual(minuten, Object.fromEntries(faelle.map(([id, , erwartet]) => [id, erwartet])))
|
||||
})
|
||||
|
||||
test('Abo-Zeit und Kommentar werden erkannt und sind nie abrechenbar, ebenso 0 Minuten', async () => {
|
||||
blatt('f1')
|
||||
blatt('f2', { countsToPlan: true })
|
||||
blatt('f3', { isComment: true, totalTime: 0 })
|
||||
blatt('f4', { totalTime: 0 })
|
||||
const b = await nachId()
|
||||
assert.deepEqual([b.f1.aboZeit, b.f1.kommentar, A.abrechenbar(b.f1)], [false, false, true])
|
||||
assert.deepEqual([b.f2.aboZeit, A.abrechenbar(b.f2)], [true, false])
|
||||
assert.deepEqual([b.f3.kommentar, A.abrechenbar(b.f3)], [true, false])
|
||||
assert.equal(A.abrechenbar(b.f4), false)
|
||||
const basis = { aboZeit: false, kommentar: false, minuten: 15 }
|
||||
assert.equal(A.abrechenbar(basis), true)
|
||||
assert.equal(A.abrechenbar({ ...basis, aboZeit: true }), false)
|
||||
assert.equal(A.abrechenbar({ ...basis, kommentar: true }), false)
|
||||
assert.equal(A.abrechenbar({ ...basis, minuten: 0 }), false)
|
||||
})
|
||||
|
||||
test('Text: wie in der Kundenansicht, eine Zeile, höchstens 200 Zeichen, Git ohne Kürzel, Link und Namen', async () => {
|
||||
blatt('x1', { details: ' Startseite\n\nKontaktformular überarbeitet ' })
|
||||
blatt('x2', { details: 'ä'.repeat(300) })
|
||||
blatt('x3', {
|
||||
serviceType: 'GIT',
|
||||
totalTime: 45,
|
||||
details: [
|
||||
'Git Push auf `knso/x` (Branch: main)',
|
||||
'Commit: 88d3c37',
|
||||
'',
|
||||
'- [88d3c37](https://git.webklar.com/knso/x/commit/88d3c37ab7b15337772cf59a5a9294ec0cc8cca4): feat: Kontaktformular repariert (Kenso Grimm)',
|
||||
'- [1a2b3c4](https://git.webklar.com/knso/x/commit/1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b): fix: Footer',
|
||||
'',
|
||||
].join('\n'),
|
||||
})
|
||||
blatt('x4', { isComment: true, details: 'nur intern' })
|
||||
blatt('x5', { details: '' })
|
||||
const b = await nachId()
|
||||
assert.equal(b.x1.text, 'Startseite Kontaktformular überarbeitet')
|
||||
assert.equal(b.x2.text.length, 200)
|
||||
assert.ok(b.x2.text.endsWith('…'))
|
||||
assert.equal(b.x3.text, 'Kontaktformular repariert; Footer')
|
||||
assert.doesNotMatch(b.x3.text, /88d3c37|git\.webklar|Kenso/)
|
||||
assert.equal(b.x4.text, '')
|
||||
assert.equal(b.x5.text, '')
|
||||
})
|
||||
|
||||
test('ladeArbeitsblaetter: nur die genannten Tickets, nach Datum und ID sortiert, in Blöcken zu 100 Tickets', async () => {
|
||||
blatt('w3', { startDate: '07.10.2026' })
|
||||
blatt('w2', { startDate: '06.10.2026' })
|
||||
blatt('w1', { startDate: '06.10.2026' })
|
||||
blatt('w9', { workorderId: 't2', startDate: '05.10.2026' })
|
||||
blatt('w8', { workorderId: 't3' })
|
||||
assert.deepEqual((await A.ladeArbeitsblaetter(['t1', 't2'])).map((b) => b.id), ['w9', 'w1', 'w2', 'w3'])
|
||||
assert.deepEqual((await A.ladeArbeitsblaetter(['t1', 't1', '', null, 5])).map((b) => b.id), ['w1', 'w2', 'w3'])
|
||||
aw.resetListCalls()
|
||||
for (const leer of [[], undefined, null, [''], 'kein-array']) assert.deepEqual(await A.ladeArbeitsblaetter(leer), [], String(leer))
|
||||
assert.equal(aw.listCalls(WS).length, 0, 'ohne Ticket keine Abfrage')
|
||||
const viele = Array.from({ length: 250 }, (_, i) => `t_${i}`)
|
||||
viele[7] = 't1'
|
||||
assert.deepEqual((await A.ladeArbeitsblaetter(viele)).map((b) => b.id), ['w1', 'w2', 'w3'])
|
||||
const aufrufe = aw.listCalls(WS)
|
||||
assert.equal(aufrufe.length, 3)
|
||||
for (const aufruf of aufrufe) {
|
||||
assert.ok(aufruf.queries.find((q) => q.method === 'equal' && q.attribute === 'workorderId').values.length <= 100)
|
||||
}
|
||||
})
|
||||
|
||||
test('ladeArbeitsblaetterNachIds liefert nur vorhandene Blätter, sortiert wie ladeArbeitsblaetter', async () => {
|
||||
blatt('w1', { startDate: '07.10.2026' })
|
||||
blatt('w2', { workorderId: 't2', startDate: '06.10.2026' })
|
||||
assert.deepEqual((await A.ladeArbeitsblaetterNachIds(['w1', 'w2', 'gibtsnicht', 'w1'])).map((b) => [b.id, b.ticketId]), [['w2', 't2'], ['w1', 't1']])
|
||||
assert.deepEqual(await A.ladeArbeitsblaetterNachIds([]), [])
|
||||
})
|
||||
|
||||
test('abgerechneteArbeitsblaetter: nur aktive Projektrechnungen dieses Kunden; ein Entwurf hat die Nummer leer', async () => {
|
||||
const daten = (extra) => ({ kundenId: 'c1', projektId: 'p1', art: 'aufwand', betragCents: 1000, ...extra })
|
||||
await createProjektRechnung('in_gesendet', daten({ arbeitsblattIds: ['w1', 'w2'] }))
|
||||
await updateProjektRechnung('in_gesendet', { rechnungsnummer: 'WEBKLAR-0009' })
|
||||
await createProjektRechnung('in_entwurf', daten({ arbeitsblattIds: ['w5'] }))
|
||||
await createProjektRechnung('in_storniert', daten({ arbeitsblattIds: ['w3'] }))
|
||||
await updateProjektRechnung('in_storniert', { aktiv: false })
|
||||
await createProjektRechnung('in_fremd', daten({ kundenId: 'c2', arbeitsblattIds: ['w4'] }))
|
||||
const karte = await A.abgerechneteArbeitsblaetter('c1')
|
||||
assert.ok(karte instanceof Map)
|
||||
assert.deepEqual([...karte.entries()].sort(), [
|
||||
['w1', { invoiceId: 'in_gesendet', rechnungsnummer: 'WEBKLAR-0009' }],
|
||||
['w2', { invoiceId: 'in_gesendet', rechnungsnummer: 'WEBKLAR-0009' }],
|
||||
['w5', { invoiceId: 'in_entwurf', rechnungsnummer: '' }],
|
||||
])
|
||||
await assert.rejects(() => A.abgerechneteArbeitsblaetter(''), TypeError)
|
||||
})
|
||||
|
||||
test('offeneArbeitsblaetter: abrechenbar und noch nicht abgerechnet; ein stornierter Eintrag gibt das Blatt frei', async () => {
|
||||
blatt('w1') // abgerechnet (siehe unten)
|
||||
blatt('w6', { totalTime: 30 }) // offen
|
||||
blatt('w7', { countsToPlan: true }) // Abo-Zeit
|
||||
blatt('w8', { isComment: true, totalTime: 0 }) // Kommentar
|
||||
blatt('w9', { totalTime: 0 }) // 0 Minuten
|
||||
blatt('w10', { workorderId: 't2' }) // anderes Ticket
|
||||
await createProjektRechnung('in_a', { kundenId: 'c1', projektId: 'p1', art: 'aufwand', betragCents: 1000, arbeitsblattIds: ['w1'] })
|
||||
assert.deepEqual((await A.offeneArbeitsblaetter(['t1'], 'c1')).map((b) => b.id), ['w6'])
|
||||
await updateProjektRechnung('in_a', { aktiv: false })
|
||||
assert.deepEqual((await A.offeneArbeitsblaetter(['t1'], 'c1')).map((b) => b.id), ['w1', 'w6'])
|
||||
})
|
||||
|
||||
test('zeitquelleFuerProjekt: Vereinigung der vereinbarten ABs, sonst das Ticket des Projekts', () => {
|
||||
const ab = (status, zeitquelle) => ({ status, zeitquelle })
|
||||
const projekt = { $id: 'p1', ticketId: 't0' }
|
||||
const alle = [
|
||||
ab('versendet', ['t1', 't2']), ab('bestaetigt', ['t2', 't3']), ab('abgerechnet', ['t4']),
|
||||
ab('entwurf', ['t9']), ab('storniert', ['t8']), ab('ersetzt', ['t7']), ab('widerrufen', ['t6']),
|
||||
]
|
||||
assert.deepEqual(A.zeitquelleFuerProjekt(projekt, alle), ['t1', 't2', 't3', 't4'])
|
||||
assert.deepEqual(A.zeitquelleFuerProjekt(projekt, []), ['t0'])
|
||||
assert.deepEqual(A.zeitquelleFuerProjekt(projekt, [ab('entwurf', ['t9'])]), ['t0'])
|
||||
assert.deepEqual(A.zeitquelleFuerProjekt(projekt, [ab('versendet', [])]), ['t0'])
|
||||
assert.deepEqual(A.zeitquelleFuerProjekt({ $id: 'p1' }, []), [])
|
||||
assert.deepEqual(A.zeitquelleFuerProjekt({ ticketId: '' }, undefined), [])
|
||||
assert.deepEqual(A.zeitquelleFuerProjekt(null, []), [])
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
// test/auftraegeAuftragSicht.test.js
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { kundeSnapshot, fruehesterBeginn, zahlungsplanText, aufwandText, auftragFuerTeam, oeffentlicheAnsicht } from '../server/services/auftraege/auftragSicht.js'
|
||||
import { AGB_FASSUNG } from '../server/services/auftraege/konstanten.js'
|
||||
import { AB_TEXTE, BESTAETIGUNG_TEXT, FRUEHBEGINN_TEXT } from '../server/services/auftraege/texte.js'
|
||||
import { euroDe } from '../server/services/finance/format.js'
|
||||
|
||||
const KUNDE = { name: 'Anna Beispiel', firma: '', strasse: 'Hauptstr. 1', plz: '67722', ort: 'Winnweiler', land: '', email: 'anna@example.invalid', kundenart: 'privat', kundennummer: 'K-1001' }
|
||||
const festpreis = (felder = {}) => ({
|
||||
id: 'ab1', nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2, status: 'versendet', kundenId: 'c1', kunde: KUNDE, projektId: 'p1', titel: 'Website Bäckerei',
|
||||
zeitquelle: ['t1'], art: 'festpreis',
|
||||
positionen: [{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 30000 }, { beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 20000 }],
|
||||
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14,
|
||||
auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '2026-11-01', fertigBis: '', ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30',
|
||||
pdfDatei: 'c1/ab1.pdf', pdfSha256: 'a'.repeat(64), gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'anna@example.invalid', mailStatus: 'gesendet', mailFehler: '',
|
||||
tokenHash: 'b'.repeat(64), tokenGueltigBis: '2026-12-05T10:00:00.000Z', bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '',
|
||||
storniertAm: null, widerrufenAm: null, erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-05T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z', ...felder,
|
||||
})
|
||||
const aufwand = (felder = {}) =>
|
||||
festpreis({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, ...felder })
|
||||
const LEER = { name: '', firma: '', strasse: '', plz: '', ort: '', land: '', email: '', kundenart: '', kundennummer: '' }
|
||||
|
||||
test('kundeSnapshot übernimmt die Kundendaten getrimmt; eine unbekannte Kundenart wird leer; null und {} sind leer', () => {
|
||||
const doc = { $id: 'c1', name: ' Anna Beispiel ', companyName: '', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', country: '', email: 'anna@example.invalid', kundenart: 'privat', code: 'K-1001', phone: '123', customerStatus: 'active' }
|
||||
assert.deepEqual(kundeSnapshot(doc), KUNDE)
|
||||
assert.deepEqual(kundeSnapshot({ ...doc, companyName: ' Bäckerei Beispiel GmbH ', kundenart: 'unternehmen' }), { ...KUNDE, firma: 'Bäckerei Beispiel GmbH', kundenart: 'unternehmen' })
|
||||
assert.equal(kundeSnapshot({ name: 'X', kundenart: 'firma' }).kundenart, '')
|
||||
assert.deepEqual(kundeSnapshot({}), LEER)
|
||||
assert.deepEqual(kundeSnapshot(null), LEER)
|
||||
})
|
||||
|
||||
test('kundeSnapshot kürzt lange Werte, so dass die Spalte kundeJson (2000 Zeichen) reicht', () => {
|
||||
const lang = 'x'.repeat(500)
|
||||
const snapshot = kundeSnapshot({ name: lang, companyName: lang, street: lang, postalCode: lang, city: lang, country: lang, email: lang, kundenart: 'privat', code: lang })
|
||||
assert.ok(JSON.stringify(snapshot).length <= 2000)
|
||||
assert.equal(snapshot.name.length, 120)
|
||||
})
|
||||
|
||||
test('fruehesterBeginn: Tag nach Ablauf der Widerrufsfrist, nur für Privatpersonen ohne Frühbeginn', () => {
|
||||
const faelle = [
|
||||
['2026-10-06T10:00:00.000Z', '2026-10-21'],
|
||||
['2026-10-06T22:30:00.000Z', '2026-10-22'], // in Berlin schon der 07.10.
|
||||
['2026-10-10T08:00:00.000Z', '2026-10-27'], // Fristende fiele auf Samstag, 26.10. ist der Montag
|
||||
['2026-10-17T08:00:00.000Z', '2026-11-03'], // 31.10. (Samstag, Feiertag) und 01.11. (Sonntag), Fristende Montag 02.11.
|
||||
]
|
||||
for (const [gesendetAm, erwartet] of faelle) assert.equal(fruehesterBeginn(festpreis({ gesendetAm })), erwartet, gesendetAm)
|
||||
assert.equal(fruehesterBeginn(festpreis({ fruehbeginn: true })), null)
|
||||
for (const kundenart of ['unternehmen', '']) assert.equal(fruehesterBeginn(festpreis({ kunde: { ...KUNDE, kundenart } })), null, kundenart)
|
||||
assert.equal(fruehesterBeginn(festpreis({ kunde: null })), null)
|
||||
assert.equal(fruehesterBeginn(festpreis({ gesendetAm: null, status: 'entwurf' })), null)
|
||||
assert.equal(fruehesterBeginn(festpreis({ gesendetAm: 'kaputt' })), null)
|
||||
})
|
||||
|
||||
test('zahlungsplanText: Anzahlung nach Prozent gerundet, Rest als Differenz', () => {
|
||||
const text = zahlungsplanText(festpreis())
|
||||
assert.equal(text.split(euroDe(40000)).length, 3, text) // Anzahlung und Rest je 400,00 €
|
||||
assert.match(text, /50\s?%/)
|
||||
assert.match(text, /\b14\b/)
|
||||
// Math.round(10001 × 33 / 100) = 3300, Rest 6701
|
||||
const ungerade = zahlungsplanText(festpreis({ summeCents: 10001, zahlungsplan: { art: 'anzahlung', prozent: 33, text: '' } }))
|
||||
assert.ok(ungerade.includes(euroDe(3300)) && ungerade.includes(euroDe(6701)), ungerade)
|
||||
})
|
||||
|
||||
test('zahlungsplanText: nach Abnahme, monatlich, frei; Anzahlung ohne Summe fällt auf nach Abnahme zurück', () => {
|
||||
assert.match(zahlungsplanText(festpreis({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })), /\b14\b/)
|
||||
assert.match(zahlungsplanText(aufwand({ zahlungszielTage: 7 })), /\b7\b/)
|
||||
assert.ok(zahlungsplanText(festpreis({ zahlungsplan: { art: 'frei', prozent: null, text: 'Zahlung in zwei Raten.' } })).includes('Zahlung in zwei Raten'))
|
||||
const ohneSumme = aufwand({ zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' } })
|
||||
assert.equal(zahlungsplanText(ohneSumme), zahlungsplanText({ ...ohneSumme, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } }))
|
||||
})
|
||||
|
||||
test('aufwandText: nur bei Aufwand, mit Stundensatz und Schätzung in Stunden', () => {
|
||||
const text = aufwandText(aufwand())
|
||||
assert.ok(text.includes(euroDe(10000)))
|
||||
assert.match(text, /\b10\b/)
|
||||
assert.doesNotMatch(aufwandText(aufwand({ schaetzungMinuten: null })), /\b10\b/)
|
||||
assert.match(aufwandText(aufwand({ schaetzungMinuten: 135 })), /2,25/)
|
||||
assert.match(aufwandText(aufwand({ schaetzungMinuten: 90 })), /1,5\b/)
|
||||
assert.ok(aufwandText(aufwand({ stundensatzCents: null })).includes(euroDe(10000)), 'ohne Satz gilt der Standard')
|
||||
assert.equal(aufwandText(festpreis()), null)
|
||||
})
|
||||
|
||||
test('auftragFuerTeam: ohne Token-Hash, PDF-Pfad und Bestätigungs-Prüfsumme, mit den abgeleiteten Feldern', () => {
|
||||
const auftrag = festpreis()
|
||||
const kopie = structuredClone(auftrag)
|
||||
const sicht = auftragFuerTeam(auftrag)
|
||||
assert.deepEqual(auftrag, kopie, 'verändert die AB nicht')
|
||||
const entfernt = ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']
|
||||
for (const feld of entfernt) assert.equal(Object.hasOwn(sicht, feld), false, feld)
|
||||
assert.equal(sicht.pdfSha256, 'a'.repeat(64))
|
||||
assert.equal(sicht.pdfVorhanden, true)
|
||||
assert.equal(sicht.fruehesterBeginn, '2026-10-21')
|
||||
assert.equal(sicht.zahlungsplanText, zahlungsplanText(auftrag))
|
||||
assert.equal(sicht.aufwandText, null)
|
||||
assert.deepEqual(
|
||||
Object.keys(sicht).sort(),
|
||||
[...Object.keys(auftrag).filter((k) => !entfernt.includes(k)), 'pdfVorhanden', 'fruehesterBeginn', 'zahlungsplanText', 'aufwandText'].sort(),
|
||||
)
|
||||
assert.equal(auftragFuerTeam(festpreis({ pdfDatei: '' })).pdfVorhanden, false)
|
||||
})
|
||||
|
||||
test('oeffentlicheAnsicht: genau die Felder aus V3, Beträge je Position, Berliner Datum, nichts Internes', () => {
|
||||
const ansicht = oeffentlicheAnsicht(festpreis())
|
||||
assert.deepEqual(
|
||||
Object.keys(ansicht).sort(),
|
||||
['agbUrl', 'aufwandText', 'art', 'bestaetigtAm', 'datum', 'fertigBis', 'fruehbeginn', 'fruehesterBeginn', 'kundenart', 'nummer', 'positionen', 'schaetzungMinuten', 'startAm', 'status', 'stundensatzCents', 'summeCents', 'texte', 'titel', 'zahlungsplanText'].sort(),
|
||||
)
|
||||
assert.deepEqual(ansicht.positionen, [
|
||||
{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 30000, summeCents: 60000 },
|
||||
{ beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 20000, summeCents: 20000 },
|
||||
])
|
||||
assert.deepEqual(
|
||||
[ansicht.nummer, ansicht.datum, ansicht.titel, ansicht.art, ansicht.summeCents, ansicht.startAm, ansicht.kundenart, ansicht.status],
|
||||
['AB-2026-0002', '2026-10-06', 'Website Bäckerei', 'festpreis', 80000, '2026-11-01', 'privat', 'versendet'],
|
||||
)
|
||||
assert.equal(ansicht.fruehesterBeginn, '2026-10-21')
|
||||
assert.deepEqual(ansicht.texte, { bestaetigung: BESTAETIGUNG_TEXT, fruehbeginn: FRUEHBEGINN_TEXT, kleinunternehmer: AB_TEXTE.kleinunternehmer })
|
||||
assert.equal(ansicht.texte.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
|
||||
assert.equal(ansicht.agbUrl, '/rechtliches/agb-2026-09-30.pdf')
|
||||
const text = JSON.stringify(ansicht)
|
||||
for (const geheim of ['b'.repeat(64), 'c1/ab1.pdf', 'anna@example.invalid', 'Hauptstr. 1', 'kenso@webklar.com']) assert.ok(!text.includes(geheim), geheim)
|
||||
})
|
||||
|
||||
test('oeffentlicheAnsicht: Unternehmen ohne Frühbeginn-Text, Datum nach Berliner Zeit, AGB-Adresse ohne gespeicherte Fassung', () => {
|
||||
const firma = oeffentlicheAnsicht(festpreis({ kunde: { ...KUNDE, kundenart: 'unternehmen' }, gesendetAm: '2026-10-06T22:30:00.000Z', agbFassung: '' }))
|
||||
assert.equal(firma.texte.fruehbeginn, null)
|
||||
assert.equal(firma.texte.kleinunternehmer, AB_TEXTE.kleinunternehmer)
|
||||
assert.equal(firma.fruehesterBeginn, null)
|
||||
assert.equal(firma.datum, '2026-10-07')
|
||||
assert.equal(firma.agbUrl, `/rechtliches/agb-${AGB_FASSUNG}.pdf`)
|
||||
const aufwandAnsicht = oeffentlicheAnsicht(aufwand())
|
||||
assert.deepEqual([aufwandAnsicht.positionen, aufwandAnsicht.stundensatzCents, aufwandAnsicht.schaetzungMinuten], [[], 10000, 600])
|
||||
assert.equal(aufwandAnsicht.aufwandText, aufwandText(aufwand()))
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
// test/auftraegeAuftragStore.test.js
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
|
||||
|
||||
// Speicher der Auftragsbestätigungen. Nachbau von Appwrite mit dem Schema aus schema.js, Datumsfeldern wie in Appwrite
|
||||
// ("+00:00") und dem Standard-Limit 25.
|
||||
const aw = await startAppwriteMock({ schema: { auftraege: schemaKeys('auftraege') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
|
||||
after(() => aw.close())
|
||||
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 { docToAuftrag, auftragToDoc, getAuftrag, listAuftraege, createAuftrag, updateAuftrag, deleteAuftrag, findAuftragByTokenHash, findAuftragByNummer } =
|
||||
await import('../server/services/auftraege/auftragStore.js')
|
||||
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||
|
||||
const COL = config.collections.auftraege
|
||||
beforeEach(async () => {
|
||||
for (const id of [...aw.docs(COL).keys()]) await deleteDocument(COL, id)
|
||||
})
|
||||
|
||||
const ENTWURF = {
|
||||
kundenId: 'c1', projektId: 'p1', titel: 'Website Bäckerei', zeitquelle: ['t1'], art: 'festpreis',
|
||||
positionen: [{ beschreibung: 'Startseite', menge: 1, einzelpreisCents: 50000 }],
|
||||
summeCents: 50000, zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14,
|
||||
auftragVom: '2026-10-05', auftragWeg: 'telefon', erstelltVon: 'kenso@webklar.com',
|
||||
}
|
||||
const FELDER = 'id nummer jahr laufnummer status kundenId kunde projektId titel zeitquelle art positionen stundensatzCents schaetzungMinuten summeCents zahlungsplan zahlungszielTage auftragVom auftragWeg startAm fertigBis ersetztId ersetztDurchId texteFassung agbFassung pdfDatei pdfSha256 gesendetAm gesendetAn mailStatus mailFehler tokenHash tokenGueltigBis bestaetigtAm bestaetigtUeber bestaetigtPdfSha256 fruehbeginn fruehbeginnFassung storniertAm widerrufenAm erstelltVon erstelltAm geaendertAm'
|
||||
const HASH = 'a'.repeat(64)
|
||||
const seed = (id, felder = {}) => aw.seed(COL, { $id: id, kundenId: 'c1', status: 'entwurf', ...felder })
|
||||
|
||||
test('createAuftrag legt einen Entwurf an: Status erzwungen, Zeitpunkte gesetzt, JSON-Felder serialisiert', async () => {
|
||||
const vor = Date.now()
|
||||
const auftrag = await createAuftrag({ ...ENTWURF, status: 'versendet' })
|
||||
assert.deepEqual(Object.keys(auftrag).sort(), FELDER.split(' ').sort(), 'genau die 43 Felder aus V3')
|
||||
assert.match(auftrag.id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
|
||||
assert.equal(auftrag.status, 'entwurf')
|
||||
assert.deepEqual(
|
||||
{ nummer: auftrag.nummer, jahr: auftrag.jahr, laufnummer: auftrag.laufnummer, kunde: auftrag.kunde, gesendetAm: auftrag.gesendetAm, mailStatus: auftrag.mailStatus, fruehbeginn: auftrag.fruehbeginn },
|
||||
{ nummer: '', jahr: null, laufnummer: null, kunde: null, gesendetAm: null, mailStatus: '', fruehbeginn: false },
|
||||
)
|
||||
assert.deepEqual(auftrag.positionen, ENTWURF.positionen)
|
||||
assert.deepEqual(auftrag.zahlungsplan, ENTWURF.zahlungsplan)
|
||||
assert.deepEqual(auftrag.zeitquelle, ['t1'])
|
||||
assert.deepEqual([auftrag.stundensatzCents, auftrag.schaetzungMinuten], [null, null])
|
||||
assert.match(auftrag.erstelltAm, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
|
||||
assert.equal(auftrag.geaendertAm, auftrag.erstelltAm)
|
||||
assert.ok(Date.parse(auftrag.erstelltAm) >= vor)
|
||||
const doc = aw.docs(COL).get(auftrag.id)
|
||||
assert.equal(doc.status, 'entwurf')
|
||||
assert.equal(doc.positionenJson, JSON.stringify(ENTWURF.positionen))
|
||||
assert.equal(doc.zeitquelleJson, '["t1"]')
|
||||
assert.deepEqual(await getAuftrag(auftrag.id), auftrag)
|
||||
})
|
||||
|
||||
test('docToAuftrag: fehlende Felder bekommen Vorgaben, kaputtes JSON wirft nie, Zeitpunkte werden zu ISO mit Z', () => {
|
||||
const auftrag = docToAuftrag({
|
||||
$id: 'x1', kundenId: 'c1', status: 'versendet', nummer: 'AB-2026-0003', jahr: 2026, laufnummer: 3,
|
||||
kundeJson: '', positionenJson: '{kaputt', zeitquelleJson: '["t1","",5," t2 "]', zahlungsplanJson: '[1]',
|
||||
gesendetAm: '2026-10-06T10:00:00.000+00:00', tokenGueltigBis: null, fruehbeginn: true, summeCents: 1000,
|
||||
$createdAt: '2026-10-06T08:00:00.000+00:00', $updatedAt: '2026-10-06T09:00:00.000+00:00',
|
||||
})
|
||||
assert.equal(auftrag.id, 'x1')
|
||||
assert.deepEqual([auftrag.nummer, auftrag.jahr, auftrag.laufnummer, auftrag.summeCents], ['AB-2026-0003', 2026, 3, 1000])
|
||||
assert.deepEqual([auftrag.kunde, auftrag.positionen, auftrag.zeitquelle], [null, [], ['t1', 't2']])
|
||||
assert.deepEqual(auftrag.zahlungsplan, { art: 'nach_abnahme', prozent: null, text: '' })
|
||||
assert.equal(auftrag.zahlungszielTage, 14)
|
||||
assert.deepEqual([auftrag.gesendetAm, auftrag.tokenGueltigBis, auftrag.bestaetigtAm], ['2026-10-06T10:00:00.000Z', null, null])
|
||||
assert.equal(auftrag.fruehbeginn, true)
|
||||
assert.deepEqual([auftrag.erstelltAm, auftrag.geaendertAm], ['2026-10-06T08:00:00.000Z', '2026-10-06T09:00:00.000Z'])
|
||||
})
|
||||
|
||||
test('auftragToDoc: nur Felder aus V1, JSON serialisiert, undefined fehlt, null löscht', () => {
|
||||
const doc = auftragToDoc({
|
||||
id: 'x1', $id: 'x1', fremd: 1, status: 'versendet', kunde: { name: 'Anna Beispiel', kundenart: 'privat' },
|
||||
zeitquelle: ['t1'], positionen: [], zahlungsplan: { art: 'frei', prozent: null, text: 'x' },
|
||||
gesendetAm: '2026-10-06T10:00:00.000Z', tokenGueltigBis: null, titel: undefined, tokenHash: null,
|
||||
})
|
||||
assert.deepEqual(doc, {
|
||||
status: 'versendet', kundeJson: '{"name":"Anna Beispiel","kundenart":"privat"}', zeitquelleJson: '["t1"]', positionenJson: '[]',
|
||||
zahlungsplanJson: '{"art":"frei","prozent":null,"text":"x"}', gesendetAm: '2026-10-06T10:00:00.000Z', tokenGueltigBis: null, tokenHash: '',
|
||||
})
|
||||
assert.equal(auftragToDoc({ kunde: null }).kundeJson, '')
|
||||
})
|
||||
|
||||
test('auftragToDoc: ein Text über der Spaltengröße ist ein 400, ein falscher Typ ein Programmfehler', () => {
|
||||
assert.throws(() => auftragToDoc({ titel: 'x'.repeat(201) }), (err) => err instanceof AuftragError && err.status === 400 && /titel/.test(err.message))
|
||||
assert.throws(() => auftragToDoc({ kunde: { name: 'x'.repeat(2001) } }), (err) => err instanceof AuftragError && err.status === 400 && /kunde/.test(err.message))
|
||||
assert.doesNotThrow(() => auftragToDoc({ titel: 'x'.repeat(200) }))
|
||||
for (const falsch of [{ titel: 5 }, { summeCents: 1.5 }, { jahr: '2026' }, { fruehbeginn: 'ja' }, { gesendetAm: 'gestern' }, { gesendetAm: '' }]) {
|
||||
assert.throws(() => auftragToDoc(falsch), TypeError, JSON.stringify(falsch))
|
||||
}
|
||||
})
|
||||
|
||||
test('getAuftrag, updateAuftrag, deleteAuftrag: unbekannte und unmögliche IDs sind null, 404 bzw. folgenlos', async () => {
|
||||
seed('u1', { titel: 'Alt', summeCents: 1000, erstelltAm: '2020-01-01T00:00:00.000Z', geaendertAm: '2020-01-01T00:00:00.000Z' })
|
||||
assert.equal((await getAuftrag('u1')).titel, 'Alt')
|
||||
for (const id of ['gibtsnicht', '', '../x', 'a/b', undefined, null, 42, 'x'.repeat(37)]) assert.equal(await getAuftrag(id), null, String(id))
|
||||
|
||||
const auftrag = await updateAuftrag('u1', { titel: 'Neu', positionen: [{ beschreibung: 'A', menge: 2, einzelpreisCents: 500 }], status: 'versendet' })
|
||||
assert.deepEqual([auftrag.titel, auftrag.summeCents, auftrag.status], ['Neu', 1000, 'versendet'])
|
||||
assert.equal(auftrag.positionen[0].menge, 2)
|
||||
assert.equal(auftrag.erstelltAm, '2020-01-01T00:00:00.000Z')
|
||||
assert.ok(auftrag.geaendertAm > '2020-01-01T00:00:00.000Z')
|
||||
assert.equal(aw.docs(COL).get('u1').titel, 'Neu')
|
||||
for (const id of ['gibtsnicht', '../x']) {
|
||||
await assert.rejects(() => updateAuftrag(id, { titel: 'x' }), (err) => err instanceof AuftragError && err.status === 404, id)
|
||||
}
|
||||
|
||||
await deleteAuftrag('u1')
|
||||
assert.equal(aw.docs(COL).size, 0)
|
||||
await deleteAuftrag('u1')
|
||||
await deleteAuftrag('../x')
|
||||
})
|
||||
|
||||
test('listAuftraege: Filter, neueste zuerst, mehr als 25 Treffer', async () => {
|
||||
seed('a1', { projektId: 'p1', jahr: 2026, erstelltAm: '2026-10-01T10:00:00.000Z' })
|
||||
seed('a2', { projektId: 'p2', jahr: 2026, erstelltAm: '2026-10-03T10:00:00.000Z' })
|
||||
seed('a3', { projektId: 'p1', jahr: 2025, erstelltAm: '2026-10-02T10:00:00.000Z' })
|
||||
seed('b1', { kundenId: 'c2', projektId: 'p1', jahr: 2026, erstelltAm: '2026-10-04T10:00:00.000Z' })
|
||||
const ids = async (filter) => (await listAuftraege(filter)).map((a) => a.id)
|
||||
assert.deepEqual(await ids({ kundenId: 'c1' }), ['a2', 'a3', 'a1'])
|
||||
assert.deepEqual(await ids({ kundenId: 'c1', projektId: 'p1' }), ['a3', 'a1'])
|
||||
assert.deepEqual(await ids({ jahr: 2026 }), ['b1', 'a2', 'a1'])
|
||||
assert.deepEqual(await ids(), ['b1', 'a2', 'a3', 'a1'])
|
||||
assert.deepEqual(await ids({ kundenId: 'gibtsnicht' }), [])
|
||||
for (let i = 0; i < 30; i += 1) seed(`m${String(i).padStart(2, '0')}`, { kundenId: 'c9', erstelltAm: `2026-09-${String(1 + (i % 28)).padStart(2, '0')}T10:00:00.000Z` })
|
||||
assert.equal((await listAuftraege({ kundenId: 'c9' })).length, 30)
|
||||
})
|
||||
|
||||
test('listAuftraege: ein angegebener, aber leerer oder ungültiger Filter ist ein Fehler, nie „alle“', async () => {
|
||||
seed('a1')
|
||||
for (const filter of [{ kundenId: '' }, { kundenId: null }, { kundenId: 5 }, { projektId: '' }, { jahr: '2026' }, { jahr: 2026.5 }, { jahr: null }]) {
|
||||
await assert.rejects(() => listAuftraege(filter), TypeError, JSON.stringify(filter))
|
||||
}
|
||||
})
|
||||
|
||||
test('findAuftragByTokenHash und findAuftragByNummer: genau eine AB, leere und ungültige Schlüssel nie', async () => {
|
||||
seed('e1', { nummer: '', tokenHash: '' })
|
||||
seed('e2')
|
||||
seed('v1', { status: 'versendet', nummer: 'AB-2026-0001', tokenHash: HASH })
|
||||
assert.equal((await findAuftragByTokenHash(HASH)).id, 'v1')
|
||||
assert.equal((await findAuftragByNummer('AB-2026-0001')).id, 'v1')
|
||||
assert.equal(await findAuftragByTokenHash('b'.repeat(64)), null)
|
||||
assert.equal(await findAuftragByNummer('AB-2026-0002'), null)
|
||||
for (const hash of ['', ' ', 'abc', 'A'.repeat(64), `${HASH}0`, undefined, null, 5]) assert.equal(await findAuftragByTokenHash(hash), null, String(hash))
|
||||
for (const nummer of ['', 'Entwurf', 'AB-2026-1', 'ab-2026-0001', 'AB-2026-0001 ', undefined, null]) assert.equal(await findAuftragByNummer(nummer), null, String(nummer))
|
||||
})
|
||||
@@ -0,0 +1,278 @@
|
||||
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, UNGUELTIG, TOKEN_NEU, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
|
||||
|
||||
// bestaetigung.js: Bestätigen (idempotent, unter Sperre) und Widerrufen einer AB; Mails über die Senke
|
||||
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 { auftragToDoc, getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||
const { FRUEHBEGINN_FASSUNG, FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js')
|
||||
const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js')
|
||||
const { withLocks } = await import('../server/services/keyedLock.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { bestaetigeAuftrag, bestaetigungsLage, widerrufeAuftrag } = await import('../server/services/auftraege/bestaetigung.js')
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return aw.close()
|
||||
})
|
||||
|
||||
const JETZT = new Date('2026-10-07T08:30:00.000Z') // 10:30 Uhr in Berlin (MESZ)
|
||||
const ms = (iso) => new Date(iso).getTime()
|
||||
let sink
|
||||
let protokoll
|
||||
beforeEach(() => {
|
||||
sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
protokoll = mock.method(console, 'error', () => {})
|
||||
})
|
||||
afterEach(() => mock.restoreAll())
|
||||
const seed = (over = {}) => {
|
||||
const ab = abDokument(over)
|
||||
aw.seed('auftraege', { $id: ab.id, ...auftragToDoc(ab) })
|
||||
return ab
|
||||
}
|
||||
const betreffe = () => sink.sent.map((mail) => mail.subject)
|
||||
const K32 = 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen'
|
||||
const T22 = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'
|
||||
const norm = (text) => text.replace(/\s+/g, ' ').trim()
|
||||
|
||||
test('bestaetigungsLage: offen, bestaetigt oder gesperrt', () => {
|
||||
const lage = (over) => bestaetigungsLage(abDokument(over))
|
||||
assert.equal(lage({}), 'offen')
|
||||
assert.equal(lage({ status: 'abgerechnet' }), 'offen')
|
||||
assert.equal(lage({ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt')
|
||||
assert.equal(lage({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'bestaetigt')
|
||||
for (const status of ['entwurf', 'ersetzt', 'storniert', 'widerrufen']) assert.equal(lage({ status, bestaetigtAm: '2026-10-07T08:30:00.000Z' }), 'gesperrt', status)
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: Privatperson über den Link mit Frühbeginn – Protokoll am Dokument, K32 und T22', async () => {
|
||||
const ab = seed()
|
||||
const neu = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
|
||||
assert.equal(neu.status, 'bestaetigt')
|
||||
assert.equal(ms(neu.bestaetigtAm), JETZT.getTime())
|
||||
assert.equal(neu.bestaetigtUeber, 'link')
|
||||
assert.equal(neu.bestaetigtPdfSha256, ab.pdfSha256)
|
||||
assert.equal(neu.fruehbeginn, true)
|
||||
assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG)
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.deepEqual([doc.status, doc.bestaetigtUeber, doc.fruehbeginn, ms(doc.bestaetigtAm)], ['bestaetigt', 'link', true, JETZT.getTime()])
|
||||
assert.deepEqual(betreffe(), [K32, T22])
|
||||
assert.ok(norm(sink.sent[0].text).includes(norm(FRUEHBEGINN_TEXT)), 'K32 gibt die Erklärung wörtlich wieder')
|
||||
assert.ok(sink.sent[1].text.includes(`${config.womsBaseUrl}/customers/c1?tab=invoices`))
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: Privatperson über das Portal ohne Frühbeginn – frühester Beginn nach der Widerrufsfrist', async () => {
|
||||
const neu = await bestaetigeAuftrag(seed(), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT })
|
||||
assert.equal(neu.bestaetigtUeber, 'portal')
|
||||
assert.equal(neu.fruehbeginn, false)
|
||||
assert.equal(neu.fruehbeginnFassung, FRUEHBEGINN_FASSUNG, 'die angezeigte Fassung der Erklärung wird auch ohne Haken festgehalten')
|
||||
assert.ok(sink.sent[0].text.includes('Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.'))
|
||||
assert.ok(sink.sent[1].text.includes('Arbeitsbeginn frühestens am 21.10.2026'))
|
||||
assert.ok(sink.sent[1].text.includes('Weg: Kundenportal'))
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: Unternehmen – fruehbeginn wird ignoriert, keine Beginn-Hinweise in K32 und T22', async () => {
|
||||
const neu = await bestaetigeAuftrag(seed({ kunde: kunde('unternehmen') }), { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
|
||||
assert.equal(neu.fruehbeginn, false)
|
||||
assert.equal(neu.fruehbeginnFassung, '')
|
||||
for (const mail of sink.sent) assert.doesNotMatch(mail.text, /Beginn der Arbeiten|frühestens|Erklärung zum früheren Beginn|Die Arbeit darf sofort/)
|
||||
assert.equal(sink.sent[1].subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH')
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: abgerechnete AB ohne frühere Bestätigung – Status bleibt abgerechnet', async () => {
|
||||
const neu = await bestaetigeAuftrag(seed({ status: 'abgerechnet' }), { ueber: 'portal', fruehbeginn: false, jetzt: JETZT })
|
||||
assert.equal(neu.status, 'abgerechnet')
|
||||
assert.equal(ms(neu.bestaetigtAm), JETZT.getTime())
|
||||
assert.equal((await getAuftrag('ab1')).status, 'abgerechnet')
|
||||
assert.equal(sink.sent.length, 2)
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: die zweite Bestätigung ändert nichts und verschickt nichts (die erste zählt, auch bei veraltetem Eingabeobjekt)', async () => {
|
||||
const ab = seed()
|
||||
const erste = await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: true, jetzt: JETZT })
|
||||
const zweite = await bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 3_600_000) })
|
||||
assert.equal(ms(zweite.bestaetigtAm), ms(erste.bestaetigtAm))
|
||||
assert.equal(zweite.bestaetigtUeber, 'link')
|
||||
assert.equal(zweite.fruehbeginn, true)
|
||||
assert.equal(sink.sent.length, 2, 'nur die Mails der ersten Bestätigung')
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: zwei gleichzeitige Bestätigungen (Doppelklick, Link und Portal) – eine zählt, genau zwei Mails', async () => {
|
||||
const ab = seed()
|
||||
const [a, b] = await Promise.all([
|
||||
bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
|
||||
bestaetigeAuftrag(ab, { ueber: 'portal', fruehbeginn: false, jetzt: new Date(JETZT.getTime() + 1000) }),
|
||||
])
|
||||
assert.equal(ms(a.bestaetigtAm), ms(b.bestaetigtAm))
|
||||
assert.equal(a.bestaetigtUeber, 'link', 'der zuerst gestartete Aufruf gewinnt')
|
||||
assert.equal(b.bestaetigtUeber, 'link')
|
||||
assert.equal(sink.sent.length, 2)
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: läuft unter der Sperre sperreAuftrag(id); K32 und T22 gehen erst nach ihrer Freigabe raus', async () => {
|
||||
const ab = seed()
|
||||
let freigeben
|
||||
const gehalten = new Promise((resolve) => {
|
||||
freigeben = resolve
|
||||
})
|
||||
const halter = withLocks(sperreAuftrag('ab1'), () => gehalten)
|
||||
let fertig = false
|
||||
const bestaetigt = bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }).then((neu) => {
|
||||
fertig = true
|
||||
return neu
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält')
|
||||
assert.equal((await getAuftrag('ab1')).status, 'versendet')
|
||||
freigeben()
|
||||
await halter
|
||||
assert.equal((await bestaetigt).status, 'bestaetigt')
|
||||
// Mails erst nach der Freigabe: im Versand ist die Sperre der AB wieder frei (sonst käme das Rennen nach 300 ms mit false zurück)
|
||||
seed()
|
||||
sink.sent.length = 0
|
||||
const frei = []
|
||||
setMailTransportForTests({
|
||||
async sendMail(mail) {
|
||||
frei.push(await Promise.race([withLocks(sperreAuftrag('ab1'), async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 300))]))
|
||||
return sink.sendMail(mail)
|
||||
},
|
||||
})
|
||||
await bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT })
|
||||
assert.deepEqual(frei, [true, true])
|
||||
assert.equal(sink.sent.length, 2)
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: Entwurf, ersetzt, storniert und widerrufen sind nicht bestätigbar (409), nichts ändert sich, keine Mail', async () => {
|
||||
const faelle = [['entwurf', 'Diese Auftragsbestätigung kann nicht bestätigt werden.'], ['ersetzt', 'Diese Auftragsbestätigung wurde ersetzt.'], ['storniert', 'Diese Auftragsbestätigung wurde storniert.'], ['widerrufen', 'Diese Auftragsbestätigung wurde widerrufen.']]
|
||||
for (const [status, meldung] of faelle) {
|
||||
const ab = seed({ status })
|
||||
await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 409 && err.message === meldung, status)
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.deepEqual([doc.status, doc.bestaetigtAm], [status, null], status)
|
||||
}
|
||||
assert.equal(sink.sent.length, 0)
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: scheitert der Mailversand, bleibt die Bestätigung bestehen (Fehler nur im Protokoll)', async () => {
|
||||
setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } })
|
||||
const neu = await bestaetigeAuftrag(seed(), { ueber: 'link', fruehbeginn: false, jetzt: JETZT })
|
||||
assert.equal(neu.status, 'bestaetigt')
|
||||
assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
|
||||
assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('AB-2026-0001')), 'Fehler mit Nummer im Protokoll')
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag: ungültiger Weg oder Zeitpunkt wirft TypeError, unbekannte AB 404', async () => {
|
||||
const ab = seed()
|
||||
await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'telefon', fruehbeginn: false, jetzt: JETZT }), TypeError)
|
||||
await assert.rejects(bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: 'gestern' }), TypeError)
|
||||
await assert.rejects(bestaetigeAuftrag(abDokument({ id: 'gibtsnicht' }), { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404)
|
||||
assert.equal(sink.sent.length, 0)
|
||||
})
|
||||
|
||||
test('bestaetigeAuftrag über den Link: ein Token-Tausch (Erinnerung) oder Ablauf zwischen Prüfung und Sperre ergibt 404 wie bei ungültigem Link', async () => {
|
||||
const alt = seed()
|
||||
const nichtsGeaendert = async () => {
|
||||
assert.equal((await getAuftrag('ab1')).status, 'versendet')
|
||||
assert.equal(sink.sent.length, 0)
|
||||
}
|
||||
// die Erinnerung tauscht den Token, nachdem die Route den alten geprüft hat
|
||||
const { hashToken } = await import('../server/services/auftraege/token.js')
|
||||
await updateAuftrag('ab1', { tokenHash: hashToken(TOKEN_NEU) })
|
||||
await assert.rejects(bestaetigeAuftrag(alt, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404 && err.message === UNGUELTIG)
|
||||
await nichtsGeaendert()
|
||||
// der Link ist inzwischen abgelaufen
|
||||
await updateAuftrag('ab1', { tokenHash: alt.tokenHash, tokenGueltigBis: '2026-10-07T08:29:59.000Z' })
|
||||
await assert.rejects(bestaetigeAuftrag(alt, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }), (err) => err instanceof AuftragError && err.status === 404 && err.message === UNGUELTIG)
|
||||
await nichtsGeaendert()
|
||||
// das Portal braucht keinen Link: dort zaehlt der Token nicht
|
||||
assert.equal((await bestaetigeAuftrag(alt, { ueber: 'portal', fruehbeginn: false, jetzt: JETZT })).status, 'bestaetigt')
|
||||
})
|
||||
|
||||
// gesendet am 06.10.2026 12:00 Berlin: die Frist endet am 20.10.2026 um 24 Uhr (= 21:59:59,999 UTC)
|
||||
const FRIST_ENDE = new Date('2026-10-20T21:59:59.999Z')
|
||||
|
||||
test('widerrufeAuftrag: Privatperson – am letzten Tag bis 24 Uhr (eingeschlossen) widerrufen, eine Millisekunde später nach_frist', async () => {
|
||||
const ab = seed()
|
||||
const ok = await widerrufeAuftrag(ab, { jetzt: FRIST_ENDE })
|
||||
assert.equal(ok.ergebnis, 'widerrufen')
|
||||
assert.equal(ok.auftrag.status, 'widerrufen')
|
||||
assert.equal(ms(ok.auftrag.widerrufenAm), FRIST_ENDE.getTime())
|
||||
assert.equal(ok.fristEnde.getTime(), FRIST_ENDE.getTime())
|
||||
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
|
||||
seed()
|
||||
const spaet = await widerrufeAuftrag(ab, { jetzt: new Date(FRIST_ENDE.getTime() + 1) })
|
||||
assert.equal(spaet.ergebnis, 'nach_frist')
|
||||
assert.equal(spaet.auftrag.status, 'versendet')
|
||||
assert.equal((await getAuftrag('ab1')).widerrufenAm, null)
|
||||
})
|
||||
|
||||
test('widerrufeAuftrag: bestätigte AB wird widerrufen, bestaetigtAm bleibt als Beleg; Unternehmen: kein_widerrufsrecht', async () => {
|
||||
const bestaetigt = seed({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', fruehbeginn: true })
|
||||
const r = await widerrufeAuftrag(bestaetigt, { jetzt: JETZT })
|
||||
assert.equal(r.ergebnis, 'widerrufen')
|
||||
assert.equal(r.auftrag.fruehbeginn, true)
|
||||
assert.equal(ms((await getAuftrag('ab1')).bestaetigtAm), JETZT.getTime())
|
||||
const firma = seed({ kunde: kunde('unternehmen') })
|
||||
const k = await widerrufeAuftrag(firma, { jetzt: JETZT })
|
||||
assert.equal(k.ergebnis, 'kein_widerrufsrecht')
|
||||
assert.equal((await getAuftrag('ab1')).status, 'versendet')
|
||||
})
|
||||
|
||||
test('widerrufeAuftrag: bereits widerrufen, abgerechnet, ersetzt, storniert, Entwurf – nichts ändert sich', async () => {
|
||||
const faelle = [['widerrufen', 'bereits_widerrufen'], ['abgerechnet', 'nicht_widerrufbar'], ['ersetzt', 'nicht_widerrufbar'], ['storniert', 'nicht_widerrufbar'], ['entwurf', 'nicht_widerrufbar']]
|
||||
for (const [status, ergebnis] of faelle) {
|
||||
const ab = seed({ status, widerrufenAm: status === 'widerrufen' ? '2026-10-08T08:00:00.000Z' : null })
|
||||
const r = await widerrufeAuftrag(ab, { jetzt: JETZT })
|
||||
assert.equal(r.ergebnis, ergebnis, status)
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.equal(doc.status, status, status)
|
||||
assert.equal(doc.widerrufenAm === null ? null : ms(doc.widerrufenAm), status === 'widerrufen' ? new Date('2026-10-08T08:00:00.000Z').getTime() : null, status)
|
||||
}
|
||||
})
|
||||
|
||||
test('widerrufeAuftrag: wartet auf die Sperre sperreAuftrag(id) und liest den Stand erst darin (veraltetes Eingabeobjekt)', async () => {
|
||||
const veraltet = seed()
|
||||
let freigeben
|
||||
const gehalten = new Promise((resolve) => {
|
||||
freigeben = resolve
|
||||
})
|
||||
const halter = withLocks(sperreAuftrag('ab1'), () => gehalten)
|
||||
let fertig = false
|
||||
const widerruf = widerrufeAuftrag(veraltet, { jetzt: JETZT }).then((r) => {
|
||||
fertig = true
|
||||
return r
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
assert.equal(fertig, false, 'wartet, solange ein anderer die Sperre der AB hält')
|
||||
seed({ status: 'storniert' }) // währenddessen von Hand storniert (L3)
|
||||
freigeben()
|
||||
await halter
|
||||
const r = await widerruf
|
||||
assert.equal(r.ergebnis, 'nicht_widerrufbar', 'maßgeblich ist der Stand innerhalb der Sperre')
|
||||
assert.equal((await getAuftrag('ab1')).status, 'storniert')
|
||||
assert.equal(sink.sent.length, 0, 'widerrufeAuftrag verschickt keine Mails')
|
||||
})
|
||||
|
||||
test('Bestätigen und Widerrufen gleichzeitig: der Widerruf geht nie verloren, in jeder Reihenfolge', async () => {
|
||||
const ab = seed()
|
||||
const [bestaetigt, widerrufen] = await Promise.all([
|
||||
bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
|
||||
widerrufeAuftrag(ab, { jetzt: JETZT }),
|
||||
])
|
||||
assert.equal(bestaetigt.status, 'bestaetigt')
|
||||
assert.equal(widerrufen.ergebnis, 'widerrufen')
|
||||
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
|
||||
seed()
|
||||
const umgekehrt = await Promise.allSettled([
|
||||
widerrufeAuftrag(ab, { jetzt: JETZT }),
|
||||
bestaetigeAuftrag(ab, { ueber: 'link', fruehbeginn: false, jetzt: JETZT }),
|
||||
])
|
||||
assert.equal(umgekehrt[0].value.ergebnis, 'widerrufen')
|
||||
assert.ok(umgekehrt[1].reason instanceof AuftragError && umgekehrt[1].reason.status === 409)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { AUFTRAEGE_COLLECTIONS } from '../server/services/auftraege/schema.js'
|
||||
import { hashToken } from '../server/services/auftraege/token.js'
|
||||
|
||||
// Testdaten der Strang-L5-Tests: gesendete AB, Token, Schema und Datumsfelder für den Appwrite-Nachbau.
|
||||
// Importiert nie server/config.js: die Tests setzen die Umgebung erst danach (useTestEnv).
|
||||
const attribute = AUFTRAEGE_COLLECTIONS.auftraege.attributes
|
||||
export const AUFTRAEGE_SCHEMA = { auftraege: attribute.map((a) => a.key) }
|
||||
export const AUFTRAEGE_DATETIMES = { auftraege: attribute.filter((a) => a.type === 'datetime').map((a) => a.key) }
|
||||
|
||||
export const TOKEN = 'T'.repeat(43)
|
||||
export const TOKEN_NEU = 'U'.repeat(43)
|
||||
export const SHA = 'a'.repeat(64)
|
||||
export const UNGUELTIG = 'Dieser Link ist ungültig oder abgelaufen.'
|
||||
|
||||
export const kunde = (kundenart = 'privat', extra = {}) => ({
|
||||
name: 'Erika Muster',
|
||||
firma: kundenart === 'unternehmen' ? 'Muster GmbH' : '',
|
||||
strasse: 'Hauptstr. 1',
|
||||
plz: '67722',
|
||||
ort: 'Winnweiler',
|
||||
land: 'DE',
|
||||
email: 'k@example.invalid',
|
||||
kundenart,
|
||||
kundennummer: 'K-100',
|
||||
...extra,
|
||||
})
|
||||
|
||||
/** Gesendete Festpreis-AB AB-2026-0001 des Kunden c1 (Form „Auftrag“ aus V3), gesendet am 06.10.2026 (Berlin 12:00), Link bis 05.12.2026. */
|
||||
export const abDokument = (over = {}) => ({
|
||||
id: 'ab1', nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1, status: 'versendet',
|
||||
kundenId: 'c1', kunde: kunde(), projektId: 'p1', titel: 'Website für Musterbäckerei', zeitquelle: [],
|
||||
art: 'festpreis', positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000 }],
|
||||
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
|
||||
zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' }, zahlungszielTage: 14,
|
||||
auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '',
|
||||
ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30',
|
||||
pdfDatei: 'c1/ab1.pdf', pdfSha256: SHA,
|
||||
gesendetAm: '2026-10-06T10:00:00.000Z', gesendetAn: 'k@example.invalid', mailStatus: 'gesendet', mailFehler: '',
|
||||
tokenHash: hashToken(TOKEN), tokenGueltigBis: '2026-12-05T10:00:00.000Z',
|
||||
bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '', fruehbeginn: false, fruehbeginnFassung: '',
|
||||
storniertAm: null, widerrufenAm: null,
|
||||
erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-06T09:00:00.000Z', geaendertAm: '2026-10-06T10:00:00.000Z',
|
||||
...over,
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, ZWEITES_TICKET, LEAD_KUNDE, FREMD, VON, MELDUNG,
|
||||
festpreisEingabe, aufwandEingabe, abgelehnt,
|
||||
} from './helpers/auftraegeAb.js'
|
||||
|
||||
const umg = await starteUmgebung()
|
||||
const { aw, config } = umg
|
||||
after(() => umg.schliesse())
|
||||
beforeEach(umg.zuruecksetzen)
|
||||
const { getDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { legeEntwurfAn, aendereEntwurf, loescheEntwurf } = await import('../server/services/auftraege/auftraege.js')
|
||||
const kundenartVon = async (id) => (await getDocument(config.collections.customers, id)).kundenart || ''
|
||||
const FREMDES_PROJEKT = MELDUNG.projektFremd
|
||||
const FREMDES_TICKET = 'Zeitquelle: Mindestens ein Ticket gehört nicht zu diesem Kunden.'
|
||||
|
||||
test('legeEntwurfAn: Festpreis-Entwurf mit Titel und Zeitquelle aus dem Projekt, Summe und Ersteller', async () => {
|
||||
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
assert.deepEqual([a.status, a.nummer, a.kundenId, a.projektId, a.titel], ['entwurf', '', KUNDE_ID, PROJEKT_ID, 'Website Beispiel'])
|
||||
assert.deepEqual(a.zeitquelle, [TICKET_ID])
|
||||
assert.deepEqual([a.art, a.summeCents, a.erstelltVon], ['festpreis', 80000, VON])
|
||||
assert.deepEqual(a.zahlungsplan, { art: 'anzahlung', prozent: 50, text: '' })
|
||||
assert.deepEqual(await getAuftrag(a.id), a)
|
||||
})
|
||||
|
||||
test('legeEntwurfAn: ein eigener Titel gilt; ohne Projekt braucht es einen Titel', async () => {
|
||||
assert.equal((await legeEntwurfAn(festpreisEingabe({ titel: ' Relaunch Phase 1 ' }), { von: VON })).titel, 'Relaunch Phase 1')
|
||||
await abgelehnt(legeEntwurfAn(festpreisEingabe({ projektId: '' }), { von: VON }), 400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.')
|
||||
const frei = await legeEntwurfAn(festpreisEingabe({ projektId: '', titel: 'Logo-Entwurf' }), { von: VON })
|
||||
assert.deepEqual([frei.projektId, frei.zeitquelle], ['', []])
|
||||
})
|
||||
|
||||
test('legeEntwurfAn: Aufwand-Entwurf ohne Positionen und Summe, mit Stundensatz, Schätzung und gewählten Tickets', async () => {
|
||||
const a = await legeEntwurfAn(aufwandEingabe({ zeitquelle: [TICKET_ID, ZWEITES_TICKET] }), { von: VON })
|
||||
assert.deepEqual([a.art, a.positionen, a.summeCents, a.stundensatzCents, a.schaetzungMinuten], ['aufwand', [], null, 10000, 600])
|
||||
assert.deepEqual(a.zeitquelle, [TICKET_ID, ZWEITES_TICKET])
|
||||
assert.deepEqual(a.zahlungsplan, { art: 'monatlich', prozent: null, text: '' })
|
||||
})
|
||||
|
||||
test('legeEntwurfAn: Entwürfe gibt es auch für Interessenten (nur das Senden ist festen Kunden vorbehalten)', async () => {
|
||||
const a = await legeEntwurfAn(festpreisEingabe({ kundenId: LEAD_KUNDE, projektId: '', titel: 'Angebotsentwurf' }), { von: VON })
|
||||
assert.deepEqual([a.kundenId, a.status], [LEAD_KUNDE, 'entwurf'])
|
||||
})
|
||||
|
||||
test('kundenart aus der Eingabe wird am Kunden gespeichert – aber nur, wenn dort noch keine steht', async () => {
|
||||
await legeEntwurfAn(festpreisEingabe({ kundenId: FREMD.kunde, projektId: FREMD.projekt, kundenart: 'privat' }), { von: VON })
|
||||
assert.equal(await kundenartVon(FREMD.kunde), 'privat')
|
||||
await legeEntwurfAn(festpreisEingabe({ kundenart: 'privat' }), { von: VON }) // der Kunde ist schon 'unternehmen'
|
||||
assert.equal(await kundenartVon(KUNDE_ID), 'unternehmen')
|
||||
await legeEntwurfAn(festpreisEingabe({ kundenId: LEAD_KUNDE, projektId: '', titel: 'x', kundenart: '' }), { von: VON })
|
||||
assert.equal(await kundenartVon(LEAD_KUNDE), '')
|
||||
})
|
||||
|
||||
test('fremde Bezüge werden abgelehnt, ohne dass etwas gespeichert wird (auch nicht die Kundenart)', async () => {
|
||||
const eingabe = (patch) => festpreisEingabe({ kundenId: FREMD.kunde, projektId: FREMD.projekt, kundenart: 'privat', ...patch })
|
||||
const faelle = [
|
||||
['Projekt eines anderen Kunden', { projektId: PROJEKT_ID }, FREMDES_PROJEKT],
|
||||
['unbekanntes Projekt', { projektId: 'p_gibtsnicht' }, FREMDES_PROJEKT],
|
||||
['Ticket eines anderen Kunden', { zeitquelle: [TICKET_ID] }, FREMDES_TICKET],
|
||||
['ein Ticket unbekannt', { zeitquelle: [FREMD.ticket, 't_gibtsnicht'] }, FREMDES_TICKET],
|
||||
]
|
||||
for (const [name, patch, meldung] of faelle) await abgelehnt(legeEntwurfAn(eingabe(patch), { von: VON }), 400, meldung, name)
|
||||
assert.equal(aw.docs(config.collections.auftraege).size, 0)
|
||||
assert.equal(await kundenartVon(FREMD.kunde), '')
|
||||
})
|
||||
|
||||
test('Kunde: ohne Kennung 400, unbekannt 404, Kennung mit Sonderzeichen 404', async () => {
|
||||
await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: undefined }), { von: VON }), 400, 'Kunde: Bitte einen Kunden angeben.')
|
||||
await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: 'c_gibtsnicht' }), { von: VON }), 404, MELDUNG.kundeFehlt)
|
||||
await abgelehnt(legeEntwurfAn(festpreisEingabe({ kundenId: '../users' }), { von: VON }), 404, MELDUNG.kundeFehlt)
|
||||
})
|
||||
|
||||
test('status, nummer, Token und Ersteller aus der Eingabe werden ignoriert', async () => {
|
||||
const a = await legeEntwurfAn(
|
||||
{ ...festpreisEingabe(), status: 'bestaetigt', nummer: 'AB-2026-0001', jahr: 2026, tokenHash: 'abc', ersetztId: 'a_alt', erstelltVon: 'boese@example.invalid', pdfDatei: 'x/y.pdf' },
|
||||
{ von: VON },
|
||||
)
|
||||
assert.deepEqual([a.status, a.nummer, a.jahr, a.erstelltVon], ['entwurf', '', null, VON])
|
||||
assert.ok(!a.tokenHash && !a.ersetztId && !a.pdfDatei)
|
||||
})
|
||||
|
||||
test('aendereEntwurf: nur die genannten Felder ändern sich, der Rest bleibt', async () => {
|
||||
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
const b = await aendereEntwurf(a.id, { titel: 'Neuer Titel', zahlungszielTage: 30 })
|
||||
assert.deepEqual([b.titel, b.zahlungszielTage, b.summeCents], ['Neuer Titel', 30, 80000])
|
||||
assert.deepEqual([b.positionen, b.zahlungsplan, b.zeitquelle], [a.positionen, a.zahlungsplan, a.zeitquelle])
|
||||
assert.deepEqual(await getAuftrag(a.id), b)
|
||||
})
|
||||
|
||||
test('aendereEntwurf: Wechsel der Art prüft das Ganze (Anzahlung geht nicht bei Aufwand), mit passendem Plan klappt er', async () => {
|
||||
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
await abgelehnt(aendereEntwurf(a.id, { art: 'aufwand' }), 400, 'Zahlungsplan: Eine Anzahlung geht nur bei einem Festpreis.')
|
||||
assert.equal((await getAuftrag(a.id)).art, 'festpreis')
|
||||
const b = await aendereEntwurf(a.id, { art: 'aufwand', stundensatzCents: 12000, zahlungsplan: { art: 'monatlich' } })
|
||||
assert.deepEqual([b.art, b.positionen, b.summeCents, b.stundensatzCents, b.schaetzungMinuten], ['aufwand', [], null, 12000, null])
|
||||
})
|
||||
|
||||
test('aendereEntwurf: Kunde lässt sich nicht wechseln, fremdes Projekt wird abgelehnt, unbekannte Aufträge sind 404', async () => {
|
||||
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
await abgelehnt(aendereEntwurf(a.id, { kundenId: FREMD.kunde }), 400, 'Kunde: Der Kunde eines Auftrags lässt sich nicht ändern.')
|
||||
assert.equal((await aendereEntwurf(a.id, { kundenId: KUNDE_ID, titel: 'Gleicher Kunde' })).titel, 'Gleicher Kunde')
|
||||
await abgelehnt(aendereEntwurf(a.id, { projektId: FREMD.projekt }), 400, FREMDES_PROJEKT)
|
||||
await abgelehnt(aendereEntwurf('a_gibtsnicht', { titel: 'x' }), 404, MELDUNG.auftragFehlt)
|
||||
await abgelehnt(aendereEntwurf('../x', { titel: 'x' }), 404, MELDUNG.auftragFehlt)
|
||||
})
|
||||
|
||||
test('Ändern und Löschen gibt es nur für Entwürfe (409); Löschen entfernt den Entwurf', async () => {
|
||||
const a = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
await updateAuftrag(a.id, { status: 'versendet', nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1 })
|
||||
await abgelehnt(aendereEntwurf(a.id, { titel: 'Nachträglich' }), 409, MELDUNG.nurEntwurfAendern)
|
||||
await abgelehnt(loescheEntwurf(a.id), 409, MELDUNG.nurEntwurfLoeschen)
|
||||
assert.equal((await getAuftrag(a.id)).titel, 'Website Beispiel')
|
||||
const b = await legeEntwurfAn(festpreisEingabe(), { von: VON })
|
||||
await loescheEntwurf(b.id)
|
||||
assert.equal(await getAuftrag(b.id), null)
|
||||
await abgelehnt(loescheEntwurf(b.id), 404, MELDUNG.auftragFehlt)
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
|
||||
const aw = await startAppwriteMock({ schema: {} })
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { getStripe } = await import('../server/services/stripeBilling.js')
|
||||
after(() => Promise.all([aw.close(), st.close()]))
|
||||
|
||||
const refused = (promise, pattern) => assert.rejects(promise, (err) => err.statusCode === 400 && pattern.test(err.message))
|
||||
const create = (extra = {}) => getStripe().invoices.create({ customer: 'cus_x', collection_method: 'send_invoice', days_until_due: 14, ...extra })
|
||||
const item = (invoice, extra) => getStripe().invoiceItems.create({ customer: 'cus_x', invoice, description: 'Posten', currency: 'eur', ...extra })
|
||||
|
||||
test('Stripe-Nachbau: custom_fields und rendering werden gespeichert und mit der Rechnung geliefert', async () => {
|
||||
const felder = [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Auftrag', value: 'AB-2026-0002 vom 06.10.2026' }]
|
||||
const invoice = await create({ custom_fields: felder, rendering: { pdf: { page_size: 'a4' } } })
|
||||
assert.deepEqual(st.invoices.get(invoice.id).custom_fields, felder)
|
||||
assert.deepEqual(st.invoices.get(invoice.id).rendering, { pdf: { page_size: 'a4' } })
|
||||
const gelesen = await getStripe().invoices.retrieve(invoice.id)
|
||||
assert.deepEqual(gelesen.custom_fields, felder)
|
||||
assert.deepEqual(gelesen.rendering, { pdf: { page_size: 'a4' } })
|
||||
await item(invoice.id, { quantity: 1, unit_amount_decimal: '1000' })
|
||||
assert.deepEqual(st.invoices.get(invoice.id).custom_fields, felder, 'Posten ändern die Zusatzfelder nicht')
|
||||
const schlicht = await create()
|
||||
assert.equal(st.invoices.get(schlicht.id).custom_fields, null)
|
||||
assert.equal(st.invoices.get(schlicht.id).rendering, null)
|
||||
})
|
||||
|
||||
test('Stripe-Nachbau: Grenzen der Zusatzfelder (4, Name 40, Wert 140) und der Seitengröße wie bei Stripe', async () => {
|
||||
const viele = Array.from({ length: 5 }, (_, i) => ({ name: `Feld ${i}`, value: 'x' }))
|
||||
await refused(create({ custom_fields: viele }), /up to 4 custom fields/)
|
||||
await refused(create({ custom_fields: [{ name: 'n'.repeat(41), value: 'x' }] }), /names must be between 1 and 40/)
|
||||
await refused(create({ custom_fields: [{ name: 'n', value: 'v'.repeat(141) }] }), /values must be between 1 and 140/)
|
||||
await refused(create({ rendering: { pdf: { page_size: 'din-a4' } } }), /page_size/)
|
||||
const vier = await create({ custom_fields: viele.slice(0, 4).map((f) => ({ ...f, value: 'v'.repeat(140) })), rendering: { pdf: { page_size: 'letter' } } })
|
||||
assert.equal(st.invoices.get(vier.id).custom_fields.length, 4)
|
||||
})
|
||||
|
||||
test('Stripe-Nachbau: Posten mit negativem amount (ohne quantity) mindern die Summe; amount mit quantity oder Einzelpreis wird abgelehnt', async () => {
|
||||
const invoice = await create()
|
||||
await item(invoice.id, { description: 'Leistung', quantity: 1, unit_amount_decimal: '40000' })
|
||||
await item(invoice.id, { description: 'abzüglich Anzahlung', amount: -15000 })
|
||||
const gespeichert = st.invoices.get(invoice.id)
|
||||
assert.deepEqual(gespeichert.lines.data.map((l) => [l.description, l.quantity, l.amount]), [['Leistung', 1, 40000], ['abzüglich Anzahlung', 1, -15000]])
|
||||
assert.deepEqual([gespeichert.total, gespeichert.amount_due], [25000, 25000])
|
||||
await refused(item(invoice.id, { amount: -100, quantity: 2 }), /only specify one of these parameters/)
|
||||
await refused(item(invoice.id, { amount: -100, unit_amount_decimal: '100' }), /only specify one of these parameters/)
|
||||
assert.equal(st.invoices.get(invoice.id).total, 25000)
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
// test/auftraegeGeruest.test.js
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
|
||||
// Gerüste der Router (L0): geprüft wird nur, was auch nach dem Füllen durch L1, L3 und L5 gilt: Zugangsschutz, unbekannte
|
||||
// Tiefenpfade (…/a/b/c/d) und die Reihenfolge der Einbindung im Quelltext.
|
||||
const aw = await startAppwriteMock()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { startTestApp, rawGet } = await import('./support/testApp.js')
|
||||
const { seedFixtures, customerCookie } = await import('./support/fixtures.js')
|
||||
const { default: projektkonten } = await import('../server/routes/admin/projektkonten.js')
|
||||
const { default: auftraegeAdmin } = await import('../server/routes/admin/auftraege.js')
|
||||
const { default: auftragOeffentlich } = await import('../server/routes/auftrag.js')
|
||||
const { default: auftraegeKunde } = await import('../server/routes/auftraegeKunde.js')
|
||||
const { default: teamRoutes } = await import('../server/routes/team/index.js')
|
||||
|
||||
const app = await startTestApp({
|
||||
'/api/admin/finanzen/projektkonten': projektkonten,
|
||||
'/api/admin/finanzen/auftraege': auftraegeAdmin,
|
||||
'/api/team': teamRoutes,
|
||||
'/api/auftrag': auftragOeffentlich,
|
||||
'/api/auftraege': auftraegeKunde,
|
||||
})
|
||||
after(() => Promise.all([app.close(), aw.close()]))
|
||||
seedFixtures(aw)
|
||||
|
||||
const bearer = (jwt) => ({ headers: { authorization: `Bearer ${jwt}` } })
|
||||
const quelle = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
|
||||
|
||||
test('Admin-Router: ohne JWT 401, Mitarbeiter ohne Admin-Label und Kunden 403, Admin kommt durch', async () => {
|
||||
for (const pfad of ['/api/admin/finanzen/projektkonten/a/b/c/d', '/api/admin/finanzen/auftraege/a/b/c/d']) {
|
||||
assert.equal((await rawGet(pfad)).status, 401, pfad)
|
||||
assert.equal((await rawGet(pfad, bearer('jwt-staff'))).status, 403, `${pfad} jwt-staff`)
|
||||
assert.equal((await rawGet(pfad, bearer('jwt-kunde'))).status, 403, `${pfad} jwt-kunde`)
|
||||
assert.equal((await rawGet(pfad, bearer('jwt-admin'))).status, 404, `${pfad} jwt-admin: unbekannter Pfad`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Team-Router: /arbeitsblaetter liegt hinter requireStaffJwt (jeder Mitarbeiter darf, Kunden nicht)', async () => {
|
||||
const pfad = '/api/team/arbeitsblaetter/a/b/c/d'
|
||||
assert.equal((await rawGet(pfad)).status, 401)
|
||||
assert.equal((await rawGet(pfad, bearer('jwt-kunde'))).status, 403)
|
||||
assert.equal((await rawGet(pfad, bearer('jwt-staff'))).status, 404, 'Mitarbeiter ohne Admin-Label kommen durch')
|
||||
assert.equal((await rawGet('/api/team/arbeitsblaetter/abgerechnet?ticketId=t1')).status, 401)
|
||||
})
|
||||
|
||||
test('Kunden-Router verlangt eine Anmeldung, der öffentliche Router nicht', async () => {
|
||||
const ohne = await rawGet('/api/auftraege/a/b/c/d')
|
||||
assert.equal(ohne.status, 401)
|
||||
assert.deepEqual(ohne.body, { error: 'Nicht angemeldet' })
|
||||
assert.equal((await rawGet('/api/auftraege/a/b/c/d', { cookie: customerCookie('c1') })).status, 404)
|
||||
for (const pfad of ['/api/auftrag/a/b/c/d', '/api/auftrag/a/b/c/d/pdf']) assert.equal((await rawGet(pfad)).status, 404, pfad)
|
||||
})
|
||||
|
||||
test('server/index.js: Admin-Router vor dem Finanzen-Router, Kunden- und öffentlicher Router hinter Wächter und Parser', () => {
|
||||
const index = quelle('server/index.js')
|
||||
const stelle = (zeile) => {
|
||||
const pos = index.indexOf(zeile)
|
||||
assert.ok(pos >= 0, `${zeile} fehlt`)
|
||||
assert.equal(index.indexOf(zeile, pos + 1), -1, `${zeile} steht mehrfach`)
|
||||
return pos
|
||||
}
|
||||
const projekt = stelle("app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes)")
|
||||
const auftraege = stelle("app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes)")
|
||||
const finanzen = stelle("app.use('/api/admin/finanzen', adminFinanzenRoutes)")
|
||||
assert.ok(projekt < finanzen && auftraege < finanzen, 'sonst prüft der Finanzen-Router zuerst')
|
||||
const waechter = stelle('app.use(kundenansichtGuard)')
|
||||
const parser = stelle("app.use(express.json({ limit: '2mb'")
|
||||
const oeffentlich = stelle("app.use('/api/auftrag', publicAuftragRoutes)")
|
||||
const kunde = stelle("app.use('/api/auftraege', kundeAuftraegeRoutes)")
|
||||
assert.ok(waechter < oeffentlich && waechter < kunde && parser < oeffentlich && parser < kunde)
|
||||
for (const [name, datei] of [
|
||||
['adminProjektkontenRoutes', 'admin/projektkonten'],
|
||||
['adminAuftraegeRoutes', 'admin/auftraege'],
|
||||
['publicAuftragRoutes', 'auftrag'],
|
||||
['kundeAuftraegeRoutes', 'auftraegeKunde'],
|
||||
]) {
|
||||
assert.ok(index.includes(`import ${name} from './routes/${datei}.js'`), name)
|
||||
}
|
||||
})
|
||||
|
||||
test('server/routes/team/index.js: /arbeitsblaetter hängt hinter requireStaffJwt', () => {
|
||||
const team = quelle('server/routes/team/index.js')
|
||||
const jwt = team.indexOf('router.use(requireStaffJwt)')
|
||||
const mount = team.indexOf("router.use('/arbeitsblaetter', arbeitsblaetterTeamRoutes)")
|
||||
assert.ok(jwt >= 0 && mount > jwt)
|
||||
assert.equal(team.indexOf("router.use('/arbeitsblaetter'", mount + 1), -1)
|
||||
assert.ok(team.includes("import arbeitsblaetterTeamRoutes from './arbeitsblaetter.js'"))
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
// test/auftraegeKonstanten.test.js
|
||||
import { test, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import * as K from '../server/services/auftraege/konstanten.js'
|
||||
import { AuftragError, sendeFehler } from '../server/services/auftraege/fehler.js'
|
||||
import { InvoiceError } from '../server/services/finance/stripeFinance.js'
|
||||
|
||||
test('Konstanten stehen wie in Übersicht V2', () => {
|
||||
assert.deepEqual(K.RECHNUNGS_ARTEN, ['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss'])
|
||||
assert.deepEqual(K.AB_RECHNUNGS_ARTEN, ['anzahlung', 'schluss'])
|
||||
assert.deepEqual(K.AUFTRAG_ARTEN, ['festpreis', 'aufwand'])
|
||||
assert.deepEqual(K.AUFTRAG_STATUS, ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen'])
|
||||
assert.deepEqual(K.VEREINBARTE_STATUS, ['versendet', 'bestaetigt', 'abgerechnet'])
|
||||
assert.deepEqual(K.AUFTRAG_WEGE, { muendlich: 'mündlich', telefon: 'telefonisch', email: 'per E-Mail', vor_ort: 'vor Ort', chat: 'im Chat' })
|
||||
assert.deepEqual(K.ZAHLUNGSPLAN_ARTEN, ['nach_abnahme', 'anzahlung', 'monatlich', 'frei'])
|
||||
assert.deepEqual(K.KUNDENARTEN, ['unternehmen', 'privat'])
|
||||
assert.equal(K.STUNDENSATZ_STANDARD_CENTS, 10000)
|
||||
assert.equal(K.VIERTELSTUNDE, 15)
|
||||
assert.equal(K.TOKEN_GUELTIG_TAGE, 60)
|
||||
assert.match(K.AGB_FASSUNG, /^\d{4}-\d{2}-\d{2}$/) // den Wert legt L7 fest
|
||||
assert.deepEqual(K.LIMITS, {
|
||||
maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000,
|
||||
maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60,
|
||||
standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10,
|
||||
maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140,
|
||||
})
|
||||
// withLocks sperrt jedes Listenelement einzeln: je Sperre genau ein zusammengesetzter Schlüssel (verschiedene Kunden und ABs blockieren sich nicht)
|
||||
assert.deepEqual(K.SPERRE_NUMMER, ['auftraege-nummer'])
|
||||
assert.deepEqual(K.sperreRechnung('c1'), ['auftraege-rechnung:c1'])
|
||||
assert.deepEqual(K.sperreAuftrag('ab1'), ['auftraege-ab:ab1'])
|
||||
assert.equal(K.SCHALTER_AUS_MELDUNG, 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.')
|
||||
for (const status of K.VEREINBARTE_STATUS) assert.ok(K.AUFTRAG_STATUS.includes(status), status)
|
||||
for (const art of K.AB_RECHNUNGS_ARTEN) assert.ok(K.RECHNUNGS_ARTEN.includes(art), art)
|
||||
})
|
||||
|
||||
const fakeRes = () => ({
|
||||
statusCode: 200,
|
||||
body: undefined,
|
||||
status(code) {
|
||||
this.statusCode = code
|
||||
return this
|
||||
},
|
||||
json(body) {
|
||||
this.body = body
|
||||
return this
|
||||
},
|
||||
})
|
||||
|
||||
test('AuftragError trägt Status und Meldung; ein Status außerhalb 400 bis 599 ist ein Programmfehler', () => {
|
||||
const err = new AuftragError(409, 'Arbeitsblatt bereits abgerechnet (WEBKLAR-0009).')
|
||||
assert.ok(err instanceof Error)
|
||||
assert.equal(err.name, 'AuftragError')
|
||||
assert.equal(err.status, 409)
|
||||
assert.equal(err.message, 'Arbeitsblatt bereits abgerechnet (WEBKLAR-0009).')
|
||||
for (const status of [200, 399, 600, 404.5, '404', undefined, null]) {
|
||||
assert.throws(() => new AuftragError(status, 'x'), TypeError, String(status))
|
||||
}
|
||||
})
|
||||
|
||||
test('sendeFehler gibt AuftragError und InvoiceError mit Status und Meldung weiter', () => {
|
||||
const a = fakeRes()
|
||||
sendeFehler(a, 'AB senden', new AuftragError(400, 'Kundenart fehlt.'))
|
||||
assert.deepEqual([a.statusCode, a.body], [400, { error: 'Kundenart fehlt.' }])
|
||||
const b = fakeRes()
|
||||
sendeFehler(b, 'Rechnung anlegen', new InvoiceError(502, 'Stripe ist gerade nicht erreichbar.'))
|
||||
assert.deepEqual([b.statusCode, b.body], [502, { error: 'Stripe ist gerade nicht erreichbar.' }])
|
||||
})
|
||||
|
||||
test('sendeFehler: alles andere wird 500 „Interner Fehler.“, das Protokoll nennt nie die Meldung', () => {
|
||||
const log = mock.method(console, 'error', () => {})
|
||||
try {
|
||||
const appwrite = Object.assign(new Error('Appwrite: Key sk_live_geheim abgelehnt'), { status: 400, type: 'user_unauthorized' })
|
||||
for (const err of [appwrite, 'sk_live_geheim', undefined, null]) {
|
||||
const res = fakeRes()
|
||||
sendeFehler(res, 'Projektkonto laden', err)
|
||||
assert.deepEqual([res.statusCode, res.body], [500, { error: 'Interner Fehler.' }])
|
||||
}
|
||||
assert.equal(log.mock.calls.length, 4)
|
||||
const zeilen = JSON.stringify(log.mock.calls.map((call) => call.arguments))
|
||||
assert.ok(!zeilen.includes('geheim'), zeilen)
|
||||
assert.ok(zeilen.includes('Projektkonto laden') && zeilen.includes('user_unauthorized'), zeilen)
|
||||
} finally {
|
||||
log.mock.restore()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
// Schlusskontrolle (Z3.4): Korrekturen an Projektrechnungen zu Auftragsbestaetigungen
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
|
||||
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
|
||||
datetimes: AUFTRAEGE_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { seedRechnungsDaten, legeAuftragAn, legeRechnungAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
|
||||
const { projektkontoDetail } = await import('../server/services/auftraege/projektkonto.js')
|
||||
const { createProjectInvoice, rechnungsVorschau, sendInvoiceNow, voidInvoice, deleteDraft, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
|
||||
const { getAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { umhaengen } = await import('../server/services/auftraege/projektRechnungenStore.js')
|
||||
|
||||
after(() => Promise.all([aw.close(), st.close()]))
|
||||
seedRechnungsDaten(aw)
|
||||
config.auftraege.mode = 'on'
|
||||
beforeEach(async () => {
|
||||
await leereRechnungsDaten(aw, st)
|
||||
invalidateFinanceCache()
|
||||
resetInvoiceCreateGuardForTests()
|
||||
})
|
||||
|
||||
const letzterEntwurf = () => st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/invoices').at(-1)
|
||||
let zaehler = 0
|
||||
const anzahlung = (ab, prozent, extra = {}) =>
|
||||
createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, projektId: 'p1', anzahlung: { prozent, bezeichnung: `Anzahlung ${++zaehler}` }, ...extra })
|
||||
const schluss = (ab, extra = {}) => createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, projektId: 'p1', ...extra })
|
||||
|
||||
test('Zahlungsziel: Anzahlung und Schluss ohne eigene Angabe nehmen das Zahlungsziel der AB, eine Angabe gilt vor der AB', async () => {
|
||||
const ab = await legeAuftragAn({ zahlungszielTage: 30 })
|
||||
await anzahlung(ab, 50, { senden: true })
|
||||
assert.equal(letzterEntwurf().body.days_until_due, '30')
|
||||
await schluss(ab)
|
||||
assert.equal(letzterEntwurf().body.days_until_due, '30')
|
||||
const ab2 = await legeAuftragAn({ zahlungszielTage: 30, projektId: 'p3' }, { nummer: 'AB-2026-0003', laufnummer: 3 })
|
||||
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab2.id, projektId: 'p3', anzahlung: { prozent: 50 }, daysUntilDue: 7 })
|
||||
assert.equal(letzterEntwurf().body.days_until_due, '7')
|
||||
})
|
||||
|
||||
test('Zahlungsziel: ohne AB-Bezug (frei, festbetrag) bleibt es bei 14 Tagen', async () => {
|
||||
await legeAuftragAn({ zahlungszielTage: 30 })
|
||||
await createProjectInvoice({ customerId: 'k1', items: [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }] })
|
||||
assert.equal(letzterEntwurf().body.days_until_due, '14')
|
||||
})
|
||||
|
||||
const ENTWURF_ID = /^in_/
|
||||
const finalisiert = (id) => st.calls.some((c) => c.method === 'POST' && c.path === `/v1/invoices/${id}/finalize`)
|
||||
|
||||
test('Senden: zwei Anzahlungs-Entwürfe à 60 % einer 800-€-AB – nur der erste geht, der zweite 409 und nichts finalisiert', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const a = await anzahlung(ab, 60)
|
||||
const b = await anzahlung(ab, 60)
|
||||
assert.match(a.id, ENTWURF_ID)
|
||||
await sendInvoiceNow(a.id)
|
||||
st.calls.length = 0
|
||||
await assert.rejects(sendInvoiceNow(b.id), (err) => err.status === 409 && /übersteig|Auftragssumme/.test(err.message))
|
||||
assert.equal(finalisiert(b.id), false)
|
||||
assert.equal(st.invoices.get(b.id).status, 'draft')
|
||||
})
|
||||
|
||||
test('Senden: parallel gesendet geht von zwei 60-%-Entwürfen genau einer', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const a = await anzahlung(ab, 60)
|
||||
const b = await anzahlung(ab, 60)
|
||||
const ergebnisse = await Promise.allSettled([sendInvoiceNow(a.id), sendInvoiceNow(b.id)])
|
||||
assert.deepEqual(ergebnisse.map((e) => e.status).sort(), ['fulfilled', 'rejected'])
|
||||
assert.equal([a.id, b.id].filter((id) => st.invoices.get(id).status !== 'draft').length, 1)
|
||||
})
|
||||
|
||||
test('Senden: ein Anzahlungs-Entwurf zu einer stornierten, widerrufenen oder ersetzten AB wird nicht gesendet', async () => {
|
||||
for (const status of ['storniert', 'widerrufen', 'ersetzt']) {
|
||||
const ab = await legeAuftragAn({}, { nummer: `AB-2026-${status}` })
|
||||
const entwurf = await anzahlung(ab, 30, { projektId: 'p1', anzahlung: { prozent: 30, bezeichnung: `A ${status}` } })
|
||||
await updateAuftrag(ab.id, { status })
|
||||
await assert.rejects(sendInvoiceNow(entwurf.id), (err) => err.status === 409 && /nicht mehr gültig/.test(err.message), status)
|
||||
assert.equal(finalisiert(entwurf.id), false, status)
|
||||
await deleteDraft(entwurf.id)
|
||||
await updateAuftrag(ab.id, { status: 'storniert' })
|
||||
}
|
||||
})
|
||||
|
||||
test('Senden: ein Schluss-Entwurf wird nicht gesendet, wenn zur AB schon eine andere Schlussrechnung festgeschrieben ist', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const s = await schluss(ab)
|
||||
await legeRechnungAn(st, 'in_fremd1', { status: 'open', total: 80000, art: 'schluss', auftragId: ab.id })
|
||||
await assert.rejects(sendInvoiceNow(s.id), (err) => err.status === 409)
|
||||
assert.equal(finalisiert(s.id), false)
|
||||
})
|
||||
|
||||
test('Senden: ein gültiger Anzahlungs- oder Schluss-Entwurf geht wie bisher', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const a = await anzahlung(ab, 50)
|
||||
assert.equal((await sendInvoiceNow(a.id)).status, 'offen')
|
||||
const s = await schluss(ab)
|
||||
assert.equal((await sendInvoiceNow(s.id)).status, 'offen')
|
||||
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
|
||||
})
|
||||
|
||||
test('Storno: eine Anzahlung wird nicht storniert, solange zur AB ein Schluss-Entwurf besteht (409); nach dem Löschen des Entwurfs geht es', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const a = await anzahlung(ab, 50, { senden: true })
|
||||
const s = await schluss(ab)
|
||||
await assert.rejects(voidInvoice(a.id), (err) => err.status === 409 && err.message === 'Erst den Entwurf der Schlussrechnung löschen.')
|
||||
assert.equal(st.invoices.get(a.id).status, 'open')
|
||||
await deleteDraft(s.id)
|
||||
assert.equal((await voidInvoice(a.id)).status, 'storniert')
|
||||
})
|
||||
|
||||
test('Storno: eine Anzahlung ohne Schluss-Entwurf lässt sich stornieren', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const a = await anzahlung(ab, 50, { senden: true })
|
||||
assert.equal((await voidInvoice(a.id)).status, 'storniert')
|
||||
})
|
||||
|
||||
test('Finalisieren einer Schlussrechnung setzt die AB aus dem Projektdatensatz auf abgerechnet (nach Umhängen die neue AB)', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const s = await schluss(ab)
|
||||
// wie beim Senden einer neuen Fassung: neue AB versendet, alte ersetzt, Projektrechnungen umgehängt
|
||||
const neu = await legeAuftragAn({ ersetztId: ab.id }, { nummer: 'AB-2026-0003', laufnummer: 3 })
|
||||
await updateAuftrag(ab.id, { status: 'ersetzt', ersetztDurchId: neu.id })
|
||||
await umhaengen(ab.id, neu.id)
|
||||
await sendInvoiceNow(s.id)
|
||||
assert.equal((await getAuftrag(neu.id)).status, 'abgerechnet')
|
||||
assert.equal((await getAuftrag(ab.id)).status, 'ersetzt')
|
||||
})
|
||||
|
||||
test('Stundensatz: bei zwei gültigen Aufwands-ABs rechnen Rechnung und Projektkonto mit derselben (jüngste nach gesendetAm)', async () => {
|
||||
const basis = { art: 'aufwand', positionen: [], summeCents: null, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }
|
||||
// A zuerst angelegt, aber später gesendet; B danach angelegt, aber früher gesendet
|
||||
await legeAuftragAn({ ...basis, stundensatzCents: 8000 }, { nummer: 'AB-2026-0010', gesendetAm: '2026-10-09T08:00:00.000Z' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 15))
|
||||
await legeAuftragAn({ ...basis, stundensatzCents: 12000 }, { nummer: 'AB-2026-0011', gesendetAm: '2026-10-06T08:00:00.000Z' })
|
||||
const vorschau = await rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] })
|
||||
const konto = await projektkontoDetail('p1')
|
||||
assert.equal(konto.konto.zeit.satzCents, 8000)
|
||||
assert.match(vorschau.positionen[0].beschreibung, /à 80,00\s€/)
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
|
||||
|
||||
// Portal-Routen /api/auftraege (V5): nur eigene, gesendete ABs; Schalter; Bestätigen mit Portal-Sitzung
|
||||
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-kunde-'))
|
||||
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 { startTestApp, send } = await import('./support/testApp.js')
|
||||
const { seedFixtures, customerCookie, adminCookie } = await import('./support/fixtures.js')
|
||||
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { default: kundeRoutes } = await import('../server/routes/auftraegeKunde.js')
|
||||
const app = await startTestApp({ '/api/auftraege': kundeRoutes })
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([app.close(), aw.close()])
|
||||
})
|
||||
seedFixtures(aw)
|
||||
|
||||
const PDF = '%PDF-1.4 Kunden-AB'
|
||||
for (const [kundenId, id] of [['c1', 'ab1'], ['c2', 'ab3'], ['c1', 'ab4']]) await speicherePdf(kundenId, id, Buffer.from(PDF))
|
||||
const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'
|
||||
const ENTWURF = { id: 'ab2', nummer: '', jahr: null, laufnummer: null, status: 'entwurf', gesendetAm: null, gesendetAn: '', tokenHash: '', tokenGueltigBis: null, pdfDatei: '', pdfSha256: '' }
|
||||
const AB4 = { id: 'ab4', nummer: 'AB-2026-0003', status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', pdfDatei: 'c1/ab4.pdf', tokenHash: '' }
|
||||
const AB3 = { id: 'ab3', nummer: 'AB-2026-0002', kundenId: 'c2', pdfDatei: 'c2/ab3.pdf', kunde: kunde('privat', { email: 'k2@example.invalid' }), tokenHash: '' }
|
||||
|
||||
let sink
|
||||
const seed = (over = {}) => aw.seed('auftraege', { $id: over.id || 'ab1', ...auftragToDoc(abDokument(over)) })
|
||||
beforeEach(() => {
|
||||
config.auftraege.mode = 'on'
|
||||
config.auftraege.pilotEmails = []
|
||||
sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
for (const over of [{}, ENTWURF, AB3, AB4]) seed(over)
|
||||
})
|
||||
const cookie = customerCookie('c1')
|
||||
const get = (pfad, opts = { cookie }) => send('GET', `/api/auftraege${pfad}`, opts)
|
||||
const post = (pfad, body, opts = { cookie }) => send('POST', `/api/auftraege${pfad}`, { ...opts, body })
|
||||
|
||||
test('GET /api/auftraege: nur eigene, nur gesendete ABs, mit id und kannBestaetigen, ohne interne Felder', async () => {
|
||||
const res = await get('')
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.body.aktiv, true)
|
||||
assert.deepEqual(res.body.auftraege.map((a) => a.id).sort(), ['ab1', 'ab4'])
|
||||
const ab1 = res.body.auftraege.find((a) => a.id === 'ab1')
|
||||
const ab4 = res.body.auftraege.find((a) => a.id === 'ab4')
|
||||
assert.deepEqual([ab1.nummer, ab1.status, ab1.kannBestaetigen, ab4.status, ab4.kannBestaetigen], ['AB-2026-0001', 'versendet', true, 'bestaetigt', false])
|
||||
for (const intern of ['kundenId', 'kunde', 'tokenHash', 'tokenGueltigBis', 'pdfDatei', 'pdfSha256', 'gesendetAn']) assert.ok(!(intern in ab1), intern)
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store')
|
||||
})
|
||||
|
||||
test('kannBestaetigen wie in V3: versendet und abgerechnet ohne frühere Bestätigung ja, sonst nein', async () => {
|
||||
const faelle = [
|
||||
[{}, true],
|
||||
[{ status: 'abgerechnet' }, true],
|
||||
[{ status: 'abgerechnet', bestaetigtAm: '2026-10-07T08:30:00.000Z' }, false],
|
||||
[{ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z' }, false],
|
||||
[{ status: 'ersetzt' }, false],
|
||||
[{ status: 'storniert' }, false],
|
||||
[{ status: 'widerrufen' }, false],
|
||||
]
|
||||
for (const [over, erwartet] of faelle) {
|
||||
seed(over)
|
||||
const ab1 = (await get('')).body.auftraege.find((a) => a.id === 'ab1')
|
||||
assert.ok(ab1, `${JSON.stringify(over)}: auch ersetzte, stornierte und widerrufene ABs stehen in der Liste`)
|
||||
assert.equal(ab1.kannBestaetigen, erwartet, JSON.stringify(over))
|
||||
}
|
||||
})
|
||||
|
||||
test('ohne Anmeldung und mit alter Mitarbeiter-Sitzung: 401, nichts ändert sich', async () => {
|
||||
for (const opts of [{}, { cookie: adminCookie() }]) {
|
||||
assert.equal((await get('', opts)).status, 401)
|
||||
assert.equal((await get('/ab1/pdf', opts)).status, 401)
|
||||
assert.equal((await post('/ab1/bestaetigen', { bestaetigt: true }, opts)).status, 401)
|
||||
}
|
||||
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
|
||||
})
|
||||
|
||||
test('Schalter aus: leere Liste, PDF und Bestätigen wie bei unbekannter AB; im Pilot nur für Adressen der Liste', async () => {
|
||||
config.auftraege.mode = 'off'
|
||||
assert.deepEqual((await get('')).body, { aktiv: false, auftraege: [] })
|
||||
for (const res of [await get('/ab1/pdf'), await post('/ab1/bestaetigen', { bestaetigt: true })]) assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }])
|
||||
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
|
||||
config.auftraege.mode = 'pilot'
|
||||
config.auftraege.pilotEmails = ['k@example.invalid']
|
||||
assert.equal((await get('')).body.aktiv, true)
|
||||
assert.deepEqual((await get('', { cookie: customerCookie('c2') })).body, { aktiv: false, auftraege: [] })
|
||||
})
|
||||
|
||||
test('GET /api/auftraege/:id/pdf: eigene AB (auch ersetzt), nie fremde, Entwürfe, unbekannte oder ungültige IDs', async () => {
|
||||
const eigene = await get('/ab1/pdf')
|
||||
assert.deepEqual([eigene.status, eigene.headers.get('content-type'), eigene.body], [200, 'application/pdf', PDF])
|
||||
assert.equal(eigene.headers.get('content-disposition'), 'inline; filename="AB-2026-0001.pdf"')
|
||||
seed({ status: 'ersetzt' })
|
||||
assert.equal((await get('/ab1/pdf')).status, 200)
|
||||
for (const id of ['ab3', 'ab2', 'gibtsnicht', '..%2F..%2Fx', 'a'.repeat(40)]) {
|
||||
const res = await get(`/${id}/pdf`)
|
||||
assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }], id)
|
||||
}
|
||||
})
|
||||
|
||||
test('POST …/bestaetigen: bestätigt über das Portal (Protokoll, K32, T22); der zweite Aufruf ändert nichts', async () => {
|
||||
const vorher = Date.now()
|
||||
const res = await post('/ab1/bestaetigen', { bestaetigt: true, fruehbeginn: true })
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual([res.body.auftrag.id, res.body.auftrag.status, res.body.auftrag.kannBestaetigen], ['ab1', 'bestaetigt', false])
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.deepEqual([doc.bestaetigtUeber, doc.fruehbeginn], ['portal', true])
|
||||
assert.ok(new Date(doc.bestaetigtAm).getTime() >= vorher - 1000 && new Date(doc.bestaetigtAm).getTime() <= Date.now() + 1000)
|
||||
assert.equal(sink.sent.length, 2)
|
||||
const zweite = await post('/ab1/bestaetigen', { bestaetigt: true, fruehbeginn: false })
|
||||
assert.deepEqual([zweite.status, zweite.body], [200, res.body])
|
||||
assert.equal(sink.sent.length, 2)
|
||||
})
|
||||
|
||||
test('POST …/bestaetigen: ohne bestaetigt: true 400; fremde, Entwurf, unbekannte AB 404; ersetzt, storniert, widerrufen 410 – nie eine Änderung', async () => {
|
||||
for (const body of [{}, { bestaetigt: 'ja' }, { bestaetigt: false }]) {
|
||||
const res = await post('/ab1/bestaetigen', body)
|
||||
assert.deepEqual([res.status, res.body], [400, { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }])
|
||||
}
|
||||
for (const id of ['ab3', 'ab2', 'gibtsnicht']) {
|
||||
const res = await post(`/${id}/bestaetigen`, { bestaetigt: true })
|
||||
assert.deepEqual([res.status, res.body], [404, { error: NICHT_GEFUNDEN }], id)
|
||||
}
|
||||
for (const [status, text] of [['ersetzt', 'ersetzt'], ['storniert', 'storniert'], ['widerrufen', 'widerrufen']]) {
|
||||
seed({ status })
|
||||
const res = await post('/ab1/bestaetigen', { bestaetigt: true })
|
||||
assert.deepEqual([res.status, res.body], [410, { error: `Diese Auftragsbestätigung wurde ${text}.` }], status)
|
||||
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null, status)
|
||||
}
|
||||
assert.equal((await getAuftrag('ab3')).bestaetigtAm, null)
|
||||
assert.equal(sink.sent.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument } from './auftraegeBestaetigungHilfe.js'
|
||||
|
||||
// Kundenansicht (Mitarbeiter im Portal eines Kunden): Bestätigen ist eine Erklärung im Namen des Kunden und bleibt gesperrt
|
||||
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-kundenansicht-'))
|
||||
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 { startTestApp, send } = await import('./support/testApp.js')
|
||||
const { seedFixtures, customerCookie, kundenansichtCookie } = await import('./support/fixtures.js')
|
||||
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
|
||||
const { kundenansichtGuard, KUNDENANSICHT_ALLOWED } = await import('../server/middleware/kundenansichtGuard.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { default: kundeRoutes } = await import('../server/routes/auftraegeKunde.js')
|
||||
const { default: tokenRoutes } = await import('../server/routes/auftrag.js')
|
||||
// Mount-Pfade und Reihenfolge wie in server/index.js: der Wächter steht vor den Routen
|
||||
const app = await startTestApp({ '/': kundenansichtGuard, '/api/auftraege': kundeRoutes, '/api/auftrag': tokenRoutes })
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([app.close(), aw.close()])
|
||||
})
|
||||
seedFixtures(aw)
|
||||
await speicherePdf('c1', 'ab1', Buffer.from('%PDF-1.4 Test'))
|
||||
|
||||
const VIEW = 'a'.repeat(32)
|
||||
const kv = () => ({ cookie: kundenansichtCookie({ customerId: 'c1', viewId: VIEW }), headers: { 'x-kundenansicht': VIEW } })
|
||||
let sink
|
||||
beforeEach(() => {
|
||||
sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument()) })
|
||||
})
|
||||
const unveraendert = async () => {
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.deepEqual([doc.status, doc.bestaetigtAm, sink.sent.length], ['versendet', null, 0])
|
||||
}
|
||||
|
||||
test('kein Eintrag der Erlaubnisliste gilt für Aufträge', () => {
|
||||
for (const pfad of ['/api/auftraege', '/api/auftraege/ab1/bestaetigen', '/api/auftraege/ab1/pdf', `/api/auftrag/${TOKEN}`, `/api/auftrag/${TOKEN}/bestaetigen`]) {
|
||||
assert.ok(!KUNDENANSICHT_ALLOWED.some(([, muster]) => muster.test(pfad)), pfad)
|
||||
}
|
||||
})
|
||||
|
||||
test('POST /api/auftraege/:id/bestaetigen in der Kundenansicht: 403 view_blocked, die AB bleibt unverändert', async () => {
|
||||
const res = await send('POST', '/api/auftraege/ab1/bestaetigen', { ...kv(), body: { bestaetigt: true, fruehbeginn: true } })
|
||||
assert.equal(res.status, 403)
|
||||
assert.equal(res.body.code, 'view_blocked')
|
||||
await unveraendert()
|
||||
})
|
||||
|
||||
test('alle Schreib-Routen des Kunden-Routers sind in der Kundenansicht gesperrt (nur Bestätigen ist eine)', async () => {
|
||||
const schreibRouten = kundeRoutes.stack
|
||||
.filter((schicht) => schicht.route)
|
||||
.flatMap((schicht) => Object.keys(schicht.route.methods).filter((m) => !['get', 'head', 'options', '_all'].includes(m)).map((m) => [m.toUpperCase(), `/api/auftraege${String(schicht.route.path).replace(/:[A-Za-z]+/g, 'ab1')}`]))
|
||||
assert.deepEqual(schreibRouten, [['POST', '/api/auftraege/ab1/bestaetigen']])
|
||||
for (const [methode, pfad] of schreibRouten) {
|
||||
const res = await send(methode, pfad, { ...kv(), body: { bestaetigt: true } })
|
||||
assert.equal(res.status, 403, `${methode} ${pfad}`)
|
||||
}
|
||||
await unveraendert()
|
||||
})
|
||||
|
||||
test('auch der öffentliche Link bestätigt nichts, wenn der Browser eine Kundenansicht-Sitzung trägt', async () => {
|
||||
const mitKennung = await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { ...kv(), body: { bestaetigt: true } })
|
||||
assert.deepEqual([mitKennung.status, mitKennung.body.code], [403, 'view_blocked'])
|
||||
const ohneKennung = await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { cookie: kv().cookie, body: { bestaetigt: true } })
|
||||
assert.deepEqual([ohneKennung.status, ohneKennung.body.code], [409, 'view_replaced'])
|
||||
await unveraendert()
|
||||
})
|
||||
|
||||
test('Lesen bleibt erlaubt, kannBestaetigen ist in der Kundenansicht false; der Kunde selbst darf bestätigen', async () => {
|
||||
const liste = await send('GET', '/api/auftraege', kv())
|
||||
assert.equal(liste.status, 200)
|
||||
assert.deepEqual(liste.body.auftraege.map((a) => [a.id, a.kannBestaetigen]), [['ab1', false]])
|
||||
assert.equal((await send('GET', '/api/auftraege/ab1/pdf', kv())).status, 200)
|
||||
const selbst = await send('GET', '/api/auftraege', { cookie: customerCookie('c1') })
|
||||
assert.deepEqual(selbst.body.auftraege.map((a) => [a.id, a.kannBestaetigen]), [['ab1', true]])
|
||||
const res = await send('POST', '/api/auftraege/ab1/bestaetigen', { cookie: customerCookie('c1'), body: { bestaetigt: true } })
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
TOKEN_MUSTER, KONTAKT, ANREDE_SIE, ANREDE_DU, TEXTE_GEMEINSAM, TEXTE_SIE, TEXTE_DU, texte, tokenAusAdresse, euroDe, datumDe, zeitpunktDe,
|
||||
pdfPfad, agbPfad, zeigtFruehbeginn, darfBestaetigen, statusAnzeige, kartenMeta, zusammenfassung, bestaetigtZeilen, zustandAusAntwort,
|
||||
fehlerMeldung, sortiereAuftraege, abNummerNormalisiert,
|
||||
} from '../public/auftrag-ansicht.js'
|
||||
import { TOKEN, BESTAETIGT_AM, KLEINUNTERNEHMER, ab, privat, bestaetigt, hatDuForm, hatSieForm, pruefeQuelltext } from './auftraegeKundenportalDaten.js'
|
||||
|
||||
const NB = '\u{a0}' // geschütztes Leerzeichen vor „€“ (wie euroDe auf dem Server)
|
||||
|
||||
test('Token: 43 Zeichen base64url, zuerst aus #t= (so verlinkt K31), sonst aus ?t=, nie mehrdeutig', () => {
|
||||
assert.equal(TOKEN.length, 43)
|
||||
assert.match(TOKEN, TOKEN_MUSTER)
|
||||
assert.equal(tokenAusAdresse('', `#t=${TOKEN}`), TOKEN)
|
||||
assert.equal(tokenAusAdresse(`?t=${TOKEN}`, ''), TOKEN)
|
||||
assert.equal(tokenAusAdresse(`?x=1&t=${TOKEN}`, ''), TOKEN)
|
||||
const anderer = 'y'.repeat(43)
|
||||
assert.equal(tokenAusAdresse(`?t=${TOKEN}`, `#t=${anderer}`), anderer, 'das Fragment gewinnt')
|
||||
assert.equal(tokenAusAdresse(`?t=${TOKEN}`, '#t=abc'), TOKEN, 'ungültiges Fragment: weiter mit der Suche')
|
||||
for (const hash of ['', '#', '#t=', '#t=abc', `#t=${TOKEN}x`, `#t=${TOKEN.slice(0, 42)}!`, `#t=${TOKEN}&t=${TOKEN}`, `#token=${TOKEN}`]) {
|
||||
assert.equal(tokenAusAdresse('', hash), '', hash)
|
||||
}
|
||||
assert.equal(tokenAusAdresse(`?t=${TOKEN}&t=${TOKEN}`, ''), '')
|
||||
assert.equal(tokenAusAdresse(undefined, undefined), '')
|
||||
})
|
||||
|
||||
test('Beträge: ganze Cent als deutscher Euro-Betrag wie auf dem Server', () => {
|
||||
assert.equal(euroDe(80000), `800,00${NB}€`)
|
||||
assert.equal(euroDe(123456), `1.234,56${NB}€`)
|
||||
assert.equal(euroDe(5), `0,05${NB}€`)
|
||||
assert.equal(euroDe(0), `0,00${NB}€`)
|
||||
assert.equal(euroDe(-449), `−4,49${NB}€`)
|
||||
for (const kaputt of [1.5, '800', null, undefined, Number.NaN]) assert.equal(euroDe(kaputt), '–')
|
||||
})
|
||||
|
||||
test('Datum und Zeit: Berliner Zeit, unabhängig von der Zeitzone des Browsers', () => {
|
||||
assert.equal(datumDe('2026-10-06'), '06.10.2026')
|
||||
assert.equal(datumDe('06.10.2026'), '06.10.2026')
|
||||
assert.equal(datumDe('2026-10-06T22:30:00.000Z'), '07.10.2026') // 00:30 MESZ am Folgetag
|
||||
for (const kaputt of ['', null, undefined, 'morgen', '2026-10', 20261006]) assert.equal(datumDe(kaputt), '')
|
||||
assert.equal(zeitpunktDe(BESTAETIGT_AM), '07.10.2026, 10:15 Uhr')
|
||||
assert.equal(zeitpunktDe('2026-10-07T08:15:00.000+00:00'), '07.10.2026, 10:15 Uhr') // so liefert Appwrite
|
||||
assert.equal(zeitpunktDe('2026-12-01T08:15:00Z'), '01.12.2026, 09:15 Uhr') // Winterzeit
|
||||
assert.equal(zeitpunktDe('2026-10-06T22:05:00Z'), '07.10.2026, 00:05 Uhr') // Mitternacht ohne „24“
|
||||
for (const kaputt of ['', null, undefined, 'bald']) assert.equal(zeitpunktDe(kaputt), '')
|
||||
})
|
||||
|
||||
test('Links: PDF-Pfad aus dem Token, AGB nur als PDF unter /rechtliches/', () => {
|
||||
assert.equal(pdfPfad(TOKEN), `/api/auftrag/${TOKEN}/pdf`)
|
||||
assert.equal(agbPfad('/rechtliches/agb-2026-09-30.pdf'), '/rechtliches/agb-2026-09-30.pdf')
|
||||
for (const boese of ['https://evil.example/agb.pdf', '//evil.example/agb.pdf', '/rechtliches/../x.pdf', '/rechtliches/agb.pdf?x=1', '/rechtliches/agb.html', 'javascript:alert(1)', '', null, undefined, 5]) {
|
||||
assert.equal(agbPfad(boese), '', String(boese))
|
||||
}
|
||||
})
|
||||
|
||||
test('Häkchen: Frühbeginn nur mit Text vom Server; Bestätigen nur, solange nicht bestätigt', () => {
|
||||
assert.equal(zeigtFruehbeginn(privat()), true)
|
||||
assert.equal(zeigtFruehbeginn(ab()), false)
|
||||
assert.equal(zeigtFruehbeginn(privat({ texte: { bestaetigung: 'x', fruehbeginn: ' ' } })), false)
|
||||
assert.equal(zeigtFruehbeginn({}), false)
|
||||
assert.equal(darfBestaetigen(ab()), true)
|
||||
assert.equal(darfBestaetigen(ab({ status: 'abgerechnet' })), true) // Schlussrechnung vor der Bestätigung (Übersicht V2)
|
||||
assert.equal(darfBestaetigen(bestaetigt(ab())), false)
|
||||
assert.equal(darfBestaetigen(ab({ status: 'abgerechnet', bestaetigtAm: BESTAETIGT_AM })), false)
|
||||
for (const status of ['storniert', 'ersetzt', 'widerrufen', 'entwurf']) assert.equal(darfBestaetigen(ab({ status })), false, status)
|
||||
})
|
||||
|
||||
test('Status: Beschriftung und Ton; „Bitte bestätigen“ nur mit kannBestaetigen und ohne Bestätigung', () => {
|
||||
assert.deepEqual(statusAnzeige({ status: 'versendet', kannBestaetigen: true, bestaetigtAm: null }), { text: 'Bitte bestätigen', ton: 'warn' })
|
||||
assert.deepEqual(statusAnzeige({ status: 'abgerechnet', kannBestaetigen: true, bestaetigtAm: null }), { text: 'Bitte bestätigen', ton: 'warn' })
|
||||
assert.deepEqual(statusAnzeige({ status: 'versendet' }), { text: 'Versendet', ton: 'muted' })
|
||||
assert.deepEqual(statusAnzeige({ status: 'bestaetigt', bestaetigtAm: BESTAETIGT_AM, kannBestaetigen: false }), { text: 'Bestätigt', ton: 'ok' })
|
||||
assert.deepEqual(statusAnzeige({ status: 'abgerechnet', bestaetigtAm: BESTAETIGT_AM }), { text: 'Abgerechnet', ton: 'ok' })
|
||||
assert.deepEqual(statusAnzeige({ status: 'storniert' }), { text: 'Storniert', ton: 'muted' })
|
||||
assert.deepEqual(statusAnzeige({ status: 'ersetzt' }), { text: 'Ersetzt', ton: 'muted' })
|
||||
assert.deepEqual(statusAnzeige({ status: 'widerrufen' }), { text: 'Widerrufen', ton: 'rot' })
|
||||
assert.deepEqual(statusAnzeige({ status: 'unbekannt' }), { text: '–', ton: 'muted' })
|
||||
assert.deepEqual(statusAnzeige(undefined), { text: '–', ton: 'muted' })
|
||||
})
|
||||
|
||||
test('Karten-Zeile und Sortierung der Portalliste', () => {
|
||||
assert.equal(kartenMeta(ab()), `vom 06.10.2026 · 800,00${NB}€`)
|
||||
assert.equal(kartenMeta(ab({ art: 'aufwand', positionen: [], summeCents: null })), 'vom 06.10.2026 · nach Aufwand')
|
||||
assert.equal(kartenMeta(ab({ datum: '' })), `800,00${NB}€`)
|
||||
assert.equal(kartenMeta({}), '')
|
||||
const liste = [
|
||||
{ nummer: 'AB-2026-0001', kannBestaetigen: false }, { nummer: 'AB-2026-0003', kannBestaetigen: false },
|
||||
{ nummer: 'AB-2026-0002', kannBestaetigen: true }, { nummer: 'AB-2025-0009', kannBestaetigen: false },
|
||||
]
|
||||
assert.deepEqual(sortiereAuftraege(liste).map((a) => a.nummer), ['AB-2026-0002', 'AB-2026-0003', 'AB-2026-0001', 'AB-2025-0009'])
|
||||
assert.equal(liste[0].nummer, 'AB-2026-0001', 'die Eingabe bleibt unverändert')
|
||||
assert.deepEqual(sortiereAuftraege(undefined), [])
|
||||
})
|
||||
|
||||
test('Zusammenfassung (Festpreis): Kopf, Positionen, Summe, Zahlungsplan, Termine, § 19 aus der API', () => {
|
||||
assert.deepEqual(zusammenfassung(ab()), {
|
||||
kopf: [{ label: 'Nummer', wert: 'AB-2026-0002' }, { label: 'Datum', wert: '06.10.2026' }, { label: 'Projekt', wert: 'Website Anelia Wolf' }],
|
||||
art: 'festpreis',
|
||||
positionen: [
|
||||
{ beschreibung: 'Erstellung der Website anelia-wolf.com', menge: '1', einzelpreis: `600,00${NB}€`, summe: `600,00${NB}€` },
|
||||
{ beschreibung: 'Texte und Bildbearbeitung', menge: '2', einzelpreis: `100,00${NB}€`, summe: `200,00${NB}€` },
|
||||
],
|
||||
summe: `800,00${NB}€`,
|
||||
aufwand: '',
|
||||
zahlung: ab().zahlungsplanText,
|
||||
termine: [{ label: 'Start', wert: '12.10.2026' }, { label: 'Voraussichtliche Fertigstellung', wert: '30.11.2026' }],
|
||||
hinweis: KLEINUNTERNEHMER,
|
||||
})
|
||||
})
|
||||
|
||||
test('Zusammenfassung (Aufwand): Aufwandstext statt Positionen; Termine nur, wenn gesetzt; ohne Satz der API kein § 19-Hinweis; leere Eingabe wirft nicht', () => {
|
||||
const z = zusammenfassung(ab({
|
||||
art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.',
|
||||
zahlungsplanText: 'Monatlich nach Aufwand.', startAm: '2026-10-12', fertigBis: '',
|
||||
}))
|
||||
assert.equal(z.art, 'aufwand')
|
||||
assert.deepEqual(z.positionen, [])
|
||||
assert.equal(z.summe, '')
|
||||
assert.equal(z.aufwand, '100,00 € pro Stunde in Schritten von 15 Minuten.')
|
||||
assert.equal(z.zahlung, 'Monatlich nach Aufwand.')
|
||||
assert.deepEqual(z.termine, [{ label: 'Start', wert: '12.10.2026' }])
|
||||
assert.equal(z.hinweis, KLEINUNTERNEHMER)
|
||||
assert.equal(zusammenfassung(ab({ texte: { bestaetigung: 'x', fruehbeginn: null } })).hinweis, '', 'keine eigene Ersatzkonstante: der Satz kommt von der API')
|
||||
assert.deepEqual(zusammenfassung({}), { kopf: [], art: 'festpreis', positionen: [], summe: '', aufwand: '', zahlung: '', termine: [], hinweis: '' })
|
||||
assert.doesNotThrow(() => zusammenfassung(undefined))
|
||||
})
|
||||
|
||||
test('Bestätigt-Zeilen: Zeitpunkt, bei Privatpersonen Frühbeginn bzw. frühester Beginn, in beiden Anreden', () => {
|
||||
assert.deepEqual(bestaetigtZeilen(ab()), [])
|
||||
assert.deepEqual(bestaetigtZeilen(bestaetigt(ab())), ['Bestätigt am 07.10.2026, 10:15 Uhr'])
|
||||
assert.deepEqual(bestaetigtZeilen(bestaetigt(ab({ fruehesterBeginn: '2026-10-21' }))), ['Bestätigt am 07.10.2026, 10:15 Uhr'], 'Unternehmen: keine Zeile zum Beginn')
|
||||
assert.deepEqual(bestaetigtZeilen(bestaetigt(privat())), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen frühestens am 21.10.2026.'])
|
||||
const wunsch = bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null }))
|
||||
assert.deepEqual(bestaetigtZeilen(wunsch), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.'])
|
||||
assert.deepEqual(bestaetigtZeilen(wunsch, ANREDE_DU), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.'])
|
||||
assert.deepEqual(bestaetigtZeilen(bestaetigt(privat({ fruehesterBeginn: null }))), ['Bestätigt am 07.10.2026, 10:15 Uhr'])
|
||||
assert.deepEqual(bestaetigtZeilen({ bestaetigtAm: 'kaputt' }), ['Bestätigt'])
|
||||
})
|
||||
|
||||
test('Zustände aus der Serverantwort: feste Texte, der 410-Grund nur aus dem Stichwort', () => {
|
||||
const kontakt = 'Bitte melden Sie sich bei uns. Wir senden Ihnen gern einen neuen Link.'
|
||||
assert.deepEqual(zustandAusAntwort(404, { error: 'Dieser Link ist ungültig oder abgelaufen.' }), { zustand: 'ungueltig', titel: 'Dieser Link ist ungültig oder abgelaufen.', text: kontakt })
|
||||
assert.deepEqual(zustandAusAntwort(410, { error: 'Diese Auftragsbestätigung wurde ersetzt.' }), {
|
||||
zustand: 'beendet', titel: 'Diese Auftragsbestätigung wurde ersetzt.', text: 'Bitte verwenden Sie den Link in der neuesten Auftragsbestätigung, die wir Ihnen geschickt haben.',
|
||||
})
|
||||
for (const grund of ['storniert', 'widerrufen']) {
|
||||
assert.deepEqual(zustandAusAntwort(410, { error: `Diese Auftragsbestätigung wurde ${grund}.` }), {
|
||||
zustand: 'beendet', titel: `Diese Auftragsbestätigung wurde ${grund}.`, text: 'Bei Fragen melden Sie sich gern bei uns.',
|
||||
})
|
||||
}
|
||||
for (const body of [{ error: `Irgendwas mit ${TOKEN}` }, {}, undefined, null]) { // fremder Text (auch mit Token) wird nie angezeigt
|
||||
const z = zustandAusAntwort(410, body)
|
||||
assert.equal(z.titel, 'Diese Auftragsbestätigung ist nicht mehr gültig.')
|
||||
assert.equal(z.text, 'Bei Fragen melden Sie sich gern bei uns.')
|
||||
}
|
||||
assert.deepEqual(zustandAusAntwort(429, { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }), { zustand: 'limit', titel: 'Zu viele Anfragen', text: 'Bitte versuchen Sie es in einigen Minuten noch einmal.' })
|
||||
for (const status of [0, 400, 401, 500, 502, 503]) {
|
||||
assert.deepEqual(zustandAusAntwort(status, { error: 'Interner Fehler.' }), {
|
||||
zustand: 'fehler', titel: 'Das hat leider nicht geklappt', text: 'Ihre Auftragsbestätigung konnte gerade nicht geladen werden. Bitte versuchen Sie es in einigen Minuten noch einmal.',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('Fehlermeldungen beim Bestätigen: feste eigene Texte nach Status, Sie auf der Seite und Du im Portal', () => {
|
||||
assert.equal(fehlerMeldung(0), 'Keine Verbindung. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.')
|
||||
assert.equal(fehlerMeldung(400), 'Bitte setzen Sie das Häkchen, um den Auftrag zu bestätigen.')
|
||||
assert.equal(fehlerMeldung(401), 'Ihre Anmeldung ist abgelaufen. Bitte laden Sie die Seite neu und melden Sie sich erneut an.')
|
||||
for (const status of [403, 409]) assert.equal(fehlerMeldung(status), 'Das Bestätigen ist gerade nicht möglich. Bitte laden Sie die Seite neu.')
|
||||
assert.equal(fehlerMeldung(429), 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.')
|
||||
for (const status of [500, 502, 503, undefined]) assert.equal(fehlerMeldung(status), 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuchen Sie es später noch einmal.')
|
||||
assert.equal(fehlerMeldung(0, ANREDE_DU), 'Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut.')
|
||||
assert.equal(fehlerMeldung(400, ANREDE_DU), 'Bitte setze das Häkchen, um den Auftrag zu bestätigen.')
|
||||
assert.equal(fehlerMeldung(401, ANREDE_DU), 'Deine Anmeldung ist abgelaufen. Bitte lade die Seite neu und melde dich erneut an.')
|
||||
for (const status of [403, 409]) assert.equal(fehlerMeldung(status, ANREDE_DU), 'Das Bestätigen ist gerade nicht möglich. Bitte lade die Seite neu.')
|
||||
assert.equal(fehlerMeldung(429, ANREDE_DU), 'Zu viele Anfragen. Bitte versuche es in einigen Minuten noch einmal.')
|
||||
for (const status of [500, 502, 503, undefined]) assert.equal(fehlerMeldung(status, ANREDE_DU), 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.')
|
||||
})
|
||||
|
||||
const werte = (objekt) => Object.values(objekt).flatMap((wert) => (wert && typeof wert === 'object' ? werte(wert) : [wert])).filter((wert) => typeof wert === 'string')
|
||||
|
||||
test('Anrede: gemeinsame Texte neutral, öffentliche Seite Sie, Portal Du; jede Du-Variante hat ihr Sie-Gegenstück', () => {
|
||||
assert.equal(ANREDE_SIE, 'sie')
|
||||
assert.equal(ANREDE_DU, 'du')
|
||||
for (const text of werte(TEXTE_GEMEINSAM)) assert.ok(!hatDuForm(text) && !hatSieForm(text), text)
|
||||
for (const text of werte(TEXTE_SIE)) assert.ok(!hatDuForm(text), text)
|
||||
for (const text of werte(TEXTE_DU)) assert.ok(!hatSieForm(text), text)
|
||||
assert.deepEqual(Object.keys(TEXTE_DU).sort(), ['fehler', 'formular', 'fruehbeginnWunsch', 'speicherHinweis'])
|
||||
for (const schluessel of Object.keys(TEXTE_DU)) assert.ok(schluessel in TEXTE_SIE, schluessel)
|
||||
assert.deepEqual(Object.keys(TEXTE_DU.fehler), Object.keys(TEXTE_SIE.fehler))
|
||||
assert.equal(texte().formular, 'Ihre Bestätigung', 'ohne Angabe gilt Sie')
|
||||
assert.equal(texte(ANREDE_SIE).speicherHinweis, 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.')
|
||||
assert.equal(texte(ANREDE_DU).formular, 'Deine Bestätigung')
|
||||
assert.equal(texte(ANREDE_DU).speicherHinweis, 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.')
|
||||
assert.equal(texte(ANREDE_DU).knopf, 'Auftrag bestätigen', 'gemeinsame Texte gibt es in beiden Anreden')
|
||||
assert.equal(texte(ANREDE_DU).ansichtsmodus, 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.')
|
||||
})
|
||||
|
||||
test('AB-Nummer: Schreibweisen der Praxis werden zu AB-JJJJ-NNNN, alles andere zu leer', () => {
|
||||
assert.equal(abNummerNormalisiert('AB-2026-0001'), 'AB-2026-0001')
|
||||
assert.equal(abNummerNormalisiert(' ab-2026-0001 '), 'AB-2026-0001')
|
||||
assert.equal(abNummerNormalisiert('ab 2026 0001'), 'AB-2026-0001')
|
||||
assert.equal(abNummerNormalisiert('AB – 2026 – 0001'), 'AB-2026-0001') // Halbgeviertstrich mit Leerzeichen
|
||||
assert.equal(abNummerNormalisiert('AB−2026−0001'), 'AB-2026-0001') // Minuszeichen
|
||||
for (const kaputt of ['', ' ', 'AB-2026-01', 'AB-26-0001', 'RE-2026-0001', 'AB-2026-0001-1', 'AB20260001', undefined, null]) assert.equal(abNummerNormalisiert(kaputt), '', String(kaputt))
|
||||
})
|
||||
|
||||
test('Quelltext und Kontakt: reines Modul ohne Importe, fetch und DOM; Kontaktdaten wie in den Rechtstexten', () => {
|
||||
pruefeQuelltext('auftrag-ansicht.js', { mitTexten: true })
|
||||
assert.deepEqual(KONTAKT, { email: 'support@webklar.com', telefon: '0176 23726355' })
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
|
||||
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
|
||||
const html = lies('public/dashboard.html')
|
||||
const app = lies('public/app.js')
|
||||
const css = lies('public/auftrag.css')
|
||||
|
||||
test('dashboard.html: auftrag.css nach zeiten.css; Abschnitt „Aufträge“ vor der Rechnungsliste, zunächst versteckt', () => {
|
||||
assert.match(html, /<link rel="stylesheet" href="\/zeiten\.css">\s*<link rel="stylesheet" href="\/auftrag\.css">/)
|
||||
const start = html.indexOf('id="view-invoices"')
|
||||
const ende = html.indexOf('</section>', html.indexOf('id="invoices-abo-note"'))
|
||||
const ansicht = html.slice(start, ende)
|
||||
assert.match(ansicht, /<section class="ab-portal hidden" id="orders-section" aria-labelledby="orders-title">\s*<h2 id="orders-title">Aufträge<\/h2>[\s\S]*<div id="orders-body"><\/div>\s*<\/section>\s*<h2 class="ab-rechnungen-titel hidden" id="invoices-title">Rechnungen<\/h2>\s*<div class="kb-panel" id="invoices-body">/)
|
||||
assert.match(ansicht, /<p class="kb-note">Deine Auftragsbestätigungen\. Offene Aufträge bestätigst du hier mit einem Klick\.<\/p>/, 'Portal: Du-Form')
|
||||
assert.ok(ansicht.indexOf('id="orders-section"') < ansicht.indexOf('id="invoices-body"'))
|
||||
assert.equal([...html.matchAll(/id="orders-section"/g)].length, 1)
|
||||
assert.match(ansicht, /<h1>Rechnungen<\/h1>/, 'Kopf und Hinweis des Reiters bleiben')
|
||||
})
|
||||
|
||||
test('app.js: loadCustomerOrders lädt auftrag-portal.js nach der Rechnungsliste, mit api und Ansichtsmodus; Fehler bleiben still', () => {
|
||||
const funktion = app.slice(app.indexOf('function loadCustomerOrders()'), app.indexOf('/* ---', app.indexOf('function loadCustomerOrders()')))
|
||||
assert.ok(funktion.length > 40, 'Funktion gefunden')
|
||||
assert.match(funktion, /import\('\/auftrag-portal\.js'\)/)
|
||||
assert.match(funktion, /m\.initAuftraege\(/)
|
||||
for (const teil of ["section: document.getElementById('orders-section')", "body: document.getElementById('orders-body')", "invoicesTitle: document.getElementById('invoices-title')", 'request: api', 'viewAs: chatState.viewAs']) {
|
||||
assert.ok(funktion.includes(teil), teil)
|
||||
}
|
||||
assert.match(funktion, /\.catch\(\(\) => \{\}\)/)
|
||||
const dashboard = app.slice(app.indexOf('async function initDashboardPage'))
|
||||
const aufruf = (name) => dashboard.search(new RegExp(`^\\s*${name}\\(\\)\\s*$`, 'm'))
|
||||
assert.ok(aufruf('loadCustomerInvoices') > 0 && aufruf('loadCustomerOrders') > aufruf('loadCustomerInvoices'), 'ein eigener Aufruf, nach der Rechnungsliste')
|
||||
assert.equal([...app.matchAll(/function loadCustomerOrders\(\)/g)].length, 1)
|
||||
})
|
||||
|
||||
test('invoiceNumberHtml: Projekt unter der Rechnungsnummer, alles maskiert', () => {
|
||||
const ausschnitt = (muster) => {
|
||||
const treffer = app.match(muster)
|
||||
assert.ok(treffer, `${muster} nicht in app.js`)
|
||||
return treffer[0]
|
||||
}
|
||||
const code = [
|
||||
ausschnitt(/function escapeHtml\(str\) \{[\s\S]*?\n\}/),
|
||||
ausschnitt(/\/\/ @rechnungsnummer-anfang[\s\S]*?\/\/ @rechnungsnummer-ende/),
|
||||
].join('\n')
|
||||
const { invoiceNumberHtml } = vm.runInNewContext(`${code}\n({ invoiceNumberHtml })`)
|
||||
assert.equal(invoiceNumberHtml({ number: 'WEBKLAR-0009', projekt: 'Website Anelia Wolf' }), 'WEBKLAR-0009<span class="inv-project">Website Anelia Wolf</span>')
|
||||
assert.equal(invoiceNumberHtml({ number: 'WEBKLAR-0009', projekt: '' }), 'WEBKLAR-0009')
|
||||
assert.equal(invoiceNumberHtml({ number: 'H96T-0004' }), 'H96T-0004')
|
||||
assert.equal(
|
||||
invoiceNumberHtml({ number: 'X', projekt: '<img src=x onerror=alert(1)> & "Bäckerei"' }),
|
||||
'X<span class="inv-project"><img src=x onerror=alert(1)> & "Bäckerei"</span>',
|
||||
)
|
||||
assert.match(app, /<td class="inv-no">\$\{invoiceNumberHtml\(i\)\}<\/td>/)
|
||||
assert.doesNotMatch(app, /\$\{i\.projekt\}/, 'der Projektname geht nie unmaskiert in eine Vorlage')
|
||||
})
|
||||
|
||||
test('auftrag.css: Projektname unter der Rechnungsnummer (Tabelle und Handy-Karte)', () => {
|
||||
assert.match(css, /\.inv-project\s*\{[^}]*display:\s*block/)
|
||||
assert.match(css, /\.inv-project\s*\{[^}]*overflow-wrap:\s*anywhere/)
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
// Gemeinsame Testdaten und Quelltext-Regeln des Strangs L6 (kein Test, daher nicht *.test.js).
|
||||
// Formen laut Übersicht V3 (AuftragOeffentlich) und V5 (Liste im Portal: plus id und kannBestaetigen).
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
export const TOKEN = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG' // 43 Zeichen wie ein echter Token (base64url)
|
||||
export const BESTAETIGT_AM = '2026-10-07T08:15:00.000Z' // = 07.10.2026, 10:15 Uhr Berliner Zeit (MESZ)
|
||||
export const KLEINUNTERNEHMER = 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.' // wie AB_TEXTE.kleinunternehmer; die API liefert ihn als texte.kleinunternehmer
|
||||
|
||||
/** Festpreis-AB eines Unternehmens, gesendet, noch nicht bestätigt. */
|
||||
export const ab = (over = {}) => ({
|
||||
nummer: 'AB-2026-0002', datum: '2026-10-06', titel: 'Website Anelia Wolf', art: 'festpreis',
|
||||
positionen: [
|
||||
{ beschreibung: 'Erstellung der Website anelia-wolf.com', menge: 1, einzelpreisCents: 60000, summeCents: 60000 },
|
||||
{ beschreibung: 'Texte und Bildbearbeitung', menge: 2, einzelpreisCents: 10000, summeCents: 20000 },
|
||||
],
|
||||
summeCents: 80000, stundensatzCents: null, schaetzungMinuten: null, aufwandText: null,
|
||||
zahlungsplanText: 'Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.',
|
||||
startAm: '2026-10-12', fertigBis: '2026-11-30', kundenart: 'unternehmen', status: 'versendet',
|
||||
bestaetigtAm: null, fruehbeginn: false, fruehesterBeginn: null, agbUrl: '/rechtliches/agb-2026-09-30.pdf',
|
||||
texte: { bestaetigung: 'Ich bestätige den Auftrag zu diesen Bedingungen.', fruehbeginn: null, kleinunternehmer: KLEINUNTERNEHMER },
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Dieselbe AB für eine Privatperson (zweites, freiwilliges Häkchen; Texte sind Testtexte, nicht die der Rechtsrunde). */
|
||||
export const privat = (over = {}) => ab({
|
||||
kundenart: 'privat', fruehesterBeginn: '2026-10-21',
|
||||
texte: {
|
||||
bestaetigung: 'Ich bestätige den Auftrag zu diesen Bedingungen.',
|
||||
fruehbeginn: 'Ich verlange, dass Sie schon vor Ablauf der Widerrufsfrist mit der Arbeit beginnen.',
|
||||
kleinunternehmer: KLEINUNTERNEHMER,
|
||||
},
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Die übergebene AB im Zustand „bestätigt“. */
|
||||
export const bestaetigt = (auftrag) => ({ ...auftrag, status: 'bestaetigt', bestaetigtAm: BESTAETIGT_AM })
|
||||
|
||||
/** Eintrag der Portal-Liste (GET /api/auftraege): AB plus id und kannBestaetigen. */
|
||||
export const eintrag = (over = {}) => ({ id: 'ab1', ...ab(), kannBestaetigen: true, ...over })
|
||||
|
||||
const ohneKommentare = (quelle) => quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
|
||||
const DU = /\b(?:du|dein(?:e[nmrs]?)?|dich|dir)\b/i
|
||||
const SIE = /\b(?:Sie|Ihr(?:e[nmrs]?)?|Ihnen)\b/
|
||||
/** Steht eine Du- bzw. Sie-Form im Text? (Anrede-Prüfungen der Tests) */
|
||||
export const hatDuForm = (text) => DU.test(String(text))
|
||||
export const hatSieForm = (text) => SIE.test(String(text))
|
||||
|
||||
/**
|
||||
* Quelltext-Regeln für jedes Modul public/auftrag*.js; gibt den Quelltext ohne Kommentare zurück.
|
||||
* mitTexten: nur auftrag-ansicht.js enthält Kundentexte (ihre Anrede prüft der Test selbst); alle anderen Module enthalten weder Du- noch Sie-Formen.
|
||||
*/
|
||||
export function pruefeQuelltext(datei, { fetchAnzahl = 0, importe = [], mitTexten = false } = {}) {
|
||||
const quelle = ohneKommentare(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8'))
|
||||
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|\beval\(|new Function/, `${datei}: Daten nie als HTML`)
|
||||
assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie|indexedDB/, `${datei}: kein Browserspeicher`)
|
||||
assert.doesNotMatch(quelle, /\bconsole\./, `${datei}: nichts ins Protokoll (der Token!)`)
|
||||
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: keine Browser-Rückfragen`)
|
||||
assert.doesNotMatch(quelle, /import\s*\(/, `${datei}: kein dynamischer Import`)
|
||||
assert.ok(!quelle.includes(String.fromCodePoint(0xa0)), `${datei}: kein verstecktes geschütztes Leerzeichen im Quelltext`)
|
||||
if (!mitTexten) assert.ok(!DU.test(quelle) && !hatSieForm(quelle), `${datei}: keine Kundentexte im Modul (sie stehen in auftrag-ansicht.js; Anrede über ANREDE_SIE/ANREDE_DU)`)
|
||||
assert.equal([...quelle.matchAll(/\bfetch\(/g)].length, fetchAnzahl, `${datei}: Anzahl fetch`)
|
||||
assert.deepEqual([...quelle.matchAll(/\bfrom\s+'([^']+)'/g)].map((m) => m[1]), importe, `${datei}: Importe`)
|
||||
return quelle
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { installFakeDom } from './support/fakeDom.js'
|
||||
import { renderZusammenfassung, renderBestaetigtBlock, renderDokumente, renderBestaetigung } from '../public/auftrag-dom.js'
|
||||
import { KLEINUNTERNEHMER, ab, privat, bestaetigt, pruefeQuelltext } from './auftraegeKundenportalDaten.js'
|
||||
import { ANREDE_DU } from '../public/auftrag-ansicht.js'
|
||||
|
||||
const NB = '\u{a0}'
|
||||
const mit = (fn) => async () => {
|
||||
const fx = installFakeDom()
|
||||
try {
|
||||
await fn(fx)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
}
|
||||
const paare = (fx, baum) => fx.alle(baum, '.ab-zeile').map((z) => [fx.finde(z, 'dt').textContent, fx.finde(z, 'dd').textContent])
|
||||
|
||||
test('Zusammenfassung (Festpreis): Kopfzeilen, Positionen mit Spaltenmarken, Gesamtpreis, § 19, Zahlung, Termine', mit(async (fx) => {
|
||||
const el = renderZusammenfassung(ab())
|
||||
assert.equal(el.getAttribute('data-art'), 'festpreis')
|
||||
assert.deepEqual(paare(fx, el).slice(0, 3), [['Nummer', 'AB-2026-0002'], ['Datum', '06.10.2026'], ['Projekt', 'Website Anelia Wolf']])
|
||||
const zeilen = fx.alle(el, '[data-position]')
|
||||
assert.equal(zeilen.length, 2)
|
||||
assert.deepEqual(fx.alle(zeilen[1], 'td').map((td) => td.textContent), ['Texte und Bildbearbeitung', '2', `100,00${NB}€`, `200,00${NB}€`])
|
||||
assert.deepEqual(fx.alle(zeilen[1], 'td').map((td) => td.getAttribute('data-label')), ['Beschreibung', 'Menge', 'Einzelpreis', 'Summe'])
|
||||
assert.deepEqual(fx.alle(el, 'th').map((th) => th.textContent), ['Beschreibung', 'Menge', 'Einzelpreis', 'Summe'])
|
||||
assert.equal(fx.finde(el, '[data-gesamt] strong').textContent, `800,00${NB}€`)
|
||||
assert.equal(fx.finde(el, '[data-hinweis]').textContent, KLEINUNTERNEHMER)
|
||||
assert.equal(fx.finde(el, '[data-zahlung]').textContent, ab().zahlungsplanText)
|
||||
assert.deepEqual(paare(fx, fx.finde(el, '[data-abschnitt="termine"]')), [['Start', '12.10.2026'], ['Voraussichtliche Fertigstellung', '30.11.2026']])
|
||||
}))
|
||||
|
||||
test('Zusammenfassung (Aufwand): Aufwandstext statt Tabelle und Gesamtpreis; ohne Termine kein Abschnitt', mit(async (fx) => {
|
||||
const a = ab({ art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.', startAm: '', fertigBis: '' })
|
||||
const el = renderZusammenfassung(a)
|
||||
assert.equal(el.getAttribute('data-art'), 'aufwand')
|
||||
assert.equal(fx.finde(el, 'table'), null)
|
||||
assert.equal(fx.finde(el, '[data-gesamt]'), null)
|
||||
assert.equal(fx.finde(el, '[data-aufwand]').textContent, '100,00 € pro Stunde in Schritten von 15 Minuten.')
|
||||
assert.equal(fx.finde(el, '[data-hinweis]').textContent, KLEINUNTERNEHMER)
|
||||
assert.equal(fx.finde(el, '[data-abschnitt="termine"]'), null)
|
||||
}))
|
||||
|
||||
test('Zusammenfassung: der § 19-Satz kommt nur aus der API; fehlt er, gibt es keinen Hinweis', mit(async (fx) => {
|
||||
assert.equal(fx.finde(renderZusammenfassung(ab({ texte: { bestaetigung: 'x', fruehbeginn: null } })), '[data-hinweis]'), null)
|
||||
}))
|
||||
|
||||
test('Zusammenfassung: Überschriftenebene wählbar (Seite h2, Portalkarte h4); leere Eingabe wirft nicht', mit(async (fx) => {
|
||||
assert.deepEqual(fx.alle(renderZusammenfassung(ab()), 'h2').map((x) => x.textContent), ['Leistungsumfang', 'Zahlung', 'Termine'])
|
||||
const karte = renderZusammenfassung(ab(), { ebene: 4 })
|
||||
assert.equal(fx.alle(karte, 'h2').length, 0)
|
||||
assert.deepEqual(fx.alle(karte, 'h4').map((x) => x.textContent), ['Leistungsumfang', 'Zahlung', 'Termine'])
|
||||
assert.doesNotThrow(() => renderZusammenfassung({}))
|
||||
}))
|
||||
|
||||
test('Fremdtext bleibt Text: Titel, Positionen und Zahlungsplan werden nie als HTML gelesen', mit(async (fx) => {
|
||||
const boese = '<img src=x onerror=alert(1)> & <b>fett</b> "Test"'
|
||||
const lang = 'x'.repeat(200)
|
||||
const el = renderZusammenfassung(ab({
|
||||
titel: boese, zahlungsplanText: boese, summeCents: 200,
|
||||
positionen: [{ beschreibung: boese, menge: 1, einzelpreisCents: 100, summeCents: 100 }, { beschreibung: lang, menge: 1, einzelpreisCents: 100, summeCents: 100 }],
|
||||
}))
|
||||
assert.equal(fx.finde(el, 'img'), null)
|
||||
assert.equal(fx.finde(el, 'b'), null)
|
||||
assert.ok(fx.text(el).includes(boese))
|
||||
assert.ok(fx.text(el).includes(lang))
|
||||
}))
|
||||
|
||||
test('Bestätigt-Block: Zeitpunkt in Berliner Zeit und Zeile zum Beginn; ohne Bestätigung null', mit(async (fx) => {
|
||||
assert.equal(renderBestaetigtBlock(ab()), null)
|
||||
const block = renderBestaetigtBlock(bestaetigt(privat()))
|
||||
assert.equal(block.getAttribute('role'), 'status')
|
||||
assert.equal(block.getAttribute('tabindex'), '-1')
|
||||
assert.equal(block.hasAttribute('data-bestaetigt'), true)
|
||||
assert.deepEqual(fx.alle(block, 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen frühestens am 21.10.2026.'])
|
||||
}))
|
||||
|
||||
test('Unterlagen: PDF und AGB als Links in neuem Tab, leere Ziele entfallen', mit(async (fx) => {
|
||||
const links = fx.alle(renderDokumente({ pdfHref: '/api/auftrag/T/pdf', agbHref: '/rechtliches/agb-2026-09-30.pdf' }), 'a')
|
||||
assert.deepEqual(links.map((a) => [a.getAttribute('data-link'), a.getAttribute('href'), a.textContent]), [
|
||||
['pdf', '/api/auftrag/T/pdf', 'Auftragsbestätigung als PDF ansehen'],
|
||||
['agb', '/rechtliches/agb-2026-09-30.pdf', 'Allgemeine Geschäftsbedingungen (PDF)'],
|
||||
])
|
||||
for (const a of links) {
|
||||
assert.equal(a.getAttribute('target'), '_blank')
|
||||
assert.equal(a.getAttribute('rel'), 'noopener noreferrer')
|
||||
}
|
||||
assert.equal(fx.alle(renderDokumente({ agbHref: '/rechtliches/agb-2026-09-30.pdf' }), 'a').length, 1)
|
||||
assert.equal(renderDokumente({}), null)
|
||||
}))
|
||||
|
||||
/* ------------------------------------------------------------ Bestätigen-Formular --- */
|
||||
const OK = () => Promise.resolve({ ok: true, auftrag: bestaetigt(ab()) })
|
||||
|
||||
function monte(fx, a, { antwort = OK, gesperrt = '', anrede } = {}) {
|
||||
const aufrufe = []
|
||||
const erfolg = []
|
||||
const ende = []
|
||||
const form = renderBestaetigung(a, {
|
||||
senden: (koerper) => { aufrufe.push(koerper); return antwort(koerper) },
|
||||
nachErfolg: (x) => erfolg.push(x),
|
||||
nachEnde: (status, body) => ende.push([status, body]),
|
||||
gesperrt,
|
||||
anrede,
|
||||
})
|
||||
fx.document.body.append(form)
|
||||
return {
|
||||
form, aufrufe, erfolg, ende,
|
||||
knopf: fx.finde(form, '[data-aktion="bestaetigen"]'),
|
||||
haken1: fx.finde(form, '[data-feld="bestaetigung"]'),
|
||||
haken2: fx.finde(form, '[data-feld="fruehbeginn"]'),
|
||||
fehler: fx.finde(form, '[data-fehler]'),
|
||||
}
|
||||
}
|
||||
|
||||
test('Bestätigen (Unternehmen): ein Häkchen mit dem Text vom Server, Knopf erst nach dem Haken, Antwort geht an nachErfolg', mit(async (fx) => {
|
||||
const t = monte(fx, ab())
|
||||
assert.equal(t.haken2, null)
|
||||
assert.equal(fx.text(fx.finde(t.form, 'label')), 'Ich bestätige den Auftrag zu diesen Bedingungen.')
|
||||
assert.equal(t.knopf.textContent, 'Auftrag bestätigen')
|
||||
assert.equal(t.knopf.disabled, true)
|
||||
await fx.klick(t.haken1)
|
||||
assert.equal(t.knopf.disabled, false)
|
||||
await fx.klick(t.haken1)
|
||||
assert.equal(t.knopf.disabled, true)
|
||||
await fx.klick(t.haken1)
|
||||
await fx.klick(t.knopf)
|
||||
assert.deepEqual(t.aufrufe, [{ bestaetigt: true, fruehbeginn: false }])
|
||||
assert.equal(t.erfolg.length, 1)
|
||||
assert.equal(t.erfolg[0].status, 'bestaetigt')
|
||||
assert.equal(fx.text(fx.finde(t.form, '.ab-notiz')), 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.')
|
||||
}))
|
||||
|
||||
test('Bestätigen: fehlt der Text vom Server, gilt der feste Wortlaut aus der Übersicht (V7)', mit(async (fx) => {
|
||||
const t = monte(fx, ab({ texte: {} }))
|
||||
assert.equal(fx.text(fx.finde(t.form, 'label')), 'Ich bestätige den Auftrag zu diesen Bedingungen.')
|
||||
}))
|
||||
|
||||
test('Bestätigen (Privatperson): zweites, freiwilliges Häkchen; ohne Haken fruehbeginn false, mit Haken true', mit(async (fx) => {
|
||||
const a = privat()
|
||||
for (const [angehakt, soll] of [[false, false], [true, true]]) {
|
||||
const t = monte(fx, a)
|
||||
const zweites = fx.alle(t.form, 'label')[1]
|
||||
assert.equal(fx.text(zweites).startsWith(a.texte.fruehbeginn), true)
|
||||
assert.match(fx.text(zweites), /\(freiwillig\)$/)
|
||||
assert.equal(t.haken2.checked, false)
|
||||
await fx.klick(t.haken1)
|
||||
assert.equal(t.knopf.disabled, false, 'das zweite Häkchen ist nicht nötig')
|
||||
if (angehakt) await fx.klick(t.haken2)
|
||||
await fx.klick(t.knopf)
|
||||
assert.deepEqual(t.aufrufe, [{ bestaetigt: true, fruehbeginn: soll }])
|
||||
t.form.remove()
|
||||
}
|
||||
}))
|
||||
|
||||
test('Bestätigen: Privatperson ohne Frühbeginn-Text vom Server bekommt kein zweites Häkchen und sendet false', mit(async (fx) => {
|
||||
const t = monte(fx, privat({ texte: { bestaetigung: 'Ich bestätige den Auftrag zu diesen Bedingungen.', fruehbeginn: null } }))
|
||||
assert.equal(t.haken2, null)
|
||||
await fx.klick(t.haken1)
|
||||
await fx.klick(t.knopf)
|
||||
assert.deepEqual(t.aufrufe, [{ bestaetigt: true, fruehbeginn: false }])
|
||||
}))
|
||||
|
||||
test('Doppelter Absenden-Versuch sendet genau einmal; Knopf zeigt „Wird gesendet …“ und bleibt nach Erfolg gesperrt', mit(async (fx) => {
|
||||
let fertig
|
||||
const t = monte(fx, ab(), { antwort: () => new Promise((resolve) => { fertig = resolve }) })
|
||||
await fx.klick(t.haken1)
|
||||
const erster = fx.klick(t.knopf)
|
||||
assert.equal(t.knopf.textContent, 'Wird gesendet …')
|
||||
assert.equal(t.knopf.disabled, true)
|
||||
assert.equal(t.knopf.getAttribute('aria-busy'), 'true')
|
||||
await fx.sende(t.form, 'submit') // zweiter Versuch (z. B. Enter), während der erste läuft
|
||||
assert.equal(t.aufrufe.length, 1)
|
||||
fertig({ ok: true, auftrag: bestaetigt(ab()) })
|
||||
await erster
|
||||
assert.equal(t.erfolg.length, 1)
|
||||
assert.equal(t.knopf.disabled, true)
|
||||
await fx.sende(t.form, 'submit')
|
||||
assert.equal(t.aufrufe.length, 1, 'auch nach dem Erfolg geht nichts mehr raus')
|
||||
}))
|
||||
|
||||
test('Netzfehler: Meldung, Häkchen bleiben stehen, erneutes Senden möglich', mit(async (fx) => {
|
||||
const antworten = [{ ok: false, status: 0, body: {} }, { ok: true, auftrag: bestaetigt(privat()) }]
|
||||
const t = monte(fx, privat(), { antwort: async () => antworten.shift() })
|
||||
await fx.klick(t.haken1)
|
||||
await fx.klick(t.haken2)
|
||||
await fx.klick(t.knopf)
|
||||
assert.equal(t.fehler.hidden, false)
|
||||
assert.equal(t.fehler.textContent, 'Keine Verbindung. Bitte prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.')
|
||||
assert.equal(t.haken1.checked, true)
|
||||
assert.equal(t.haken2.checked, true)
|
||||
assert.equal(t.knopf.disabled, false)
|
||||
assert.equal(t.knopf.textContent, 'Auftrag bestätigen')
|
||||
assert.equal(t.erfolg.length, 0)
|
||||
await fx.klick(t.knopf)
|
||||
assert.deepEqual(t.aufrufe, [{ bestaetigt: true, fruehbeginn: true }, { bestaetigt: true, fruehbeginn: true }])
|
||||
assert.equal(t.erfolg.length, 1)
|
||||
}))
|
||||
|
||||
test('Fehlertexte nach Status; 404 und 410 gehen an nachEnde statt in die Fehlerzeile', mit(async (fx) => {
|
||||
for (const [status, text] of [
|
||||
[429, 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.'],
|
||||
[500, 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuchen Sie es später noch einmal.'],
|
||||
[400, 'Bitte setzen Sie das Häkchen, um den Auftrag zu bestätigen.'],
|
||||
]) {
|
||||
const t = monte(fx, ab(), { antwort: async () => ({ ok: false, status, body: { error: 'Servertext, der nie erscheint' } }) })
|
||||
await fx.klick(t.haken1)
|
||||
await fx.klick(t.knopf)
|
||||
assert.equal(t.fehler.textContent, text, String(status))
|
||||
t.form.remove()
|
||||
}
|
||||
const body = { error: 'Diese Auftragsbestätigung wurde ersetzt.' }
|
||||
const t = monte(fx, ab(), { antwort: async () => ({ ok: false, status: 410, body }) })
|
||||
await fx.klick(t.haken1)
|
||||
await fx.klick(t.knopf)
|
||||
assert.deepEqual(t.ende, [[410, body]])
|
||||
assert.equal(t.fehler.hidden, true)
|
||||
}))
|
||||
|
||||
test('Gesperrt (Ansichtsmodus): Häkchen und Knopf gesperrt, Hinweis sichtbar, kein Senden', mit(async (fx) => {
|
||||
const hinweis = 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.'
|
||||
const t = monte(fx, privat(), { gesperrt: hinweis })
|
||||
assert.equal(fx.alle(t.form, 'input').every((i) => i.disabled), true)
|
||||
assert.equal(t.knopf.disabled, true)
|
||||
assert.equal(fx.finde(t.form, '[data-gesperrt]').textContent, hinweis)
|
||||
await fx.sende(t.form, 'submit')
|
||||
assert.equal(t.aufrufe.length, 0)
|
||||
}))
|
||||
|
||||
test('Anrede: im Portal (Du) stehen Überschrift, Hinweis, Fehlertext und die Zeile zum Wunsch in der Du-Form, auf der öffentlichen Seite (Sie) in der Sie-Form', mit(async (fx) => {
|
||||
const sie = monte(fx, ab())
|
||||
assert.equal(fx.text(fx.finde(sie.form, 'legend')), 'Ihre Bestätigung')
|
||||
assert.equal(fx.text(fx.finde(sie.form, '.ab-notiz')), 'Ihre Bestätigung wird mit Datum und Uhrzeit gespeichert.')
|
||||
sie.form.remove()
|
||||
const du = monte(fx, ab(), { anrede: ANREDE_DU, antwort: async () => ({ ok: false, status: 0, body: {} }) })
|
||||
assert.equal(fx.text(fx.finde(du.form, 'legend')), 'Deine Bestätigung')
|
||||
assert.equal(fx.text(fx.finde(du.form, '.ab-notiz')), 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.')
|
||||
await fx.klick(du.haken1)
|
||||
await fx.klick(du.knopf)
|
||||
assert.equal(du.fehler.textContent, 'Keine Verbindung. Bitte prüfe deine Internetverbindung und versuche es erneut.')
|
||||
const wunsch = bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null }))
|
||||
assert.deepEqual(fx.alle(renderBestaetigtBlock(wunsch), 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.'])
|
||||
assert.deepEqual(fx.alle(renderBestaetigtBlock(wunsch, { anrede: ANREDE_DU }), 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.'])
|
||||
}))
|
||||
|
||||
test('Quelltext: nur Text, nur ./auftrag-ansicht.js, kein fetch, keine Kundentexte im Modul', () => {
|
||||
pruefeQuelltext('auftrag-dom.js', { importe: ['./auftrag-ansicht.js'] })
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
|
||||
const html = lies('public/auftrag.html')
|
||||
const css = lies('public/auftrag.css')
|
||||
|
||||
test('auftrag.html: noindex, kein Referrer, Viewport mit Notch, Titel, ein Modul-Skript, kein Inline-Skript', () => {
|
||||
assert.match(html, /<meta name="robots" content="noindex, nofollow">/)
|
||||
assert.match(html, /<meta name="referrer" content="no-referrer">/)
|
||||
assert.match(html, /<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">/)
|
||||
assert.match(html, /<meta name="color-scheme" content="light dark">/)
|
||||
assert.match(html, /<title>Auftragsbestätigung – WEBklar<\/title>/)
|
||||
assert.match(html, /<link rel="stylesheet" href="\/auftrag\.css">/)
|
||||
assert.deepEqual(html.match(/<script\b[^>]*>/g), ['<script type="module" src="/auftrag.js">'])
|
||||
assert.doesNotMatch(html, /<script>/)
|
||||
assert.doesNotMatch(html, /\son[a-z]+=/i, 'keine Ereignis-Attribute')
|
||||
assert.doesNotMatch(html, /https?:\/\/(?!webklar\.com\/impressum)/, 'keine Fremdressourcen')
|
||||
})
|
||||
|
||||
test('auftrag.html: Wurzel im Zustand „lädt“, Live-Region, Hinweis ohne JavaScript', () => {
|
||||
assert.match(html, /<div class="ab-wurzel" id="ab-wurzel" data-zustand="laedt" aria-busy="true">/)
|
||||
assert.match(html, /<p class="ab-laden">Ihre Auftragsbestätigung wird geladen …<\/p>/)
|
||||
assert.match(html, /<div class="ab-vh" id="ab-ansage" aria-live="polite"><\/div>/)
|
||||
assert.match(html, /<noscript><p>Bitte aktivieren Sie JavaScript, um Ihren Auftrag zu bestätigen\.<\/p><\/noscript>/)
|
||||
assert.match(html, /<noscript><style>\.ab-laden\{display:none\}<\/style><\/noscript>/)
|
||||
})
|
||||
|
||||
test('auftrag.html: Fußzeile mit Datenschutz und Impressum; Zurück-Link nur für die installierte App', () => {
|
||||
assert.match(html, /<footer class="ab-fuss">[\s\S]*<a href="\/datenschutz\.html" target="_blank" rel="noopener noreferrer">Datenschutz<\/a>[\s\S]*<a href="https:\/\/webklar\.com\/impressum" target="_blank" rel="noopener noreferrer">Impressum<\/a>[\s\S]*<\/footer>/)
|
||||
assert.match(html, /<a class="ab-app-back" href="\/dashboard\.html">‹ Zurück zum Kundenbereich<\/a>/)
|
||||
})
|
||||
|
||||
test('auftrag.css: Farben der Mailvorlagen hell und dunkel, Standalone-Modus, Handy-Tabelle, Umbruch, Tippflächen', () => {
|
||||
for (const farbe of ['#f1f0ec', '#efe6fb', '#5a2a91', '#e4f2e6', '#1d5b2c', '#161615', '#1f1f1e', '#f2f1ee', '#a8a6a0']) assert.ok(css.includes(farbe), farbe)
|
||||
assert.match(css, /@media \(prefers-color-scheme: dark\)/)
|
||||
assert.match(css, /@media \(display-mode: standalone\)/)
|
||||
assert.match(css, /env\(safe-area-inset-top\)/)
|
||||
assert.match(css, /overflow-wrap:\s*anywhere/)
|
||||
assert.match(css, /min-height:\s*48px/)
|
||||
assert.match(css, /content:\s*attr\(data-label\)/)
|
||||
assert.match(css, /\.ab-app-back\s*\{[^}]*display:\s*none/)
|
||||
assert.match(css, /\.ab-vh\s*\{/)
|
||||
assert.match(css, /body\.ab-seite[^{]*\.hidden|\.ab-seite \.hidden/)
|
||||
})
|
||||
|
||||
test('Service Worker und Manifest brauchen für die neue Seite keinen Eintrag', () => {
|
||||
assert.doesNotMatch(lies('public/sw.js'), /auftrag/)
|
||||
const manifest = JSON.parse(lies('public/manifest.webmanifest'))
|
||||
assert.equal(manifest.scope, '/', 'der Link aus der Mail kann in der installierten App öffnen: darum der Standalone-Modus oben')
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { installFakeDom } from './support/fakeDom.js'
|
||||
import { h } from '../public/auftrag-dom.js'
|
||||
import { initAuftraege } from '../public/auftrag-portal.js'
|
||||
import { ab, privat, bestaetigt, eintrag, pruefeQuelltext } from './auftraegeKundenportalDaten.js'
|
||||
|
||||
const NB = '\u{a0}'
|
||||
const mit = (fn) => async () => {
|
||||
const fx = installFakeDom()
|
||||
try {
|
||||
await fn(fx)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
}
|
||||
function aufbau(fx) {
|
||||
const body = h('div', { id: 'orders-body' })
|
||||
const section = h('section', { id: 'orders-section', class: 'ab-portal hidden' }, body)
|
||||
const invoicesTitle = h('h2', { id: 'invoices-title', class: 'hidden' }, 'Rechnungen')
|
||||
fx.document.body.append(section, invoicesTitle)
|
||||
return { section, body, invoicesTitle }
|
||||
}
|
||||
/** request wie api() in app.js: gibt die nächste Antwort zurück oder wirft sie (Error mit status). */
|
||||
function fakeRequest(antworten) {
|
||||
const aufrufe = []
|
||||
const request = async (pfad, optionen = {}) => {
|
||||
aufrufe.push({ pfad, methode: optionen.method || 'GET', body: optionen.body ? JSON.parse(optionen.body) : undefined })
|
||||
const antwort = antworten.shift()
|
||||
if (antwort instanceof Error) throw antwort
|
||||
return antwort
|
||||
}
|
||||
return { request, aufrufe }
|
||||
}
|
||||
const fehler = (status, text = `Fehler ${status}`) => Object.assign(new Error(text), { status })
|
||||
const sichtbar = (el) => !el.classList.contains('hidden')
|
||||
const liste = (...auftraege) => ({ aktiv: true, auftraege })
|
||||
|
||||
const A_OFFEN = eintrag({ id: 'ab1', nummer: 'AB-2026-0002' })
|
||||
const A_OHNE_RECHT = eintrag({ id: 'ab4', nummer: 'AB-2026-0004', kannBestaetigen: false }) // Status offen, der Server erlaubt das Bestätigen nicht
|
||||
const A_FERTIG = eintrag({ id: 'ab2', ...bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null })), nummer: 'AB-2026-0003', kannBestaetigen: false })
|
||||
const A_STORNO = eintrag({
|
||||
id: 'ab0', nummer: 'AB-2026-0001', status: 'storniert', kannBestaetigen: false, art: 'aufwand', positionen: [], summeCents: null,
|
||||
aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.',
|
||||
})
|
||||
const bestaetige = async (fx, wurzel) => {
|
||||
await fx.klick(fx.finde(wurzel, '[data-feld="bestaetigung"]'))
|
||||
await fx.klick(fx.finde(wurzel, '[data-aktion="bestaetigen"]'))
|
||||
}
|
||||
|
||||
test('Der Abschnitt bleibt weg: Schalter aus, keine Aufträge, Ladefehler', mit(async (fx) => {
|
||||
for (const antwort of [{ aktiv: false, auftraege: [] }, { aktiv: false, auftraege: [A_OFFEN] }, liste(), fehler(500), fehler(401)]) {
|
||||
const els = aufbau(fx)
|
||||
const { request, aufrufe } = fakeRequest([antwort])
|
||||
assert.deepEqual(await initAuftraege({ ...els, request, viewAs: false }), { sichtbar: false })
|
||||
assert.equal(sichtbar(els.section), false)
|
||||
assert.equal(sichtbar(els.invoicesTitle), false)
|
||||
assert.deepEqual(aufrufe, [{ pfad: '/api/auftraege', methode: 'GET', body: undefined }])
|
||||
els.section.remove()
|
||||
els.invoicesTitle.remove()
|
||||
}
|
||||
}))
|
||||
|
||||
test('Liste: Bestätigbares zuerst, Status, Zeile, PDF-Link; nur der offene Auftrag ist aufgeklappt und hat das Formular', mit(async (fx) => {
|
||||
const els = aufbau(fx)
|
||||
const { request } = fakeRequest([liste(A_STORNO, A_FERTIG, A_OHNE_RECHT, A_OFFEN)])
|
||||
assert.deepEqual(await initAuftraege({ ...els, request, viewAs: false }), { sichtbar: true })
|
||||
assert.equal(sichtbar(els.section), true)
|
||||
assert.equal(sichtbar(els.invoicesTitle), true)
|
||||
const karten = fx.alle(els.body, '.ab-karte')
|
||||
assert.deepEqual(karten.map((k) => k.getAttribute('data-auftrag')), ['ab1', 'ab4', 'ab2', 'ab0'])
|
||||
const [offen, ohneRecht, fertig, storno] = karten
|
||||
assert.equal(fx.finde(offen, '.ab-pille').textContent, 'Bitte bestätigen')
|
||||
assert.equal(fx.finde(ohneRecht, '.ab-pille').textContent, 'Versendet')
|
||||
assert.equal(fx.finde(ohneRecht, 'form'), null, 'ohne kannBestaetigen vom Server gibt es kein Formular')
|
||||
assert.equal(fx.finde(fertig, '.ab-pille').textContent, 'Bestätigt')
|
||||
assert.equal(fx.finde(storno, '.ab-pille').textContent, 'Storniert')
|
||||
assert.equal(fx.finde(offen, '.ab-karte-titel').textContent, 'Website Anelia Wolf')
|
||||
assert.equal(fx.finde(offen, '.ab-karte-meta').textContent, `vom 06.10.2026 · 800,00${NB}€`)
|
||||
assert.equal(fx.finde(storno, '.ab-karte-meta').textContent, 'vom 06.10.2026 · nach Aufwand')
|
||||
assert.equal(fx.finde(offen, '[data-link="pdf"]').getAttribute('href'), '/api/auftraege/ab1/pdf')
|
||||
assert.equal(fx.finde(offen, '[data-link="pdf"]').textContent, 'PDF ansehen')
|
||||
assert.equal(fx.finde(offen, 'summary').textContent, 'Details ansehen und bestätigen')
|
||||
assert.equal(fx.finde(fertig, 'summary').textContent, 'Details ansehen')
|
||||
assert.equal(fx.finde(offen, 'details').hasAttribute('open'), true)
|
||||
assert.equal(fx.finde(fertig, 'details').hasAttribute('open'), false)
|
||||
assert.ok(fx.finde(offen, 'form [data-feld="bestaetigung"]'))
|
||||
assert.equal(fx.finde(fertig, 'form'), null)
|
||||
assert.equal(fx.finde(storno, 'form'), null)
|
||||
assert.deepEqual(fx.alle(fx.finde(fertig, '[data-bestaetigt]'), 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf deinen Wunsch schon vor Ablauf der Widerrufsfrist.'])
|
||||
assert.equal(fx.text(fx.finde(offen, 'legend')), 'Deine Bestätigung', 'Portal: Du-Form')
|
||||
assert.equal(fx.text(fx.finde(offen, '.ab-notiz')), 'Deine Bestätigung wird mit Datum und Uhrzeit gespeichert.')
|
||||
assert.deepEqual(fx.alle(offen, 'h4').map((x) => x.textContent), ['Leistungsumfang', 'Zahlung', 'Termine'])
|
||||
assert.equal(fx.finde(offen, '[data-link="agb"]').getAttribute('href'), '/rechtliches/agb-2026-09-30.pdf')
|
||||
}))
|
||||
|
||||
test('Bestätigen: POST an /api/auftraege/<id>/bestaetigen, die Karte zeigt danach „Bestätigt“ (auch ohne id in der Antwort)', mit(async (fx) => {
|
||||
const els = aufbau(fx)
|
||||
const { request, aufrufe } = fakeRequest([liste(A_OFFEN), { auftrag: bestaetigt(ab()) }])
|
||||
await initAuftraege({ ...els, request, viewAs: false })
|
||||
await bestaetige(fx, els.body)
|
||||
assert.deepEqual(aufrufe[1], { pfad: '/api/auftraege/ab1/bestaetigen', methode: 'POST', body: { bestaetigt: true, fruehbeginn: false } })
|
||||
const karte = fx.finde(els.body, '.ab-karte')
|
||||
assert.equal(karte.getAttribute('data-auftrag'), 'ab1')
|
||||
assert.equal(fx.finde(karte, '.ab-pille').textContent, 'Bestätigt')
|
||||
assert.equal(fx.finde(karte, 'form'), null)
|
||||
assert.equal(fx.finde(karte, 'details').hasAttribute('open'), true)
|
||||
assert.equal(fx.document.activeElement, fx.finde(karte, '[data-bestaetigt]'))
|
||||
}))
|
||||
|
||||
test('Ansichtsmodus: Häkchen und Knopf gesperrt, Hinweis sichtbar, kein POST; der Abschnitt bleibt lesbar', mit(async (fx) => {
|
||||
const els = aufbau(fx)
|
||||
// wie L5 es liefert: in der Kundenansicht ist kannBestaetigen immer false; die Oberfläche zeigt die offene AB trotzdem mit gesperrtem Formular
|
||||
const { request, aufrufe } = fakeRequest([liste(eintrag({ ...privat(), id: 'ab1', kannBestaetigen: false }))])
|
||||
await initAuftraege({ ...els, request, viewAs: true })
|
||||
assert.equal(fx.finde(els.body, '.ab-pille').textContent, 'Bitte bestätigen')
|
||||
const form = fx.finde(els.body, 'form')
|
||||
assert.equal(fx.alle(form, 'input').every((i) => i.disabled), true)
|
||||
assert.equal(fx.finde(form, '[data-aktion="bestaetigen"]').disabled, true)
|
||||
assert.equal(fx.finde(form, '[data-gesperrt]').textContent, 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.')
|
||||
await fx.sende(form, 'submit')
|
||||
assert.equal(aufrufe.length, 1, 'nur das Laden der Liste')
|
||||
assert.ok(fx.finde(els.body, '[data-link="pdf"]'))
|
||||
assert.ok(fx.finde(els.body, '[data-position]'))
|
||||
}))
|
||||
|
||||
test('Fehler beim Bestätigen: feste Meldung statt Servertext; ein 410 lädt die Liste neu', mit(async (fx) => {
|
||||
const els = aufbau(fx)
|
||||
const storniert = { ...A_OFFEN, status: 'storniert', kannBestaetigen: false }
|
||||
const { request, aufrufe } = fakeRequest([liste(A_OFFEN), fehler(500), fehler(410, 'Diese Auftragsbestätigung wurde storniert.'), liste(storniert)])
|
||||
await initAuftraege({ ...els, request, viewAs: false })
|
||||
await bestaetige(fx, els.body)
|
||||
assert.equal(fx.finde(els.body, '[data-fehler]').textContent, 'Die Bestätigung konnte nicht gespeichert werden. Bitte versuche es später noch einmal.')
|
||||
assert.ok(!fx.text(els.body).includes('Fehler 500'))
|
||||
await fx.klick(fx.finde(els.body, '[data-aktion="bestaetigen"]'))
|
||||
assert.equal(aufrufe.length, 4, 'Liste, POST, POST, Liste neu')
|
||||
assert.equal(fx.finde(els.body, '.ab-pille').textContent, 'Storniert')
|
||||
assert.equal(fx.finde(els.body, 'form'), null)
|
||||
}))
|
||||
|
||||
test('Fremdtext bleibt Text; die ID im PDF-Link wird kodiert', mit(async (fx) => {
|
||||
const els = aufbau(fx)
|
||||
const boese = '<script>alert(1)</script> & "Test"'
|
||||
const { request } = fakeRequest([liste(eintrag({ id: 'a/b', titel: boese }))])
|
||||
await initAuftraege({ ...els, request, viewAs: false })
|
||||
assert.equal(fx.alle(els.body, 'script').length, 0)
|
||||
assert.equal(fx.finde(els.body, '.ab-karte-titel').textContent, boese)
|
||||
assert.equal(fx.finde(els.body, '[data-link="pdf"]').getAttribute('href'), '/api/auftraege/a%2Fb/pdf')
|
||||
}))
|
||||
|
||||
test('Quelltext und CSS: kein eigenes fetch, Karten und Abschnitt sind gestaltet', () => {
|
||||
pruefeQuelltext('auftrag-portal.js', { importe: ['./auftrag-ansicht.js', './auftrag-dom.js'] })
|
||||
const css = readFileSync(new URL('../public/auftrag.css', import.meta.url), 'utf8')
|
||||
for (const klasse of ['ab-portal', 'ab-karte', 'ab-karte-kopf', 'ab-karte-titel', 'ab-karte-meta', 'ab-karte-aktionen', 'ab-details']) {
|
||||
assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[>]`), `.${klasse} ist gestaltet`)
|
||||
}
|
||||
assert.match(css, /\.ab-portal\s*\{[^}]*--ab-/, 'der Portal-Abschnitt setzt seine eigenen Farbschlüssel (dunkles Portal)')
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
|
||||
// Reiner Test von customerStripeInvoice: config.js liest die Umgebung beim ersten Import, die Adressen werden nie angefragt.
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:9', stripeUrl: 'http://127.0.0.1:9' })
|
||||
const { customerStripeInvoice } = await import('../server/services/customerInvoices.js')
|
||||
|
||||
const roh = (over = {}) => ({
|
||||
id: 'in_1', number: 'WEBKLAR-0009', status: 'open', effective_at: 1_791_000_000, due_date: 1_792_000_000,
|
||||
total: 50000, amount_remaining: 50000, hosted_invoice_url: 'https://invoice.stripe.com/i/in_1',
|
||||
invoice_pdf: 'https://pay.stripe.com/invoice/in_1/pdf', metadata: {}, ...over,
|
||||
})
|
||||
|
||||
test('Rechnung der Kunden-Liste: projekt kommt aus metadata.projektName, sonst leer', () => {
|
||||
assert.equal(customerStripeInvoice(roh({ metadata: { projektName: 'Website Anelia Wolf' } })).projekt, 'Website Anelia Wolf')
|
||||
assert.equal(customerStripeInvoice(roh()).projekt, '')
|
||||
assert.equal(customerStripeInvoice(roh({ metadata: { webklarCustomerId: 'c1', art: 'abo' } })).projekt, '')
|
||||
const ohneMetadaten = roh()
|
||||
delete ohneMetadaten.metadata
|
||||
assert.equal(customerStripeInvoice(ohneMetadaten).projekt, '')
|
||||
})
|
||||
|
||||
test('Rechnung der Kunden-Liste: die bisherigen Felder bleiben, projekt kommt dazu', () => {
|
||||
assert.deepEqual(Object.keys(customerStripeInvoice(roh())).sort(), [
|
||||
'amount', 'balance', 'cancelled', 'date', 'dueDate', 'hasPdf', 'id', 'number', 'overdue', 'paid', 'portalUrl', 'projekt', 'source', 'status',
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { installFakeDom } from './support/fakeDom.js'
|
||||
import { h } from '../public/auftrag-dom.js'
|
||||
import { initAuftragsseite } from '../public/auftrag.js'
|
||||
import { TOKEN, KLEINUNTERNEHMER, ab, privat, bestaetigt, pruefeQuelltext } from './auftraegeKundenportalDaten.js'
|
||||
|
||||
const ok = (body, status = 200) => ({ status, body })
|
||||
const mit = (antworten, fn) => async () => {
|
||||
const fx = installFakeDom({ antworten })
|
||||
const root = h('div', { id: 'ab-wurzel' })
|
||||
const ansage = h('div', { id: 'ab-ansage' })
|
||||
fx.document.body.append(root, ansage)
|
||||
try {
|
||||
await fn(fx, root, ansage)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
}
|
||||
const start = (root, ansage, adresse = {}) => initAuftragsseite({ root, ansage, suche: '', hash: `#t=${TOKEN}`, ...adresse }) // K31 verlinkt #t=
|
||||
const bestaetige = async (fx, root, { frueh = false } = {}) => {
|
||||
await fx.klick(fx.finde(root, '[data-feld="bestaetigung"]'))
|
||||
if (frueh) await fx.klick(fx.finde(root, '[data-feld="fruehbeginn"]'))
|
||||
await fx.klick(fx.finde(root, '[data-aktion="bestaetigen"]'))
|
||||
}
|
||||
|
||||
test('Ohne gültigen Token: „ungültig“ und Kontaktweg, ohne Anfrage', mit([], async (fx, root, ansage) => {
|
||||
for (const adresse of [{ hash: '' }, { hash: '#t=' }, { hash: '#t=zu-kurz' }, { hash: `#t=${TOKEN}x` }, { hash: '', suche: '?t=zu-kurz' }]) {
|
||||
await start(root, ansage, adresse)
|
||||
assert.equal(root.dataset.zustand, 'ungueltig', JSON.stringify(adresse))
|
||||
assert.equal(fx.finde(root, 'h1').textContent, 'Dieser Link ist ungültig oder abgelaufen.')
|
||||
assert.equal(fx.finde(root, 'a[href="mailto:support@webklar.com"]').textContent, 'support@webklar.com')
|
||||
assert.equal(fx.finde(root, 'a[href="tel:017623726355"]').textContent, '0176 23726355')
|
||||
}
|
||||
assert.equal(fx.fetchLog.length, 0)
|
||||
}))
|
||||
|
||||
test('Laden und Anzeigen: Kopf, Zusammenfassung, Unterlagen, Formular; der Token steht nur in Links', mit([ok({ auftrag: ab() })], async (fx, root, ansage) => {
|
||||
await start(root, ansage)
|
||||
assert.deepEqual(fx.fetchLog, [{ method: 'GET', url: `/api/auftrag/${TOKEN}`, body: undefined }])
|
||||
assert.equal(root.dataset.zustand, 'offen')
|
||||
assert.equal(fx.finde(root, 'h1').textContent, 'Auftragsbestätigung AB-2026-0002')
|
||||
assert.equal(fx.finde(root, '.ab-pille').textContent, 'Bitte bestätigen')
|
||||
assert.equal(fx.alle(root, '[data-position]').length, 2)
|
||||
const pdf = fx.finde(root, '[data-link="pdf"]')
|
||||
const agb = fx.finde(root, '[data-link="agb"]')
|
||||
assert.equal(pdf.getAttribute('href'), `/api/auftrag/${TOKEN}/pdf`)
|
||||
assert.equal(agb.getAttribute('href'), '/rechtliches/agb-2026-09-30.pdf')
|
||||
assert.ok(fx.finde(root, 'form [data-feld="bestaetigung"]'))
|
||||
assert.equal(fx.finde(root, '[data-feld="fruehbeginn"]'), null)
|
||||
assert.equal(fx.finde(root, '[data-hinweis]').textContent, KLEINUNTERNEHMER, 'der § 19-Satz kommt aus der API')
|
||||
assert.equal(fx.finde(root, 'legend').textContent, 'Ihre Bestätigung', 'öffentliche Seite: Sie-Form')
|
||||
assert.equal(fx.text(ansage), 'Ihre Auftragsbestätigung wurde geladen.')
|
||||
assert.ok(!fx.text(root).includes(TOKEN) && !fx.text(ansage).includes(TOKEN))
|
||||
}))
|
||||
|
||||
test('Der Token steht im Fragment (#t=, so verlinkt K31); als Rückfall genügt ?t=', mit([ok({ auftrag: ab() }), ok({ auftrag: ab() })], async (fx, root, ansage) => {
|
||||
await start(root, ansage)
|
||||
assert.equal(root.dataset.zustand, 'offen')
|
||||
await start(root, ansage, { hash: '', suche: `?t=${TOKEN}` })
|
||||
assert.equal(root.dataset.zustand, 'offen')
|
||||
assert.deepEqual(fx.fetchLog.map((a) => a.url), [`/api/auftrag/${TOKEN}`, `/api/auftrag/${TOKEN}`])
|
||||
}))
|
||||
|
||||
test('Aufwand: Aufwandstext statt Tabelle', mit([ok({ auftrag: ab({ art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde.' }) })], async (fx, root, ansage) => {
|
||||
await start(root, ansage)
|
||||
assert.equal(fx.finde(root, '[data-aufwand]').textContent, '100,00 € pro Stunde.')
|
||||
assert.equal(fx.finde(root, 'table'), null)
|
||||
}))
|
||||
|
||||
test('Bestätigen (Unternehmen): POST mit Häkchen, danach „bestätigt“ mit Zeitpunkt, Fokus und Ansage', mit([ok({ auftrag: ab() }), ok({ auftrag: bestaetigt(ab()) })], async (fx, root, ansage) => {
|
||||
await start(root, ansage)
|
||||
await bestaetige(fx, root)
|
||||
assert.deepEqual(fx.fetchLog[1], { method: 'POST', url: `/api/auftrag/${TOKEN}/bestaetigen`, body: { bestaetigt: true, fruehbeginn: false } })
|
||||
assert.equal(root.dataset.zustand, 'bestaetigt')
|
||||
assert.equal(fx.finde(root, 'h1').textContent, 'Ihr Auftrag ist bestätigt.')
|
||||
assert.equal(fx.finde(root, '.ab-pille').textContent, 'Bestätigt')
|
||||
const block = fx.finde(root, '[data-bestaetigt]')
|
||||
assert.deepEqual(fx.alle(block, 'p').map((p) => p.textContent), ['Bestätigt am 07.10.2026, 10:15 Uhr'])
|
||||
assert.equal(fx.finde(root, 'form'), null)
|
||||
assert.equal(fx.document.activeElement, block)
|
||||
assert.equal(fx.text(ansage), 'Ihr Auftrag ist bestätigt.')
|
||||
assert.ok(fx.finde(root, '[data-link="pdf"]'), 'Unterlagen bleiben erreichbar')
|
||||
}))
|
||||
|
||||
test('Privatperson: zweites Häkchen; angezeigt wird, was der Server meldet (Frühbeginn „nein“ trotz Häkchen)', mit([ok({ auftrag: privat() }), ok({ auftrag: bestaetigt(privat({ fruehbeginn: false })) })], async (fx, root, ansage) => {
|
||||
await start(root, ansage)
|
||||
assert.ok(fx.finde(root, '[data-feld="fruehbeginn"]'))
|
||||
await bestaetige(fx, root, { frueh: true })
|
||||
assert.deepEqual(fx.fetchLog[1].body, { bestaetigt: true, fruehbeginn: true })
|
||||
assert.deepEqual(fx.alle(fx.finde(root, '[data-bestaetigt]'), 'p').map((p) => p.textContent), [
|
||||
'Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen frühestens am 21.10.2026.',
|
||||
])
|
||||
}))
|
||||
|
||||
test('Schon bestätigt beim Laden: „bestätigt“ ohne Formular, mit der Zeile zum Beginn', mit([ok({ auftrag: bestaetigt(privat({ fruehbeginn: true, fruehesterBeginn: null })) })], async (fx, root, ansage) => {
|
||||
await start(root, ansage)
|
||||
assert.equal(root.dataset.zustand, 'bestaetigt')
|
||||
assert.equal(fx.finde(root, 'form'), null)
|
||||
assert.deepEqual(fx.alle(fx.finde(root, '[data-bestaetigt]'), 'p').map((p) => p.textContent), [
|
||||
'Bestätigt am 07.10.2026, 10:15 Uhr', 'Wir beginnen auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist.',
|
||||
])
|
||||
}))
|
||||
|
||||
test('Zustände: ungültig, ersetzt, storniert, widerrufen, zu viele Anfragen, Serverfehler – feste Texte, nie der Token', mit([], async (fx, root, ansage) => {
|
||||
const faelle = [
|
||||
[ok({ error: 'Dieser Link ist ungültig oder abgelaufen.' }, 404), 'ungueltig', 'Dieser Link ist ungültig oder abgelaufen.'],
|
||||
[ok({ error: 'Diese Auftragsbestätigung wurde ersetzt.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde ersetzt.'],
|
||||
[ok({ error: 'Diese Auftragsbestätigung wurde storniert.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde storniert.'],
|
||||
[ok({ error: 'Diese Auftragsbestätigung wurde widerrufen.' }, 410), 'beendet', 'Diese Auftragsbestätigung wurde widerrufen.'],
|
||||
[ok({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }, 429), 'limit', 'Zu viele Anfragen'],
|
||||
[ok({ error: 'Interner Fehler.' }, 500), 'fehler', 'Das hat leider nicht geklappt'],
|
||||
[ok({}, 502), 'fehler', 'Das hat leider nicht geklappt'],
|
||||
[ok({}, 200), 'fehler', 'Das hat leider nicht geklappt'], // Antwort ohne auftrag
|
||||
]
|
||||
for (const [antwort, zustand, titel] of faelle) {
|
||||
fx.antworten.push(antwort)
|
||||
await start(root, ansage)
|
||||
assert.equal(root.dataset.zustand, zustand, titel)
|
||||
assert.equal(fx.finde(root, 'h1').textContent, titel)
|
||||
assert.equal(fx.text(ansage), titel)
|
||||
assert.equal(fx.finde(root, 'form'), null)
|
||||
assert.ok(!fx.text(root).includes(TOKEN) && !fx.text(ansage).includes(TOKEN))
|
||||
assert.equal(Boolean(fx.finde(root, '[data-aktion="erneut"]')), ['limit', 'fehler'].includes(zustand), `Erneut-Knopf bei ${zustand}`)
|
||||
}
|
||||
assert.equal(fx.finde(root, 'a[href="mailto:support@webklar.com"]') !== null, true, 'auch beim Fehler steht der Kontaktweg da')
|
||||
}))
|
||||
|
||||
test('Netzfehler beim Laden: Fehlerzustand ohne Fremdtext, „Erneut versuchen“ lädt neu', mit([() => { throw new TypeError('fetch failed') }, ok({ auftrag: ab() })], async (fx, root, ansage) => {
|
||||
await start(root, ansage)
|
||||
assert.equal(root.dataset.zustand, 'fehler')
|
||||
assert.ok(!fx.text(root).includes('fetch failed'))
|
||||
await fx.klick(fx.finde(root, '[data-aktion="erneut"]'))
|
||||
assert.equal(root.dataset.zustand, 'offen')
|
||||
assert.equal(fx.fetchLog.length, 2)
|
||||
}))
|
||||
|
||||
test('Status wechselt zwischen Laden und Bestätigen (410): „storniert“, kein Formular mehr', mit([ok({ auftrag: ab() }), ok({ error: 'Diese Auftragsbestätigung wurde storniert.' }, 410)], async (fx, root, ansage) => {
|
||||
await start(root, ansage)
|
||||
await bestaetige(fx, root)
|
||||
assert.equal(root.dataset.zustand, 'beendet')
|
||||
assert.equal(fx.finde(root, 'h1').textContent, 'Diese Auftragsbestätigung wurde storniert.')
|
||||
assert.equal(fx.finde(root, 'form'), null)
|
||||
}))
|
||||
|
||||
test('Fehler beim Bestätigen (429): Meldung in der Fehlerzeile, Seite bleibt offen, Häkchen bleiben', mit([ok({ auftrag: ab() }), ok({ error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }, 429)], async (fx, root, ansage) => {
|
||||
await start(root, ansage)
|
||||
await bestaetige(fx, root)
|
||||
assert.equal(root.dataset.zustand, 'offen')
|
||||
assert.equal(fx.finde(root, '[data-fehler]').textContent, 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten noch einmal.')
|
||||
assert.equal(fx.finde(root, '[data-feld="bestaetigung"]').checked, true)
|
||||
assert.equal(fx.finde(root, '[data-aktion="bestaetigen"]').disabled, false)
|
||||
assert.ok(!fx.text(root).includes(TOKEN))
|
||||
}))
|
||||
|
||||
test('Quelltext: genau ein fetch ohne Cookies, keine Protokollausgabe, nur relative Importe', () => {
|
||||
const quelle = pruefeQuelltext('auftrag.js', { fetchAnzahl: 1, importe: ['./auftrag-ansicht.js', './auftrag-dom.js'] })
|
||||
assert.match(quelle, /credentials: 'omit'/)
|
||||
assert.doesNotMatch(quelle, /location\.href|history\.|document\.referrer/)
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { installFakeDom } from './support/fakeDom.js'
|
||||
import { h } from '../public/auftrag-dom.js'
|
||||
import { initProjektWiderruf } from '../public/auftrag-widerruf.js'
|
||||
import { pruefeQuelltext } from './auftraegeKundenportalDaten.js'
|
||||
|
||||
const html = readFileSync(new URL('../public/widerrufen.html', import.meta.url), 'utf8')
|
||||
const MELDUNG = 'Die Nummer sieht anders aus als AB-2026-0001. Bitte prüfen Sie die Nummer oder lassen Sie das Feld leer.'
|
||||
|
||||
test('widerrufen.html: Vertragsart „Projektauftrag“ hinter „Öffnungszeiten live“, freiwilliges Feld abNummer vor dem Namen, Skripte', () => {
|
||||
assert.match(html, /name="order" value="zeiten"[^>]*> Öffnungszeiten live<\/label>\s*<label class="radio"><input type="radio" name="order" value="projekt" data-text="Projektauftrag \(Auftragsbestätigung\)"> Projektauftrag \(Auftragsbestätigung\)<\/label>\s*<\/fieldset>/)
|
||||
assert.match(html, /<div id="ab-nummer-wrap">\s*<label for="abNummer" data-label-for="abNummer" data-label="Nummer der Auftragsbestätigung">Nummer der Auftragsbestätigung <small>\(falls zur Hand, zum Beispiel AB-2026-0001\)<\/small><\/label>\s*<input id="abNummer" name="abNummer" maxlength="20" autocomplete="off" autocapitalize="characters" spellcheck="false" placeholder="AB-2026-0001">\s*<\/div>/)
|
||||
assert.doesNotMatch(html, /<input id="abNummer"[^>]*required/, 'die Nummer ist freiwillig')
|
||||
assert.ok(html.indexOf('id="ab-nummer-wrap"') < html.indexOf('for="name"'), 'das Feld steht vor dem Namen')
|
||||
assert.doesNotMatch(html, /id="ab-nummer-wrap"[^>]*hidden/, 'ohne das Skript bleibt das Feld sichtbar')
|
||||
assert.match(html, /initLegalForm\(\{ endpoint: '\/api\/abo\/public\/withdraw', fields: \['order', 'abNummer', 'name', 'customerNumber', 'email'\] \}\)/)
|
||||
assert.match(html, /<script type="module" src="\/auftrag-widerruf\.js"><\/script>/)
|
||||
for (const wert of ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten']) assert.match(html, new RegExp(`name="order" value="${wert}"`), `${wert} bleibt`)
|
||||
})
|
||||
|
||||
const mit = (fn) => async () => {
|
||||
const fx = installFakeDom()
|
||||
try {
|
||||
await fn(fx)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
}
|
||||
function aufbau(fx, order = 'abschluss') {
|
||||
const radios = ['abschluss', 'projekt'].map((wert) => h('input', { type: 'radio', name: 'order', value: wert, checked: wert === order }))
|
||||
const nummer = h('input', { id: 'abNummer', name: 'abNummer' })
|
||||
const wrap = h('div', { id: 'ab-nummer-wrap' }, h('label', { for: 'abNummer' }, 'Nummer'), nummer)
|
||||
const fehler = h('p', { id: 'form-error', class: 'error hidden' })
|
||||
const knopf = h('button', { type: 'submit' }, 'Widerruf bestätigen')
|
||||
const form = h('form', { id: 'decl-form' }, h('fieldset', {}, ...radios.map((r) => h('label', { class: 'radio' }, r))), wrap, fehler, knopf)
|
||||
fx.document.body.append(form)
|
||||
const gesendet = []
|
||||
form.addEventListener('submit', (ev) => {
|
||||
ev.preventDefault()
|
||||
gesendet.push(nummer.value)
|
||||
})
|
||||
initProjektWiderruf(form)
|
||||
return { form, radios, nummer, wrap, fehler, knopf, gesendet }
|
||||
}
|
||||
|
||||
test('Das Feld erscheint nur bei „Projektauftrag“ und wird beim Wegwählen geleert', mit(async (fx) => {
|
||||
const t = aufbau(fx, 'abschluss')
|
||||
assert.equal(t.wrap.classList.contains('hidden'), true)
|
||||
await fx.klick(t.radios[1])
|
||||
assert.equal(t.wrap.classList.contains('hidden'), false)
|
||||
await fx.setze(t.nummer, 'AB-2026-0001')
|
||||
await fx.klick(t.radios[0])
|
||||
assert.equal(t.wrap.classList.contains('hidden'), true)
|
||||
assert.equal(t.nummer.value, '')
|
||||
}))
|
||||
|
||||
test('Ist „Projektauftrag“ schon gewählt, ist das Feld sofort da', mit(async (fx) => {
|
||||
const t = aufbau(fx, 'projekt')
|
||||
assert.equal(t.wrap.classList.contains('hidden'), false)
|
||||
}))
|
||||
|
||||
test('Projektauftrag: eine eingegebene, aber unpassende Nummer wird gemeldet und stoppt das Absenden; Wechsel der Vertragsart nimmt die Meldung weg', mit(async (fx) => {
|
||||
const t = aufbau(fx, 'projekt')
|
||||
for (const eingabe of ['AB-2026-01', 'RE-2026-0001', 'irgendwas']) {
|
||||
await fx.setze(t.nummer, eingabe)
|
||||
await fx.klick(t.knopf)
|
||||
assert.deepEqual(t.gesendet, [], eingabe)
|
||||
assert.equal(t.fehler.classList.contains('hidden'), false)
|
||||
assert.equal(t.fehler.textContent, MELDUNG)
|
||||
assert.equal(t.nummer.value, eingabe)
|
||||
}
|
||||
assert.equal(fx.document.activeElement, t.nummer)
|
||||
await fx.klick(t.radios[0])
|
||||
assert.equal(t.fehler.classList.contains('hidden'), true)
|
||||
}))
|
||||
|
||||
test('Ohne Nummer geht der Projektauftrag durch (freiwilliges Feld); die eigene Meldung verschwindet, sobald der Widerruf durchgeht', mit(async (fx) => {
|
||||
const t = aufbau(fx, 'projekt')
|
||||
await fx.klick(t.knopf)
|
||||
assert.deepEqual(t.gesendet, [''])
|
||||
assert.equal(t.fehler.classList.contains('hidden'), true)
|
||||
await fx.setze(t.nummer, 'kaputt')
|
||||
await fx.klick(t.knopf)
|
||||
assert.deepEqual(t.gesendet, [''], 'unpassende Eingabe: nichts gesendet')
|
||||
assert.equal(t.fehler.classList.contains('hidden'), false)
|
||||
await fx.setze(t.nummer, ' ')
|
||||
await fx.klick(t.knopf)
|
||||
assert.deepEqual(t.gesendet, ['', ''], 'nur Leerzeichen zählen als leer')
|
||||
assert.equal(t.fehler.classList.contains('hidden'), true)
|
||||
}))
|
||||
|
||||
test('Gültige Nummer in anderer Schreibweise wird vereinheitlicht, dann geht der Widerruf durch', mit(async (fx) => {
|
||||
const t = aufbau(fx, 'projekt')
|
||||
await fx.setze(t.nummer, ' ab 2026 0001 ')
|
||||
await fx.klick(t.knopf)
|
||||
assert.deepEqual(t.gesendet, ['AB-2026-0001'])
|
||||
assert.equal(t.fehler.classList.contains('hidden'), true)
|
||||
}))
|
||||
|
||||
test('Andere Vertragsart: kein Zwang, das Feld wird leer gesendet', mit(async (fx) => {
|
||||
const t = aufbau(fx, 'abschluss')
|
||||
await fx.klick(t.knopf)
|
||||
assert.deepEqual(t.gesendet, [''])
|
||||
}))
|
||||
|
||||
test('Quelltext: nur ./auftrag-ansicht.js, kein fetch', () => {
|
||||
pruefeQuelltext('auftrag-widerruf.js', { importe: ['./auftrag-ansicht.js'] })
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
|
||||
|
||||
// mails.js: K31 (mit Teamkopie), K32, T22 aufbauen und über die Senke verschicken. Kein Datenbankzugriff: jede versehentliche Anfrage scheitert sofort.
|
||||
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-mails-'))
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1/v1', stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js')
|
||||
const { euroDe } = await import('../server/services/finance/format.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { AGB_FASSUNG } = await import('../server/services/auftraege/konstanten.js')
|
||||
const { FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js')
|
||||
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
|
||||
const { agbPdfPfad, k31Mail, k32Mail, sendeAuftragMail, sendeBestaetigtMails, setzeAgbVerzeichnisFuerTests, t22Mail } = await import('../server/services/auftraege/mails.js')
|
||||
|
||||
const agbVerzeichnis = mkdtempSync(path.join(os.tmpdir(), 'auftraege-agb-'))
|
||||
const AB_PDF = Buffer.from('%PDF-1.4 AB-Test')
|
||||
const AGB_PDF = Buffer.from('%PDF-1.4 AGB-Test')
|
||||
writeFileSync(path.join(agbVerzeichnis, 'agb-2026-09-30.pdf'), AGB_PDF)
|
||||
setzeAgbVerzeichnisFuerTests(agbVerzeichnis)
|
||||
after(() => {
|
||||
setzeAgbVerzeichnisFuerTests(null)
|
||||
setMailTransportForTests(null)
|
||||
})
|
||||
|
||||
let sink
|
||||
let protokoll
|
||||
beforeEach(() => {
|
||||
sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
protokoll = mock.method(console, 'error', () => {})
|
||||
})
|
||||
afterEach(() => mock.restoreAll())
|
||||
const adressen = (liste) => liste.map((a) => a.address)
|
||||
|
||||
const AB = abDokument()
|
||||
const AUFWAND = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, kunde: kunde('unternehmen') }
|
||||
const BESTAETIGT = (over = {}) => abDokument({ status: 'bestaetigt', bestaetigtAm: '2026-10-07T08:30:00.000Z', bestaetigtUeber: 'link', ...over })
|
||||
const K31_BETREFF = 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei'
|
||||
const T22_BETREFF = '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'
|
||||
const WERTE_K31 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', datum: '06.10.2026', betrag: euroDe(80000), gueltig_bis: '05.12.2026' }
|
||||
|
||||
test('agbPdfPfad: Vorgabe ist public/rechtliches/agb-<Fassung>.pdf; eine ungültige Fassung wirft', () => {
|
||||
setzeAgbVerzeichnisFuerTests(null)
|
||||
try {
|
||||
const rechtliches = fileURLToPath(new URL('../public/rechtliches/', import.meta.url))
|
||||
assert.equal(agbPdfPfad(), path.join(rechtliches, `agb-${AGB_FASSUNG}.pdf`))
|
||||
assert.equal(agbPdfPfad('2025-01-01'), path.join(rechtliches, 'agb-2025-01-01.pdf'))
|
||||
for (const fassung of ['', '../x', 'a/b', 'x'.repeat(21)]) assert.throws(() => agbPdfPfad(fassung), TypeError, fassung)
|
||||
} finally {
|
||||
setzeAgbVerzeichnisFuerTests(agbVerzeichnis)
|
||||
}
|
||||
})
|
||||
|
||||
test('k31Mail: Link im Fragment; als Teamkopie ohne Link und ohne Kundenblöcke; Aufwand als Erinnerung (Berliner Tag des Sendens)', () => {
|
||||
assert.deepEqual(k31Mail(AB, { token: TOKEN }), {
|
||||
template: 'K31',
|
||||
values: { ...WERTE_K31, link: `${config.publicBaseUrl}/auftrag.html#t=${TOKEN}` },
|
||||
blocks: { erinnerung: false, privat: true, festpreis: true, aufwand: false, teamkopie: false, kundenmail: true },
|
||||
})
|
||||
// auch wenn man das Token mitgibt: die Teamkopie enthält es nie
|
||||
assert.deepEqual(k31Mail(AB, { token: TOKEN, teamkopie: true }), {
|
||||
template: 'K31',
|
||||
values: { ...WERTE_K31, link: '' },
|
||||
blocks: { erinnerung: false, privat: false, festpreis: true, aufwand: false, teamkopie: true, kundenmail: false },
|
||||
})
|
||||
const nachts = k31Mail(abDokument({ ...AUFWAND, gesendetAm: '2026-10-06T22:30:00.000Z' }), { token: TOKEN, erinnerung: true })
|
||||
assert.equal(nachts.values.datum, '07.10.2026')
|
||||
assert.equal(nachts.values.betrag, `${euroDe(10000)} pro Stunde`)
|
||||
assert.deepEqual(nachts.blocks, { erinnerung: true, privat: false, festpreis: false, aufwand: true, teamkopie: false, kundenmail: true })
|
||||
})
|
||||
|
||||
test('k32Mail und t22Mail: Zeitpunkt in Berliner Zeit, Frühbeginn-Erklärung wörtlich, Blöcke je Kundenart und Wunsch', () => {
|
||||
const ja = BESTAETIGT({ fruehbeginn: true })
|
||||
assert.deepEqual(k32Mail(ja), {
|
||||
template: 'K32',
|
||||
values: { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', fruehester_beginn: '', fruehbeginn_text: FRUEHBEGINN_TEXT },
|
||||
blocks: { fruehbeginn: true, ohne_fruehbeginn: false },
|
||||
})
|
||||
const nein = BESTAETIGT({ bestaetigtUeber: 'portal' })
|
||||
assert.equal(k32Mail(nein).values.fruehester_beginn, '21.10.2026')
|
||||
assert.equal(k32Mail(nein).values.fruehbeginn_text, '')
|
||||
assert.deepEqual(k32Mail(nein).blocks, { fruehbeginn: false, ohne_fruehbeginn: true })
|
||||
const firma = BESTAETIGT({ kunde: kunde('unternehmen') })
|
||||
assert.deepEqual(k32Mail(firma).blocks, { fruehbeginn: false, ohne_fruehbeginn: false })
|
||||
assert.deepEqual([k32Mail(firma).values.fruehester_beginn, k32Mail(firma).values.fruehbeginn_text], ['', ''])
|
||||
assert.deepEqual(t22Mail(nein), {
|
||||
template: 'T22',
|
||||
values: { kunde: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', ueber: 'Kundenportal', woms_link: `${config.womsBaseUrl}/customers/c1?tab=invoices`, fruehester_beginn: '21.10.2026' },
|
||||
blocks: { privat_fruehbeginn: false, privat_ohne_fruehbeginn: true },
|
||||
})
|
||||
assert.equal(t22Mail(firma).values.kunde, 'Muster GmbH')
|
||||
assert.equal(t22Mail(ja).values.ueber, 'Bestätigungslink')
|
||||
assert.deepEqual(t22Mail(ja).blocks, { privat_fruehbeginn: true, privat_ohne_fruehbeginn: false })
|
||||
})
|
||||
|
||||
test('Builder und Vorlagen passen zusammen: alle Platzhalter und Blöcke belegt, keine Unstimmigkeiten beim Rendern', () => {
|
||||
const nachrichten = [
|
||||
k31Mail(AB, { token: TOKEN }),
|
||||
k31Mail(AB, { token: TOKEN, teamkopie: true }),
|
||||
k31Mail(abDokument(AUFWAND), { token: TOKEN, erinnerung: true }),
|
||||
k31Mail(abDokument(AUFWAND), { erinnerung: true, teamkopie: true }),
|
||||
k32Mail(BESTAETIGT({ fruehbeginn: true })),
|
||||
k32Mail(BESTAETIGT()),
|
||||
t22Mail(BESTAETIGT({ fruehbeginn: true })),
|
||||
t22Mail(BESTAETIGT({ kunde: kunde('unternehmen') })),
|
||||
]
|
||||
for (const { template, values, blocks } of nachrichten) {
|
||||
const vorlage = loadTemplate(template)
|
||||
assert.deepEqual(Object.keys(values).sort(), [...vorlage.placeholders].sort(), template)
|
||||
assert.deepEqual(Object.keys(blocks).sort(), [...vorlage.blocks].sort(), template)
|
||||
assert.deepEqual(renderMail(template, values, blocks).problems, [], template)
|
||||
}
|
||||
})
|
||||
|
||||
test('sendeAuftragMail: K31 an den Kunden ohne BCC (Link im Fragment, AB-PDF und AGB), danach die Teamkopie ohne Link, Token und Anhänge', async () => {
|
||||
await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN })
|
||||
assert.equal(sink.sent.length, 2)
|
||||
const [kundenmail, teamkopie] = sink.sent
|
||||
assert.deepEqual(adressen(kundenmail.to), ['k@example.invalid'])
|
||||
assert.ok(!kundenmail.bcc || kundenmail.bcc.length === 0, 'kein BCC ans Team: die Mail trägt den Bestätigungslink')
|
||||
assert.equal(kundenmail.subject, K31_BETREFF)
|
||||
assert.ok(kundenmail.text.includes(`Auftrag bestätigen: ${config.publicBaseUrl}/auftrag.html#t=${TOKEN}`))
|
||||
assert.deepEqual(kundenmail.attachments.map((a) => [a.filename, a.contentType]), [['AB-2026-0001.pdf', 'application/pdf'], ['WEBklar-AGB-2026-09-30.pdf', 'application/pdf']])
|
||||
assert.ok(Buffer.from(kundenmail.attachments[0].content, 'base64').equals(AB_PDF))
|
||||
assert.ok(Buffer.from(kundenmail.attachments[1].content, 'base64').equals(AGB_PDF))
|
||||
assert.deepEqual(adressen(teamkopie.to), config.chatNotify.emails)
|
||||
assert.equal(teamkopie.subject, `Kopie: ${K31_BETREFF}`)
|
||||
assert.ok(teamkopie.text.split('\n').includes('Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.'))
|
||||
assert.ok(!teamkopie.attachments || teamkopie.attachments.length === 0, 'die AB-PDF liegt im Ticketsystem')
|
||||
const geheim = JSON.stringify(teamkopie)
|
||||
for (const spur of [TOKEN, 'auftrag.html#t=', 'auftrag.html?t=']) assert.ok(!geheim.includes(spur), `die Teamkopie enthält ${spur}`)
|
||||
})
|
||||
|
||||
test('sendeAuftragMail: die Erinnerung geht auch als Teamkopie raus; ohne pdf wird die gespeicherte Datei aus der Ablage gelesen', async () => {
|
||||
const { pdfDatei } = await speicherePdf('c1', 'ab-erinnerung', AB_PDF)
|
||||
await sendeAuftragMail(abDokument({ ...AUFWAND, pdfDatei }), { token: TOKEN, erinnerung: true })
|
||||
assert.equal(sink.sent.length, 2)
|
||||
for (const mail of sink.sent) assert.ok(mail.text.includes('Wir haben Ihnen diese Auftragsbestätigung am 06.10.2026 schon einmal geschickt'))
|
||||
const betreff = 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei'
|
||||
assert.deepEqual(sink.sent.map((m) => m.subject), [`Erinnerung: ${betreff}`, `Kopie: Erinnerung: ${betreff}`])
|
||||
assert.ok(Buffer.from(sink.sent[0].attachments[0].content, 'base64').equals(AB_PDF))
|
||||
assert.ok(!JSON.stringify(sink.sent[1]).includes(TOKEN))
|
||||
})
|
||||
|
||||
test('sendeAuftragMail: wirft und verschickt nichts bei fehlender AGB-Datei, falschem Token oder fehlender Adresse; scheitert die Kundenmail, geht keine Teamkopie raus', async () => {
|
||||
await assert.rejects(sendeAuftragMail(abDokument({ agbFassung: '1999-01-01' }), { pdf: AB_PDF, token: TOKEN }), /AGB-PDF 1999-01-01 nicht lesbar/)
|
||||
await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: 'kurz' }), TypeError)
|
||||
await assert.rejects(sendeAuftragMail(abDokument({ gesendetAn: '', kunde: kunde('privat', { email: '' }) }), { pdf: AB_PDF, token: TOKEN }), /E-Mail-Adresse/)
|
||||
assert.equal(sink.sent.length, 0)
|
||||
let versuche = 0
|
||||
setMailTransportForTests({ async sendMail() { versuche += 1; throw new Error('SMTP 451') } })
|
||||
await assert.rejects(sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN }), /SMTP 451/)
|
||||
assert.equal(versuche, 1, 'nach dem Fehler der Kundenmail keine Teamkopie')
|
||||
})
|
||||
|
||||
test('sendeAuftragMail: scheitert nur die Teamkopie, gilt der Versand als erfolgreich (nur Protokoll mit der Nummer)', async () => {
|
||||
let aufrufe = 0
|
||||
setMailTransportForTests({
|
||||
async sendMail(mail) {
|
||||
aufrufe += 1
|
||||
if (aufrufe === 2) throw new Error('SMTP 451')
|
||||
return sink.sendMail(mail)
|
||||
},
|
||||
})
|
||||
await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN })
|
||||
assert.equal(aufrufe, 2)
|
||||
assert.deepEqual(sink.sent.map((m) => adressen(m.to)), [['k@example.invalid']])
|
||||
assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('Teamkopie') && c.arguments.join(' ').includes('AB-2026-0001')))
|
||||
})
|
||||
|
||||
test('sendeBestaetigtMails: K32 an den Kunden (Kopie ans Team), danach T22 an das Team', async () => {
|
||||
await sendeBestaetigtMails(BESTAETIGT({ fruehbeginn: true }))
|
||||
assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', T22_BETREFF])
|
||||
const [k32, t22] = sink.sent
|
||||
assert.deepEqual(adressen(k32.to), ['k@example.invalid'])
|
||||
assert.deepEqual(adressen(k32.bcc), config.chatNotify.emails)
|
||||
assert.deepEqual(adressen(t22.to), config.chatNotify.emails)
|
||||
assert.ok(k32.text.includes('Bestätigt am: 07.10.2026, 10:30 Uhr'))
|
||||
assert.ok(t22.text.includes(`Im Ticketsystem öffnen: ${config.womsBaseUrl}/customers/c1?tab=invoices`))
|
||||
})
|
||||
|
||||
test('sendeBestaetigtMails wirft nie: scheitert K32, geht T22 trotzdem raus; ohne Adresse entfällt K32; Fehler stehen mit der Nummer im Protokoll', async () => {
|
||||
let aufrufe = 0
|
||||
setMailTransportForTests({
|
||||
async sendMail(mail) {
|
||||
aufrufe += 1
|
||||
if (aufrufe === 1) throw new Error('SMTP 451')
|
||||
return sink.sendMail(mail)
|
||||
},
|
||||
})
|
||||
await sendeBestaetigtMails(BESTAETIGT())
|
||||
assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF])
|
||||
assert.ok(protokoll.mock.calls.some((c) => c.arguments.join(' ').includes('K32') && c.arguments.join(' ').includes('AB-2026-0001')))
|
||||
sink.sent.length = 0
|
||||
setMailTransportForTests(sink)
|
||||
await sendeBestaetigtMails(BESTAETIGT({ gesendetAn: '', kunde: kunde('privat', { email: '' }) }))
|
||||
assert.deepEqual(sink.sent.map((m) => m.subject), [T22_BETREFF])
|
||||
})
|
||||
|
||||
test('Protokoll der Mail-Wege: nur Nummer und Fehlerkennung, nie die Meldung des Transports und nie eine Adresse', async () => {
|
||||
const meldung = 'Recipient address rejected: <k@example.invalid>'
|
||||
// nur die Zeilen von mails.js; das Protokoll des Mailers (shared) ist nicht Gegenstand
|
||||
const protokolliert = () => protokoll.mock.calls.map((c) => c.arguments.join(' ')).filter((zeile) => zeile.startsWith('[auftraege]')).join('\n')
|
||||
const pruefe = (mitKennung) => {
|
||||
const text = protokolliert()
|
||||
assert.ok(text.includes('AB-2026-0001'), 'die Nummer steht im Protokoll')
|
||||
for (const verboten of ['k@example.invalid', 'Recipient', 'rejected']) assert.ok(!text.includes(verboten), `das Protokoll enthält ${verboten}`)
|
||||
if (mitKennung) assert.ok(text.includes('EENVELOPE'), 'die Fehlerkennung steht im Protokoll')
|
||||
protokoll.mock.resetCalls()
|
||||
}
|
||||
const fehler = () => Object.assign(new Error(meldung), { code: 'EENVELOPE' })
|
||||
// Teamkopie scheitert (zweiter Aufruf)
|
||||
let aufrufe = 0
|
||||
setMailTransportForTests({ async sendMail(mail) { aufrufe += 1; if (aufrufe === 2) throw fehler(); return sink.sendMail(mail) } })
|
||||
await sendeAuftragMail(AB, { pdf: AB_PDF, token: TOKEN })
|
||||
pruefe(false)
|
||||
// K32 und T22 scheitern beide
|
||||
setMailTransportForTests({ async sendMail() { throw fehler() } })
|
||||
await sendeBestaetigtMails(BESTAETIGT())
|
||||
assert.ok(protokolliert().includes('K32') && protokolliert().includes('T22'))
|
||||
pruefe(true)
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { starteUmgebung, entwurfsDaten } from './helpers/auftraegeAb.js'
|
||||
|
||||
const umg = await starteUmgebung()
|
||||
after(() => umg.schliesse())
|
||||
beforeEach(umg.zuruecksetzen)
|
||||
const { createAuftrag, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { formatNummer, vergibNummer } = await import('../server/services/auftraege/nummern.js')
|
||||
|
||||
/** Eine versendete AB mit Nummer anlegen (über den Speicher von L0). */
|
||||
async function legeNummerierteAn(jahr, laufnummer, patch = {}) {
|
||||
const entwurf = await createAuftrag(entwurfsDaten())
|
||||
return updateAuftrag(entwurf.id, { status: 'versendet', nummer: formatNummer(jahr, laufnummer), jahr, laufnummer, ...patch })
|
||||
}
|
||||
|
||||
test('formatNummer: AB-JJJJ-NNNN mit vier Ziffern; ungültige Werte werfen RangeError', () => {
|
||||
assert.equal(formatNummer(2026, 1), 'AB-2026-0001')
|
||||
assert.equal(formatNummer(2026, 42), 'AB-2026-0042')
|
||||
assert.equal(formatNummer(2027, 9999), 'AB-2027-9999')
|
||||
for (const [jahr, lauf] of [[2026, 0], [2026, 10000], [2026, 1.5], [2026, '1'], [1999, 1], ['2026', 1], [2026, -3]]) {
|
||||
assert.throws(() => formatNummer(jahr, lauf), RangeError, `${jahr}/${lauf}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('vergibNummer: die erste Nummer des Jahres ist 0001', async () => {
|
||||
assert.deepEqual(await vergibNummer(new Date('2026-10-06T10:00:00Z')), { nummer: 'AB-2026-0001', jahr: 2026, laufnummer: 1 })
|
||||
})
|
||||
|
||||
test('vergibNummer: größte Laufnummer des Jahres plus 1 – Lücken egal, andere Jahre und Entwürfe zählen nicht', async () => {
|
||||
for (const nr of [1, 2, 7]) await legeNummerierteAn(2026, nr)
|
||||
await legeNummerierteAn(2025, 40)
|
||||
await createAuftrag(entwurfsDaten()) // Entwurf ohne Nummer
|
||||
assert.deepEqual(await vergibNummer(new Date('2026-10-06T10:00:00Z')), { nummer: 'AB-2026-0008', jahr: 2026, laufnummer: 8 })
|
||||
assert.deepEqual(await vergibNummer(new Date('2025-03-01T10:00:00Z')), { nummer: 'AB-2025-0041', jahr: 2025, laufnummer: 41 })
|
||||
})
|
||||
|
||||
test('vergibNummer: Jahreswechsel nach Berliner Zeit (31.12. 23:00 UTC ist in Berlin schon der 01.01.)', async () => {
|
||||
for (const nr of [1, 2, 3]) await legeNummerierteAn(2026, nr)
|
||||
assert.equal((await vergibNummer(new Date('2026-12-31T22:59:59Z'))).nummer, 'AB-2026-0004')
|
||||
assert.deepEqual(await vergibNummer(new Date('2026-12-31T23:00:00Z')), { nummer: 'AB-2027-0001', jahr: 2027, laufnummer: 1 })
|
||||
assert.equal((await vergibNummer(new Date('2027-01-01T08:00:00Z'))).nummer, 'AB-2027-0001')
|
||||
})
|
||||
|
||||
test('vergibNummer: zählt auch bei mehr als 25 ABs im Jahr richtig (Seitengröße 25 wie bei Appwrite ohne limit)', async () => {
|
||||
for (let i = 1; i <= 30; i += 1) await legeNummerierteAn(2026, i)
|
||||
assert.equal((await vergibNummer(new Date('2026-10-06T10:00:00Z'))).nummer, 'AB-2026-0031')
|
||||
})
|
||||
|
||||
test('vergibNummer: überspringt eine Nummer, die es schon gibt (Dokument mit nummer, aber ohne laufnummer)', async () => {
|
||||
const entwurf = await createAuftrag(entwurfsDaten())
|
||||
await updateAuftrag(entwurf.id, { status: 'versendet', nummer: 'AB-2026-0001' })
|
||||
assert.deepEqual(await vergibNummer(new Date('2026-10-06T10:00:00Z')), { nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2 })
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { alleTexte, beispielAuftrag, beispielSettings, leseSeiten, seitenZahl } from './auftraegePdfHilfe.js'
|
||||
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { auftragPdfDaten, erzeugeAuftragPdf, bereinigeTexte } = await import('../server/services/auftraege/pdf.js')
|
||||
const { AB_TEXTE, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } = await import('../server/services/auftraege/texte.js')
|
||||
const { aufwandText } = await import('../server/services/auftraege/auftragSicht.js')
|
||||
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||
const { euroDe } = await import('../server/services/finance/format.js')
|
||||
|
||||
const JETZT = new Date('2026-10-06T10:00:00.000Z')
|
||||
const cp = (...codes) => String.fromCodePoint(...codes)
|
||||
const FRUEHBEGINN_SATZ = 'Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.' // V7: AB_TEXTE.termine bei Privatpersonen
|
||||
const optionen = (extra = {}) => ({ settings: beispielSettings, jetzt: JETZT, ...extra })
|
||||
const ohneLeerraum = (text) => text.replace(/\s+/g, '')
|
||||
const gesamtText = (pdf) => leseSeiten(pdf).flat().join('\n')
|
||||
const privat = { kunde: { ...beispielAuftrag().kunde, firma: '', kundenart: 'privat' } }
|
||||
const aufwand = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }
|
||||
|
||||
/** Alles, was in den Daten steht und nicht nur Beschriftung oder Schlüssel ist, muss im PDF-Text vorkommen. */
|
||||
function pruefeAlleTexteImPdf(pdf, auftrag, extra = {}) {
|
||||
const daten = bereinigeTexte(auftragPdfDaten(auftrag, optionen(extra)))
|
||||
const imPdf = ohneLeerraum(gesamtText(pdf))
|
||||
const erwartet = alleTexte({ ...daten, beschriftung: null })
|
||||
assert.ok(erwartet.length > 20)
|
||||
for (const text of erwartet) assert.ok(imPdf.includes(ohneLeerraum(text)), `fehlt im PDF: ${text.slice(0, 70)}`)
|
||||
}
|
||||
|
||||
test('Festpreis-AB: gültiges A4-PDF, jede Angabe aus den Daten steht im Text', async () => {
|
||||
const auftrag = beispielAuftrag()
|
||||
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
|
||||
assert.ok(Buffer.isBuffer(pdf))
|
||||
assert.equal(pdf.subarray(0, 5).toString('latin1'), '%PDF-')
|
||||
assert.equal(pdf.subarray(-6).toString('latin1'), '%%EOF\n')
|
||||
assert.match(pdf.toString('latin1'), /\/MediaBox \[0 0 595\.28 841\.89\]/)
|
||||
const n = seitenZahl(pdf)
|
||||
assert.ok(n >= 1)
|
||||
assert.equal(leseSeiten(pdf).length, n)
|
||||
pruefeAlleTexteImPdf(pdf, auftrag)
|
||||
const text = gesamtText(pdf)
|
||||
for (const erwartet of ['AB-2026-0007', '06.10.2026', 'K-1042', 'Steuernummer 12/345/67890', euroDe(60000), euroDe(80000), `Seite 1 von ${n}`]) {
|
||||
assert.ok(text.includes(erwartet), erwartet)
|
||||
}
|
||||
assert.equal(text.includes('ENTWURF'), false)
|
||||
assert.equal(text.includes('\u{FFFD}'), false)
|
||||
})
|
||||
|
||||
test('Aufwand-AB: Aufwandsregel statt Tabelle', async () => {
|
||||
const auftrag = beispielAuftrag(aufwand)
|
||||
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
|
||||
pruefeAlleTexteImPdf(pdf, auftrag)
|
||||
const text = gesamtText(pdf)
|
||||
assert.ok(ohneLeerraum(text).includes(ohneLeerraum(aufwandText(auftrag))))
|
||||
assert.equal(text.includes('Einzelpreis'), false)
|
||||
assert.equal(text.includes('Gesamtpreis'), false)
|
||||
})
|
||||
|
||||
test('Privatperson: Widerrufsbelehrung und Muster-Widerrufsformular auf eigenen Seiten nach der AB', async () => {
|
||||
const auftrag = beispielAuftrag(privat)
|
||||
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
|
||||
const seiten = leseSeiten(pdf)
|
||||
const texte = seiten.map((zeilen) => zeilen.join('\n'))
|
||||
const index = seiten.findIndex((zeilen) => zeilen.includes(WIDERRUFSBELEHRUNG_PROJEKT.titel))
|
||||
assert.ok(index >= 1, 'die Belehrung steht nicht auf Seite 1')
|
||||
assert.equal(seiten[index][2], WIDERRUFSBELEHRUNG_PROJEKT.titel, 'sie beginnt oben, direkt unter dem Seitenkopf (WEBklar, Titel und Nummer)')
|
||||
assert.ok(texte.slice(index).join('\n').includes(WIDERRUFSFORMULAR.titel))
|
||||
assert.ok(ohneLeerraum(texte.join('')).includes(ohneLeerraum(FRUEHBEGINN_SATZ)), 'Satz zum Beginn nach Ablauf der Widerrufsfrist')
|
||||
pruefeAlleTexteImPdf(pdf, auftrag)
|
||||
texte.forEach((text, i) => assert.ok(text.includes(`Seite ${i + 1} von ${texte.length}`)))
|
||||
const unternehmen = gesamtText(await erzeugeAuftragPdf(beispielAuftrag(), optionen()))
|
||||
assert.equal(unternehmen.includes(WIDERRUFSFORMULAR.titel), false)
|
||||
assert.equal(ohneLeerraum(unternehmen).includes(ohneLeerraum(FRUEHBEGINN_SATZ)), false, 'bei Unternehmen steht der Satz nicht im PDF')
|
||||
})
|
||||
|
||||
test('Umlaute, ß, €, Minuszeichen, Gedankenstrich und Anführungszeichen kommen lesbar im PDF an', async () => {
|
||||
const name = `Jörg Müßig${cp(0x2212)}Åström`
|
||||
const titel = `Übergabe „Größe“ ${cp(0x2013)} 3${cp(0xa0)}€ ${cp(0x2212)}5${cp(0xa0)}€`
|
||||
const auftrag = beispielAuftrag({ titel, kunde: { ...beispielAuftrag().kunde, name, firma: '' } })
|
||||
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
|
||||
const text = gesamtText(pdf)
|
||||
assert.ok(text.includes(name), name)
|
||||
assert.ok(ohneLeerraum(text).includes(ohneLeerraum(titel)), titel)
|
||||
assert.equal(text.includes('\u{FFFD}'), false)
|
||||
})
|
||||
|
||||
test('Zeichen ohne Glyph werden ersetzt statt als Kästchen gedruckt', async () => {
|
||||
const auftrag = beispielAuftrag({ kunde: { ...beispielAuftrag().kunde, firma: '', name: `${cp(0x411, 0x43e, 0x440)} Łukasz`, strasse: `Hauptstraße${cp(0x202f)}12` } })
|
||||
const text = gesamtText(await erzeugeAuftragPdf(auftrag, optionen()))
|
||||
assert.ok(text.includes('??? Łukasz'))
|
||||
assert.ok(text.includes('Hauptstraße 12'))
|
||||
assert.equal(text.includes('\u{FFFD}'), false)
|
||||
})
|
||||
|
||||
test('20 Positionen mit 200 Zeichen ohne Leerzeichen: mehrere Seiten, Tabellenkopf auf jeder Seite mit Positionen, nichts geht verloren', async () => {
|
||||
const baustein = 'ÄÖÜäöü߀' + cp(0x2212)
|
||||
const positionen = Array.from({ length: 20 }, (_, i) => ({ beschreibung: `P${i + 1}${baustein.repeat(30)}`.slice(0, 200), menge: i + 1, einzelpreisCents: 1000 + i }))
|
||||
const summeCents = positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
|
||||
const auftrag = beispielAuftrag({ positionen, summeCents, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })
|
||||
const pdf = await erzeugeAuftragPdf(auftrag, optionen())
|
||||
const n = seitenZahl(pdf)
|
||||
const seiten = leseSeiten(pdf).map((zeilen) => zeilen.join('\n'))
|
||||
assert.ok(n >= 2, `Seiten: ${n}`)
|
||||
assert.equal(seiten.length, n)
|
||||
const alles = ohneLeerraum(seiten.join('\n'))
|
||||
for (const p of positionen) assert.ok(alles.includes(ohneLeerraum(p.beschreibung)), p.beschreibung.slice(0, 12))
|
||||
assert.ok(alles.includes(ohneLeerraum(euroDe(summeCents))), 'Gesamtpreis')
|
||||
seiten.forEach((text, i) => {
|
||||
if (/P\d+/.test(text)) assert.ok(text.includes('Beschreibung') && text.includes('Einzelpreis'), `Seite ${i + 1} ohne Tabellenkopf`)
|
||||
assert.ok(text.includes(`Seite ${i + 1} von ${n}`))
|
||||
assert.equal(text.includes('\u{FFFD}'), false)
|
||||
})
|
||||
assert.ok(seiten.find((text) => text.includes('P20')).includes('Gesamtpreis'), 'Summe steht auf der Seite der letzten Position')
|
||||
})
|
||||
|
||||
test('mehrere PDFs gleichzeitig: Schriften sind geteilt, nichts vermischt sich', async () => {
|
||||
const nummern = [1, 2, 3, 4, 5].map((i) => `AB-2026-000${i}`)
|
||||
const pdfs = await Promise.all(nummern.map((nummer) => erzeugeAuftragPdf(beispielAuftrag({ nummer }), optionen())))
|
||||
pdfs.forEach((pdf, i) => {
|
||||
const text = gesamtText(pdf)
|
||||
assert.ok(text.includes(nummern[i]))
|
||||
assert.equal(nummern.filter((n) => text.includes(n)).length, 1)
|
||||
})
|
||||
})
|
||||
|
||||
test('unvollständige AB: AuftragError 409 statt halbem PDF', async () => {
|
||||
await assert.rejects(erzeugeAuftragPdf(beispielAuftrag({ titel: '' }), optionen()), (e) => e instanceof AuftragError && e.status === 409)
|
||||
await assert.rejects(erzeugeAuftragPdf(beispielAuftrag({ kunde: null }), optionen({ vorschau: true })), (e) => e instanceof AuftragError && e.status === 409)
|
||||
})
|
||||
|
||||
test('Vorschau: ENTWURF und Hinweis auf jeder Seite, Nummer „Entwurf“', async () => {
|
||||
const pdf = await erzeugeAuftragPdf(beispielAuftrag({ nummer: '', gesendetAm: null }), optionen({ vorschau: true }))
|
||||
const seiten = leseSeiten(pdf)
|
||||
assert.ok(seiten.length >= 1)
|
||||
for (const zeilen of seiten) {
|
||||
const text = zeilen.join('\n')
|
||||
assert.ok(text.includes('ENTWURF'))
|
||||
assert.ok(text.includes('Entwurf – Vorschau, noch keine gültige Auftragsbestätigung'))
|
||||
}
|
||||
assert.ok(gesamtText(pdf).includes('Entwurf'))
|
||||
})
|
||||
|
||||
test('ersetzte AB: Hinweis mit Nummer und Datum der alten AB im PDF', async () => {
|
||||
const pdf = await erzeugeAuftragPdf(beispielAuftrag(), optionen({ ersetzt: { nummer: 'AB-2026-0003', datum: '2026-10-01' } }))
|
||||
assert.ok(ohneLeerraum(gesamtText(pdf)).includes(ohneLeerraum(AB_TEXTE.ersetzt({ nummer: 'AB-2026-0003', datum: '01.10.2026' }))))
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { beispielAuftrag, beispielSettings } from './auftraegePdfHilfe.js'
|
||||
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { auftragPdfDaten, PDF_BESCHRIFTUNG, bereinigeTexte } = await import('../server/services/auftraege/pdf.js')
|
||||
const { AB_TEXTE, WIDERRUFSBELEHRUNG_PROJEKT, WIDERRUFSFORMULAR } = await import('../server/services/auftraege/texte.js')
|
||||
const { zahlungsplanText, aufwandText } = await import('../server/services/auftraege/auftragSicht.js')
|
||||
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||
const { euroDe } = await import('../server/services/finance/format.js')
|
||||
|
||||
const JETZT = new Date('2026-10-06T10:00:00.000Z')
|
||||
const NBSP = String.fromCodePoint(0xa0)
|
||||
const FRUEHBEGINN_SATZ = 'Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.' // V7: AB_TEXTE.termine bei Privatpersonen
|
||||
const daten = (extra = {}, optionen = {}) => auftragPdfDaten(beispielAuftrag(extra), { settings: beispielSettings, jetzt: JETZT, ...optionen })
|
||||
const aufwandAuftrag = { art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } }
|
||||
|
||||
test('euroDe liefert Beträge mit geschütztem Leerzeichen vor dem €-Zeichen (Grundlage aller Betragsvergleiche hier)', () => {
|
||||
assert.equal(euroDe(60000), `600,00${NBSP}€`)
|
||||
})
|
||||
|
||||
test('Festpreis: Kopf, Anschrift, Angaben und Positionen sind fertig formatiert', () => {
|
||||
const d = daten()
|
||||
assert.equal(d.vorschau, false)
|
||||
assert.equal(d.titel, AB_TEXTE.titel)
|
||||
assert.equal(d.nummer, 'AB-2026-0007')
|
||||
assert.equal(d.datum, '06.10.2026')
|
||||
assert.equal(d.kundennummer, 'K-1042')
|
||||
assert.equal(d.projekt, 'Neue Website Müller Bäckerei')
|
||||
assert.deepEqual(d.absender.zeilen, [
|
||||
'WEBklar GbR', 'Am Schwimmbad 10', '67722 Winnweiler', 'Telefon 0176 23726355', 'support@webklar.com', 'webklar.com', 'Steuernummer 12/345/67890',
|
||||
])
|
||||
assert.equal(d.absender.rueckzeile, 'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler')
|
||||
assert.deepEqual(d.empfaenger, ['Müller Bäckerei GmbH', 'Anna Müller', 'Hauptstraße 12', '67722 Winnweiler'])
|
||||
assert.equal(d.einleitung, AB_TEXTE.einleitung({ datum: '05.10.2026', weg: 'telefonisch' }))
|
||||
assert.ok(d.einleitung.includes('05.10.2026') && d.einleitung.includes('telefonisch'))
|
||||
assert.equal(d.leistung.art, 'festpreis')
|
||||
assert.equal(d.leistung.ueberschrift, 'Leistungsumfang')
|
||||
assert.deepEqual(d.leistung.positionen, [
|
||||
{ nr: '1', beschreibung: 'Gestaltung und Umsetzung der Website (5 Seiten)', menge: '1', einzelpreis: euroDe(60000), summe: euroDe(60000) },
|
||||
{ nr: '2', beschreibung: 'Kontaktformular mit E-Mail-Versand', menge: '2', einzelpreis: euroDe(10000), summe: euroDe(20000) },
|
||||
])
|
||||
assert.deepEqual(d.leistung.gesamt, { label: 'Gesamtpreis', betrag: euroDe(80000) })
|
||||
assert.equal(d.leistung.aufwandText, null)
|
||||
assert.equal(d.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
|
||||
assert.deepEqual(PDF_BESCHRIFTUNG.spalten, ['Pos.', 'Beschreibung', 'Menge', 'Einzelpreis', 'Summe'])
|
||||
})
|
||||
|
||||
test('Datum der AB ist der Berliner Tag von gesendetAm, ohne gesendetAm der heutige Berliner Tag', () => {
|
||||
assert.equal(daten({ gesendetAm: '2026-10-06T22:30:00.000Z' }).datum, '07.10.2026')
|
||||
assert.equal(daten({ gesendetAm: '2026-10-06T21:30:00+00:00' }).datum, '06.10.2026')
|
||||
assert.equal(daten({ gesendetAm: null }, { jetzt: new Date('2026-12-31T23:30:00.000Z') }).datum, '01.01.2027')
|
||||
})
|
||||
|
||||
test('Aufwand: Aufwandsregel statt Positionen, keine Gesamtsumme', () => {
|
||||
const auftrag = beispielAuftrag(aufwandAuftrag)
|
||||
const d = auftragPdfDaten(auftrag, { settings: beispielSettings, jetzt: JETZT })
|
||||
assert.equal(d.leistung.art, 'aufwand')
|
||||
assert.deepEqual(d.leistung.positionen, [])
|
||||
assert.equal(d.leistung.gesamt, null)
|
||||
assert.equal(d.leistung.aufwandText, aufwandText(auftrag))
|
||||
assert.ok(d.leistung.aufwandText.includes(euroDe(10000)))
|
||||
assert.equal(d.kleinunternehmer, AB_TEXTE.kleinunternehmer)
|
||||
})
|
||||
|
||||
test('Vorschau: Nummer „Entwurf“, heutiges Datum, Kennzeichnung; ohne Vorschau braucht die AB eine Nummer', () => {
|
||||
const entwurf = { nummer: '', gesendetAm: null }
|
||||
const d = daten(entwurf, { vorschau: true })
|
||||
assert.equal(d.vorschau, true)
|
||||
assert.equal(d.nummer, 'Entwurf')
|
||||
assert.equal(d.datum, '06.10.2026')
|
||||
assert.equal(d.beschriftung.entwurf, 'ENTWURF')
|
||||
assert.throws(() => daten(entwurf), (e) => e instanceof AuftragError && e.status === 409 && /Nummer/.test(e.message))
|
||||
})
|
||||
|
||||
test('Anschrift: Firma und Name nur einmal, Land nur außerhalb Deutschlands, leere Teile entfallen', () => {
|
||||
const kunde = (extra) => ({ ...beispielAuftrag().kunde, ...extra })
|
||||
assert.deepEqual(daten({ kunde: kunde({ firma: 'Anna Müller' }) }).empfaenger, ['Anna Müller', 'Hauptstraße 12', '67722 Winnweiler'])
|
||||
assert.deepEqual(daten({ kunde: kunde({ land: 'Österreich', plz: '1010', ort: 'Wien' }) }).empfaenger, ['Müller Bäckerei GmbH', 'Anna Müller', 'Hauptstraße 12', '1010 Wien', 'Österreich'])
|
||||
assert.deepEqual(daten({ kunde: kunde({ firma: '', land: '', strasse: '' }) }).empfaenger, ['Anna Müller', '67722 Winnweiler'])
|
||||
})
|
||||
|
||||
test('Fehlende Pflichtangaben und falsche Summe: AuftragError 409 mit Klartext statt kaputtem PDF', () => {
|
||||
const faelle = [
|
||||
[{ kunde: null }, /Kundendaten/],
|
||||
[{ titel: ' ' }, /Titel/],
|
||||
[{ auftragVom: '' }, /Datum der Absprache/],
|
||||
[{ auftragWeg: 'brieftaube' }, /Weg der Absprache/],
|
||||
[{ art: 'pauschal' }, /Art des Auftrags/],
|
||||
[{ zahlungsplan: null }, /Zahlungsplan/],
|
||||
[{ positionen: [], summeCents: 0 }, /Positionen/],
|
||||
[{ summeCents: 79999 }, /Summe der Positionen/],
|
||||
[{ ...aufwandAuftrag, stundensatzCents: null }, /Stundensatz/],
|
||||
]
|
||||
for (const [extra, muster] of faelle) {
|
||||
assert.throws(() => daten(extra, { vorschau: true }), (e) => e instanceof AuftragError && e.status === 409 && muster.test(e.message), JSON.stringify(extra))
|
||||
}
|
||||
})
|
||||
test('Zahlungsplan Anzahlung nennt Anzahlung und Rest mit Beträgen und das Zahlungsziel', () => {
|
||||
const auftrag = beispielAuftrag()
|
||||
const text = daten().abschnitte.find((a) => a.id === 'zahlung').text
|
||||
assert.equal(text, zahlungsplanText(auftrag))
|
||||
assert.equal(text.split(euroDe(40000)).length - 1, 2)
|
||||
assert.ok(text.includes('50') && text.includes('14'))
|
||||
const nachAufwand = beispielAuftrag(aufwandAuftrag)
|
||||
assert.equal(auftragPdfDaten(nachAufwand, { settings: beispielSettings, jetzt: JETZT }).abschnitte[0].text, zahlungsplanText(nachAufwand))
|
||||
const dreissig = beispielAuftrag({ zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 30 })
|
||||
const text30 = auftragPdfDaten(dreissig, { settings: beispielSettings, jetzt: JETZT }).abschnitte[0].text
|
||||
assert.ok(text30.includes(euroDe(24000)) && text30.includes(euroDe(56000)) && text30.includes('30'))
|
||||
})
|
||||
|
||||
test('Abschnitte: Zahlung, Termine (nur mit Datum), Mitwirkung und Abnahme; AGB-Satz mit Fassung', () => {
|
||||
const d = daten()
|
||||
assert.deepEqual(d.abschnitte.map((a) => [a.id, a.ueberschrift]), [['zahlung', 'Zahlung'], ['termine', 'Termine'], ['mitwirkung', 'Mitwirkung und Abnahme']])
|
||||
assert.equal(d.abschnitte[1].text, AB_TEXTE.termine({ start: '12.10.2026', fertig: '15.11.2026', privat: false }))
|
||||
assert.equal(d.abschnitte[2].text, AB_TEXTE.mitwirkung)
|
||||
assert.equal(d.agb, AB_TEXTE.agb({ fassung: '30.09.2026' }))
|
||||
assert.equal(daten({ startAm: '', fertigBis: '' }).abschnitte.some((a) => a.id === 'termine'), false)
|
||||
assert.equal(daten({ agbFassung: '' }).agb, AB_TEXTE.agb({ fassung: '30.09.2026' }), 'ohne agbFassung gilt AGB_FASSUNG')
|
||||
assert.equal(daten().gruss, AB_TEXTE.gruss)
|
||||
})
|
||||
|
||||
test('Termine: Privatpersonen bekommen den Satz zum Beginn nach Ablauf der Widerrufsfrist, auch ohne Daten; Unternehmen nicht', () => {
|
||||
const privat = { kunde: { ...beispielAuftrag().kunde, firma: '', kundenart: 'privat' } }
|
||||
const termine = (d) => d.abschnitte.find((a) => a.id === 'termine')?.text
|
||||
const mitDaten = termine(daten(privat))
|
||||
assert.equal(mitDaten, AB_TEXTE.termine({ start: '12.10.2026', fertig: '15.11.2026', privat: true }))
|
||||
assert.ok(mitDaten.includes(FRUEHBEGINN_SATZ) && mitDaten.includes('12.10.2026') && mitDaten.includes('15.11.2026'))
|
||||
const ohneDaten = termine(daten({ ...privat, startAm: '', fertigBis: '' }))
|
||||
assert.equal(ohneDaten, AB_TEXTE.termine({ start: null, fertig: null, privat: true }))
|
||||
assert.ok(ohneDaten.includes(FRUEHBEGINN_SATZ), 'der Abschnitt „Termine“ steht auch ohne Start- und Fertigdatum')
|
||||
assert.equal(termine(daten()).includes(FRUEHBEGINN_SATZ), false)
|
||||
assert.equal(termine(daten({ startAm: '', fertigBis: '' })), undefined, 'Unternehmen ohne Daten: kein Abschnitt')
|
||||
})
|
||||
|
||||
test('Privatperson: Widerrufsbelehrung und Muster-Widerrufsformular gehören dazu, bei Unternehmen nicht', () => {
|
||||
assert.equal(daten().widerruf, null)
|
||||
const privat = { kunde: { ...beispielAuftrag().kunde, firma: '', kundenart: 'privat' } }
|
||||
const d = daten(privat)
|
||||
assert.deepEqual(d.widerruf, { belehrung: WIDERRUFSBELEHRUNG_PROJEKT, formular: WIDERRUFSFORMULAR })
|
||||
assert.deepEqual(d.empfaenger, ['Anna Müller', 'Hauptstraße 12', '67722 Winnweiler'])
|
||||
})
|
||||
|
||||
test('Ersetzte AB: Hinweis mit Nummer und Datum nur, wenn ersetzt übergeben wird', () => {
|
||||
assert.equal(daten().ersetzt, null)
|
||||
assert.equal(daten({}, { ersetzt: { nummer: 'AB-2026-0003', datum: '2026-10-01' } }).ersetzt, AB_TEXTE.ersetzt({ nummer: 'AB-2026-0003', datum: '01.10.2026' }))
|
||||
})
|
||||
|
||||
test('Fuß: Bankverbindung zeilenweise, Kontakt; leere Angaben lassen ihre Zeilen weg', () => {
|
||||
const d = daten()
|
||||
assert.deepEqual(d.fuss.bank, ['Finom Bank', 'IBAN DE12 3456 7890 1234 5678 90', 'BIC FNOMDEB2'])
|
||||
assert.deepEqual(d.fuss.kontakt, ['WEBklar GbR · vertreten durch Kenso Grimm und Justin Klein', 'Telefon 0176 23726355 · support@webklar.com · webklar.com'])
|
||||
const leer = auftragPdfDaten(beispielAuftrag({ kunde: { ...beispielAuftrag().kunde, kundennummer: '' } }), { settings: {}, jetzt: JETZT })
|
||||
assert.deepEqual(leer.fuss.bank, [])
|
||||
assert.equal(leer.kundennummer, '')
|
||||
assert.equal(leer.absender.zeilen.at(-1), 'webklar.com', 'ohne Steuernummer entfällt die Zeile')
|
||||
assert.equal(auftragPdfDaten(beispielAuftrag(), { jetzt: JETZT }).fuss.bank.length, 0, 'ohne settings')
|
||||
})
|
||||
|
||||
test('Wächter: alle Texte, die ins PDF kommen, haben ein Glyph in Figtree (die Ersetzung greift dort nie ein)', () => {
|
||||
const kunde = beispielAuftrag().kunde
|
||||
const faelle = [
|
||||
daten({ kunde: { ...kunde, kundenart: 'privat' } }, { ersetzt: { nummer: 'AB-2026-0003', datum: '2026-10-01' } }),
|
||||
daten({ kunde: { ...kunde, kundenart: 'privat' }, startAm: '', fertigBis: '' }),
|
||||
daten({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } }),
|
||||
daten({ zahlungsplan: { art: 'frei', prozent: null, text: 'Zahlung in drei Raten.' } }),
|
||||
daten(aufwandAuftrag),
|
||||
daten({ ...aufwandAuftrag, schaetzungMinuten: null }),
|
||||
]
|
||||
for (const d of faelle) assert.deepEqual(bereinigeTexte(d), d)
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { beispielAuftrag, beispielSettings, leseSeiten } from './auftraegePdfHilfe.js'
|
||||
|
||||
// Die alte AB liegt im Appwrite-Nachbau (Schema aus L0); erzeugeAuftragPdf liest sie über getAuftrag aus auftragStore.js
|
||||
const aw = await startAppwriteMock({ schema: { auftraege: schemaKeys('auftraege') } })
|
||||
after(() => aw.close())
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { erzeugeAuftragPdf } = await import('../server/services/auftraege/pdf.js')
|
||||
const { AB_TEXTE } = await import('../server/services/auftraege/texte.js')
|
||||
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||
|
||||
const optionen = { settings: beispielSettings, jetzt: new Date('2026-10-06T10:00:00.000Z') }
|
||||
const ohneLeerraum = (text) => text.replace(/\s+/g, '')
|
||||
const text = (pdf) => ohneLeerraum(leseSeiten(pdf).flat().join(''))
|
||||
const hinweis = ohneLeerraum(AB_TEXTE.ersetzt({ nummer: 'AB-2026-0003', datum: '01.10.2026' }))
|
||||
|
||||
aw.seed('auftraege', { $id: 'alt1', kundenId: 'k1', status: 'versendet', nummer: 'AB-2026-0003', gesendetAm: '2026-10-01T09:00:00.000+00:00' })
|
||||
|
||||
test('ersetztId ohne Option: die alte AB wird gelesen, ihre Nummer und ihr Berliner Datum stehen im PDF', async () => {
|
||||
const pdf = await erzeugeAuftragPdf(beispielAuftrag({ ersetztId: 'alt1' }), optionen)
|
||||
assert.ok(text(pdf).includes(hinweis))
|
||||
})
|
||||
|
||||
test('ersetztId verweist auf eine unbekannte AB: AuftragError 409 statt PDF ohne den Hinweis', async () => {
|
||||
await assert.rejects(erzeugeAuftragPdf(beispielAuftrag({ ersetztId: 'gibtsnicht' }), optionen), (e) => e instanceof AuftragError && e.status === 409)
|
||||
})
|
||||
|
||||
test('ersetzt: null unterdrückt Hinweis und Lesen, auch bei gesetztem ersetztId', async () => {
|
||||
const pdf = await erzeugeAuftragPdf(beispielAuftrag({ ersetztId: 'gibtsnicht' }), { ...optionen, ersetzt: null })
|
||||
assert.equal(text(pdf).includes(hinweis), false)
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { inflateSync } from 'node:zlib'
|
||||
|
||||
/** Beispiel-Auftrag (V3-Form) für die PDF-Tests; `extra` überschreibt einzelne Felder. */
|
||||
export function beispielAuftrag(extra = {}) {
|
||||
return {
|
||||
id: 'ab1', nummer: 'AB-2026-0007', jahr: 2026, laufnummer: 7, status: 'versendet', kundenId: 'k1',
|
||||
kunde: {
|
||||
name: 'Anna Müller', firma: 'Müller Bäckerei GmbH', strasse: 'Hauptstraße 12', plz: '67722', ort: 'Winnweiler',
|
||||
land: 'Deutschland', email: 'anna@example.test', kundenart: 'unternehmen', kundennummer: 'K-1042',
|
||||
},
|
||||
projektId: 'p1', titel: 'Neue Website Müller Bäckerei', zeitquelle: ['t1'], art: 'festpreis',
|
||||
positionen: [
|
||||
{ beschreibung: 'Gestaltung und Umsetzung der Website (5 Seiten)', menge: 1, einzelpreisCents: 60000 },
|
||||
{ beschreibung: 'Kontaktformular mit E-Mail-Versand', menge: 2, einzelpreisCents: 10000 },
|
||||
],
|
||||
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
|
||||
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14,
|
||||
auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '2026-10-12', fertigBis: '2026-11-15',
|
||||
ersetztId: '', ersetztDurchId: '', texteFassung: '2026-10-06', agbFassung: '2026-09-30', pdfDatei: '', pdfSha256: '',
|
||||
gesendetAm: '2026-10-06T08:30:00.000Z', gesendetAn: 'anna@example.test', mailStatus: '', mailFehler: '',
|
||||
tokenHash: '', tokenGueltigBis: null, bestaetigtAm: null, bestaetigtUeber: '', bestaetigtPdfSha256: '',
|
||||
fruehbeginn: false, fruehbeginnFassung: '', storniertAm: null, widerrufenAm: null,
|
||||
erstelltVon: 'kenso', erstelltAm: '2026-10-06T08:00:00.000Z', geaendertAm: '2026-10-06T08:00:00.000Z',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
export const beispielSettings = { steuernummer: '12/345/67890', bankDetailsText: 'Finom Bank\nIBAN DE12 3456 7890 1234 5678 90\nBIC FNOMDEB2' }
|
||||
|
||||
/** Alle Zeichenketten in verschachtelten Objekten und Listen (flach gesammelt); Werte unter den Schlüsseln id und art sind keine Texte. */
|
||||
export function alleTexte(wert, aus = []) {
|
||||
if (typeof wert === 'string') aus.push(wert)
|
||||
else if (Array.isArray(wert)) wert.forEach((e) => alleTexte(e, aus))
|
||||
else if (wert && typeof wert === 'object') {
|
||||
for (const [schluessel, e] of Object.entries(wert)) if (schluessel !== 'id' && schluessel !== 'art') alleTexte(e, aus)
|
||||
}
|
||||
return aus
|
||||
}
|
||||
|
||||
/** Anzahl Seiten eines PDFs (Seitenobjekte, nicht der Seitenbaum). */
|
||||
export const seitenZahl = (pdf) => (pdf.toString('latin1').match(/\/Type \/Page(?![s\w])/g) || []).length
|
||||
|
||||
/**
|
||||
* Text aus einem mit pdfkit erzeugten PDF (eingebettete Schriften, Identity-H, ToUnicode), je Seite eine Liste
|
||||
* von Textzeilen in Zeichenreihenfolge. Nur für Tests: kennt genau das, was pdfkit schreibt.
|
||||
*/
|
||||
export function leseSeiten(pdf) {
|
||||
const s = pdf.toString('latin1')
|
||||
const objekte = new Map()
|
||||
const re = /(\d+) 0 obj\n/g
|
||||
let m
|
||||
while ((m = re.exec(s))) {
|
||||
const start = m.index + m[0].length
|
||||
const ende = s.indexOf('\nendobj', start)
|
||||
const koerper = s.slice(start, ende)
|
||||
const strom = koerper.indexOf('\nstream\n')
|
||||
let dict = koerper
|
||||
let daten = null
|
||||
if (strom >= 0) {
|
||||
dict = koerper.slice(0, strom)
|
||||
const laenge = Number(/\/Length (\d+)/.exec(dict)[1])
|
||||
daten = pdf.subarray(start + strom + 8, start + strom + 8 + laenge)
|
||||
if (/\/Filter \/FlateDecode/.test(dict)) daten = inflateSync(daten)
|
||||
}
|
||||
objekte.set(Number(m[1]), { dict, daten })
|
||||
re.lastIndex = ende
|
||||
}
|
||||
const tabellen = new Map()
|
||||
for (const [id, o] of objekte) {
|
||||
const tu = /\/ToUnicode (\d+) 0 R/.exec(o.dict)
|
||||
if (!/\/Subtype \/Type0/.test(o.dict) || !tu) continue
|
||||
const tabelle = []
|
||||
for (const r of objekte.get(Number(tu[1])).daten.toString('latin1').matchAll(/<([0-9a-f]{4})> <([0-9a-f]{4})> \[([^\]]*)\]/g)) {
|
||||
let i = parseInt(r[1], 16)
|
||||
for (const e of r[3].matchAll(/<([0-9a-f ]+)>/g)) tabelle[i++] = e[1].split(' ').map((h) => String.fromCharCode(parseInt(h, 16))).join('')
|
||||
}
|
||||
tabellen.set(id, tabelle)
|
||||
}
|
||||
const schriftNachName = new Map()
|
||||
for (const o of objekte.values()) {
|
||||
if (!/\/Font <</.test(o.dict)) continue
|
||||
for (const f of o.dict.matchAll(/\/(F\d+) (\d+) 0 R/g)) schriftNachName.set(f[1], tabellen.get(Number(f[2])))
|
||||
}
|
||||
const seiten = []
|
||||
for (const o of objekte.values()) {
|
||||
if (!/\/Type \/Page(?![s\w])/.test(o.dict)) continue
|
||||
const inhalt = objekte.get(Number(/\/Contents (\d+) 0 R/.exec(o.dict)[1])).daten.toString('latin1')
|
||||
const zeilen = []
|
||||
for (const bt of inhalt.matchAll(/BT\n([\s\S]*?)\nET/g)) {
|
||||
const tabelle = schriftNachName.get(/\/(F\d+) [\d.]+ Tf/.exec(bt[1])?.[1])
|
||||
let text = ''
|
||||
for (const tj of bt[1].matchAll(/\[([^\]]*)\] TJ/g)) {
|
||||
for (const h of tj[1].matchAll(/<([0-9a-f]+)>/g)) {
|
||||
for (let i = 0; i < h[1].length; i += 4) text += tabelle[parseInt(h[1].slice(i, i + 4), 16)] ?? '\u{FFFD}'
|
||||
}
|
||||
}
|
||||
zeilen.push(text)
|
||||
}
|
||||
seiten.push(zeilen)
|
||||
}
|
||||
return seiten
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { beispielAuftrag, beispielSettings } from './auftraegePdfHilfe.js'
|
||||
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { auftragPdfDaten, paginiere, baueAtome, BEREICHE, SEITE, SPALTEN, INHALT_BREITE } = await import('../server/services/auftraege/pdf.js')
|
||||
|
||||
// Kleine Seiten: erste Seite 0–200 pt, Folgeseiten 20–200 pt
|
||||
const SEITEN = { erste: { oben: 0, unten: 200 }, folge: { oben: 20, unten: 200 } }
|
||||
const baustein = (id, hoehe, extra = {}) => ({ id, typ: 'absatz', hoehe, abstandDavor: 0, haengtAnNaechstem: false, umbruchDavor: false, kopf: null, ...extra })
|
||||
const lage = (seiten) => seiten.map((s) => s.posten.map((p) => `${p.atom.id}${p.wiederholt ? '*' : ''}@${p.y}`))
|
||||
|
||||
test('Maße: A4, Inhaltsbreite und Spaltenbreiten passen zusammen', () => {
|
||||
assert.equal(SEITE.breite, 595.28)
|
||||
assert.equal(SEITE.hoehe, 841.89)
|
||||
assert.ok(Math.abs(INHALT_BREITE - 481.88) < 1e-9)
|
||||
assert.ok(Math.abs(SPALTEN.reduce((a, b) => a + b, 0) - INHALT_BREITE) < 1e-9)
|
||||
assert.ok(BEREICHE.erste.oben < BEREICHE.folge.oben && BEREICHE.erste.unten === BEREICHE.folge.unten)
|
||||
assert.ok(BEREICHE.erste.unten < SEITE.hoehe - 80, 'Platz für den Fuß bleibt frei')
|
||||
})
|
||||
|
||||
test('paginiere: alles passt auf eine Seite, Abstände nur zwischen Bausteinen', () => {
|
||||
const seiten = paginiere([baustein('a', 30, { abstandDavor: 9 }), baustein('b', 20, { abstandDavor: 8 }), baustein('c', 10, { abstandDavor: 8 })], SEITEN)
|
||||
assert.deepEqual(lage(seiten), [['a@0', 'b@38', 'c@66']])
|
||||
assert.deepEqual(paginiere([], SEITEN).map((s) => s.posten), [[]], 'auch ohne Inhalt gibt es eine Seite')
|
||||
})
|
||||
|
||||
test('paginiere: was nicht mehr passt, beginnt die nächste Seite ohne Abstand davor', () => {
|
||||
const seiten = paginiere([baustein('a', 170), baustein('b', 40, { abstandDavor: 5 }), baustein('c', 40, { abstandDavor: 5 })], SEITEN)
|
||||
assert.deepEqual(lage(seiten), [['a@0'], ['b@20', 'c@65']])
|
||||
assert.deepEqual(seiten.map((s) => s.nummer), [1, 2])
|
||||
})
|
||||
|
||||
test('paginiere: Überschrift (haengtAnNaechstem) bleibt bei ihrem Absatz', () => {
|
||||
const seiten = paginiere([baustein('a', 150), baustein('kopf', 15, { abstandDavor: 5, haengtAnNaechstem: true }), baustein('text', 40)], SEITEN)
|
||||
assert.deepEqual(lage(seiten), [['a@0'], ['kopf@20', 'text@35']])
|
||||
})
|
||||
|
||||
test('paginiere: umbruchDavor beginnt eine neue Seite, aber nie eine leere', () => {
|
||||
assert.deepEqual(lage(paginiere([baustein('a', 30), baustein('b', 30, { umbruchDavor: true })], SEITEN)), [['a@0'], ['b@20']])
|
||||
assert.deepEqual(lage(paginiere([baustein('a', 30, { umbruchDavor: true })], SEITEN)), [['a@0']])
|
||||
assert.deepEqual(lage(paginiere([baustein('a', 200), baustein('b', 30, { umbruchDavor: true })], SEITEN)), [['a@0'], ['b@20']])
|
||||
})
|
||||
|
||||
const tabelle = (zeilen, extra = {}) => {
|
||||
const kopf = baustein('kopf', 20, { typ: 'tabellenkopf', haengtAnNaechstem: true })
|
||||
const rows = Array.from({ length: zeilen }, (_, i) => baustein(`z${i + 1}`, 30, { typ: 'zeile', kopf, haengtAnNaechstem: i === zeilen - 1 && extra.mitSumme }))
|
||||
return [kopf, ...rows, ...(extra.mitSumme ? [baustein('summe', 30, { haengtAnNaechstem: true }), baustein('hinweis', 10)] : [])]
|
||||
}
|
||||
|
||||
test('paginiere: lange Tabelle läuft über mehrere Seiten, der Kopf wiederholt sich, keine Zeile wird geteilt', () => {
|
||||
const seiten = paginiere(tabelle(20), SEITEN)
|
||||
assert.deepEqual(lage(seiten).map((s) => s.length), [7, 6, 6, 5], 'Seite 1: Kopf + 6 Zeilen; Folgeseiten: wiederholter Kopf + je 5 Zeilen, Rest auf der letzten')
|
||||
assert.equal(seiten.length, 4)
|
||||
for (const seite of seiten.slice(1)) {
|
||||
assert.equal(seite.posten[0].atom.id, 'kopf')
|
||||
assert.equal(seite.posten[0].wiederholt, true)
|
||||
assert.equal(seite.posten[0].y, 20)
|
||||
}
|
||||
const zeilen = seiten.flatMap((s) => s.posten.filter((p) => !p.wiederholt).map((p) => p.atom.id))
|
||||
assert.deepEqual(zeilen, ['kopf', ...Array.from({ length: 20 }, (_, i) => `z${i + 1}`)])
|
||||
for (const seite of seiten) for (const p of seite.posten) assert.ok(p.y + p.atom.hoehe <= seite.unten, `${p.atom.id} ragt über die Seite`)
|
||||
})
|
||||
|
||||
test('paginiere: letzte Zeile, Summe und Hinweis bleiben zusammen, auch wenn die Zeile allein noch gepasst hätte', () => {
|
||||
// Seite 1: Kopf (20) + 6 Zeilen (180) = 200 – Zeile 6 ist die letzte und hängt an Summe und Hinweis
|
||||
const seiten = paginiere(tabelle(6, { mitSumme: true }), SEITEN)
|
||||
assert.deepEqual(lage(seiten), [['kopf@0', 'z1@20', 'z2@50', 'z3@80', 'z4@110', 'z5@140'], ['kopf*@20', 'z6@40', 'summe@70', 'hinweis@100']])
|
||||
})
|
||||
|
||||
test('paginiere: Kette größer als eine Seite wird zwischen Bausteinen getrennt, zu hoher Baustein steht allein', () => {
|
||||
const kette = [baustein('a', 120, { haengtAnNaechstem: true }), baustein('b', 120, { haengtAnNaechstem: true }), baustein('c', 120)]
|
||||
assert.deepEqual(lage(paginiere(kette, SEITEN)), [['a@0'], ['b@20'], ['c@20']])
|
||||
assert.deepEqual(lage(paginiere([baustein('a', 50), baustein('riesig', 500), baustein('b', 50)], SEITEN)), [['a@0'], ['riesig@20'], ['b@20']])
|
||||
})
|
||||
|
||||
// Messfunktion ohne pdfkit: 10 pt je angefangene 60 Zeichen
|
||||
const messe = (text) => 10 * Math.max(1, Math.ceil(String(text).length / 60))
|
||||
const bausteine = (extra = {}, optionen = {}) => baueAtome(auftragPdfDaten(beispielAuftrag(extra), { settings: beispielSettings, ...optionen }), messe)
|
||||
const ids = (atome) => atome.map((a) => a.id)
|
||||
|
||||
test('baueAtome Festpreis: Reihenfolge, Zeilenhöhen und Zusammenhalt der Tabelle', () => {
|
||||
const atome = bausteine()
|
||||
assert.deepEqual(ids(atome), [
|
||||
'briefkopf', 'einleitung', 'leistung', 'tabellenkopf', 'zeile-1', 'zeile-2', 'summe', 'kleinunternehmer',
|
||||
'zahlung-kopf', 'zahlung', 'termine-kopf', 'termine', 'mitwirkung-kopf', 'mitwirkung', 'agb', 'gruss',
|
||||
])
|
||||
const nach = (id) => atome.find((a) => a.id === id)
|
||||
assert.equal(nach('briefkopf').typ, 'briefkopf')
|
||||
assert.ok(nach('briefkopf').hoehe > 150)
|
||||
assert.equal(nach('briefkopf').masse.infoZeilen.length, 3)
|
||||
assert.equal(nach('zeile-1').hoehe, 10 + 12, 'eine Zeile Text plus 2 × 6 pt Rand')
|
||||
assert.deepEqual([nach('leistung'), nach('tabellenkopf'), nach('zeile-1'), nach('zeile-2'), nach('summe'), nach('kleinunternehmer')].map((a) => a.haengtAnNaechstem), [true, true, false, true, true, false])
|
||||
assert.equal(nach('zeile-1').kopf, nach('tabellenkopf'))
|
||||
assert.equal(nach('zeile-2').kopf, nach('tabellenkopf'))
|
||||
assert.equal(nach('summe').label, 'Gesamtpreis')
|
||||
assert.ok(atome.every((a) => a.hoehe > 0 && Number.isFinite(a.hoehe)))
|
||||
})
|
||||
|
||||
test('baueAtome Aufwand, ersetzt und Privatperson: nur die passenden Bausteine', () => {
|
||||
const aufwand = bausteine({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' } })
|
||||
assert.ok(ids(aufwand).includes('aufwand') && !ids(aufwand).includes('tabellenkopf') && !ids(aufwand).includes('summe'))
|
||||
assert.equal(aufwand.find((a) => a.id === 'aufwand').kasten, true)
|
||||
const mitErsetzt = bausteine({}, { ersetzt: { nummer: 'AB-2026-0003', datum: '2026-10-01' } })
|
||||
assert.ok(ids(mitErsetzt).indexOf('ersetzt') > ids(mitErsetzt).indexOf('agb') && ids(mitErsetzt).indexOf('ersetzt') < ids(mitErsetzt).indexOf('gruss'))
|
||||
assert.equal(ids(bausteine()).some((id) => /^(widerruf|formular)/.test(id)), false)
|
||||
const privat = bausteine({ kunde: { ...beispielAuftrag().kunde, kundenart: 'privat' } })
|
||||
const titel = privat.find((a) => a.id === 'widerruf-titel')
|
||||
assert.equal(titel.umbruchDavor, true)
|
||||
assert.ok(ids(privat).indexOf('formular-titel') > ids(privat).indexOf('widerruf-titel'))
|
||||
assert.equal(privat.find((a) => a.id === 'formular-titel').haengtAnNaechstem, true)
|
||||
assert.equal(privat.at(-1).haengtAnNaechstem, false)
|
||||
})
|
||||
|
||||
test('20 Positionen mit je 200 Zeichen ohne Leerzeichen: alle Zeilen da, mehrere Seiten, Tabellenkopf auf jeder Folgeseite', () => {
|
||||
const positionen = Array.from({ length: 20 }, (_, i) => ({ beschreibung: 'X'.repeat(200), menge: i + 1, einzelpreisCents: 1000 }))
|
||||
const summe = positionen.reduce((s, p) => s + p.menge * p.einzelpreisCents, 0)
|
||||
const seiten = paginiere(bausteine({ positionen, summeCents: summe }), BEREICHE)
|
||||
assert.ok(seiten.length >= 2)
|
||||
const zeilen = seiten.flatMap((s) => s.posten.filter((p) => p.atom.typ === 'zeile').map((p) => p.atom.id))
|
||||
assert.deepEqual(zeilen, Array.from({ length: 20 }, (_, i) => `zeile-${i + 1}`))
|
||||
for (const seite of seiten.slice(1)) {
|
||||
const ersteZeile = seite.posten.findIndex((p) => p.atom.typ === 'zeile')
|
||||
if (ersteZeile >= 0) assert.equal(seite.posten[0].atom.typ, 'tabellenkopf', `Seite ${seite.nummer} beginnt mit dem Tabellenkopf`)
|
||||
}
|
||||
const letzte = seiten.at(-1).posten.map((p) => p.atom.id)
|
||||
assert.ok(letzte.indexOf('summe') > letzte.indexOf('zeile-20'), 'Summe steht bei der letzten Zeile')
|
||||
for (const seite of seiten) for (const p of seite.posten) assert.ok(p.y + p.atom.hoehe <= seite.unten + 1e-9)
|
||||
})
|
||||
|
||||
test('lange Widerrufsbelehrung: beginnt auf einer neuen Seite, das Formular bleibt zusammen', () => {
|
||||
const absatz = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(12)
|
||||
const d = auftragPdfDaten(beispielAuftrag({ kunde: { ...beispielAuftrag().kunde, kundenart: 'privat' } }), { settings: beispielSettings })
|
||||
d.widerruf = { ...d.widerruf, belehrung: { titel: 'Widerrufsbelehrung', abschnitte: Array.from({ length: 6 }, (_, i) => ({ ueberschrift: `Abschnitt ${i + 1}`, absaetze: [absatz, absatz] })) } }
|
||||
const seiten = paginiere(baueAtome(d, messe), BEREICHE)
|
||||
const seiteVon = (id) => seiten.find((s) => s.posten.some((p) => p.atom.id === id)).nummer
|
||||
assert.ok(seiten.length >= 4)
|
||||
assert.ok(seiteVon('widerruf-titel') >= 2)
|
||||
assert.equal(seiten[seiteVon('widerruf-titel') - 1].posten.find((p) => !p.wiederholt).atom.id, 'widerruf-titel', 'Belehrung beginnt oben auf einer neuen Seite')
|
||||
const formular = seiten.flatMap((s) => s.posten.filter((p) => p.atom.id.startsWith('formular')).map(() => s.nummer))
|
||||
assert.ok(formular.length >= 2 && new Set(formular).size === 1, 'alle Zeilen des Formulars stehen auf derselben Seite')
|
||||
for (const seite of seiten) for (const p of seite.posten) assert.ok(p.y + p.atom.hoehe <= seite.unten + 1e-9)
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import PDFDocument from 'pdfkit'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { leseSeiten, seitenZahl } from './auftraegePdfHilfe.js'
|
||||
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { ladeSchriften } = await import('../server/services/auftraege/pdf.js')
|
||||
|
||||
// Prüft die Testhilfe selbst an einem kleinen, direkt mit pdfkit gebauten PDF (unabhängig vom AB-Code)
|
||||
function kleinesPdf(komprimieren) {
|
||||
const { regular, semibold } = ladeSchriften()
|
||||
const doc = new PDFDocument({ size: 'A4', compress: komprimieren, info: { CreationDate: new Date('2026-10-06T10:00:00.000Z') } })
|
||||
doc.registerFont('Figtree', regular)
|
||||
doc.registerFont('Figtree-SemiBold', semibold)
|
||||
const teile = []
|
||||
doc.on('data', (teil) => teile.push(teil))
|
||||
const fertig = new Promise((resolve) => doc.on('end', () => resolve(Buffer.concat(teile))))
|
||||
doc.font('Figtree').fontSize(12).text(`Müller & Söhne – ß € ${String.fromCodePoint(0x2212)}5`, 50, 50, { lineBreak: false })
|
||||
doc.font('Figtree-SemiBold').fontSize(12).text('Zweite Zeile „fett“', 50, 80, { lineBreak: false })
|
||||
doc.addPage().font('Figtree').fontSize(12).text('Seite zwei', 50, 50, { lineBreak: false })
|
||||
doc.end()
|
||||
return fertig
|
||||
}
|
||||
|
||||
test('leseSeiten und seitenZahl: Text je Seite aus einem pdfkit-PDF, komprimiert und unkomprimiert', async () => {
|
||||
for (const komprimieren of [true, false]) {
|
||||
const pdf = await kleinesPdf(komprimieren)
|
||||
assert.equal(seitenZahl(pdf), 2)
|
||||
assert.deepEqual(leseSeiten(pdf), [[`Müller & Söhne – ß € ${String.fromCodePoint(0x2212)}5`, 'Zweite Zeile „fett“'], ['Seite zwei']])
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
|
||||
const FONTS = new URL('../server/assets/fonts/', import.meta.url)
|
||||
const sha256 = (datei) => createHash('sha256').update(readFileSync(new URL(datei, FONTS))).digest('hex')
|
||||
const cp = (...codes) => String.fromCodePoint(...codes)
|
||||
const NBSP = cp(0xa0)
|
||||
|
||||
test('Schriftdateien: Figtree Regular und SemiBold samt OFL-Lizenz und Quellenangabe liegen unverändert vor', () => {
|
||||
assert.deepEqual(readdirSync(FONTS).sort(), ['Figtree-OFL.txt', 'Figtree-Regular.ttf', 'Figtree-SemiBold.ttf', 'QUELLE.txt'])
|
||||
assert.equal(sha256('Figtree-Regular.ttf'), '4e2e9876c4a908c2cbe410ceb648e4724464a84492bd49cb2d6e7b0d3f769a66')
|
||||
assert.equal(sha256('Figtree-SemiBold.ttf'), '1b76aae525b51758fc0c9c5096f33d90831657d3ad2fe3c34fb7e666d76f8960')
|
||||
assert.equal(sha256('Figtree-OFL.txt'), '140d37233e7f3ce7313798befa9600893bcceaf41a55fa0fa5ad52f7f657a268')
|
||||
assert.match(readFileSync(new URL('Figtree-OFL.txt', FONTS), 'utf8'), /^Copyright 2022 The Figtree Project Authors/)
|
||||
assert.match(readFileSync(new URL('QUELLE.txt', FONTS), 'utf8'), /@expo-google-fonts\/figtree@0\.4\.1/)
|
||||
})
|
||||
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { ladeSchriften, hatGlyph, bereinigeText, bereinigeTexte } = await import('../server/services/auftraege/pdf.js')
|
||||
|
||||
test('ladeSchriften: Dateien werden einmal je Prozess gelesen, jeder Aufruf liefert dasselbe Objekt', () => {
|
||||
const schriften = ladeSchriften()
|
||||
assert.equal(ladeSchriften(), schriften)
|
||||
assert.equal(schriften.regularFont.postscriptName, 'Figtree-Regular')
|
||||
assert.equal(schriften.semiboldFont.postscriptName, 'Figtree-SemiBold')
|
||||
assert.equal(schriften.regular.length, 40140)
|
||||
assert.equal(schriften.logo.subarray(1, 4).toString('latin1'), 'PNG')
|
||||
})
|
||||
|
||||
test('hatGlyph: deutsche Buchstaben, €, Minuszeichen, geschütztes Leerzeichen und typografische Zeichen sind in beiden Schnitten da', () => {
|
||||
const noetig = [
|
||||
...'äöüÄÖÜ߀§%/&()@:;,.!?+*#"\'0123456789 ',
|
||||
NBSP, cp(0x2212), cp(0x2013), cp(0x2014), cp(0x201e), cp(0x201c), cp(0x201d), cp(0x2018), cp(0x2019), cp(0x2026), cp(0x2022), cp(0xb7), cp(0xd7),
|
||||
'ł', 'ğ', 'Ş', 'ő', 'ř', 'ı',
|
||||
]
|
||||
for (const zeichen of noetig) assert.ok(hatGlyph(zeichen.codePointAt(0)), `Glyph fehlt: U+${zeichen.codePointAt(0).toString(16)}`)
|
||||
for (const code of [0x202f, 0x2009, 0x200b, 0x416, 0x1f600, 0x3042]) assert.equal(hatGlyph(code), false, `U+${code.toString(16)} sollte fehlen`)
|
||||
})
|
||||
test('bereinigeText: vorhandene Zeichen bleiben unverändert, getrennte Akzente werden zusammengesetzt', () => {
|
||||
const text = `Müller & Söhne – „Größe“ 1.234,56${NBSP}€ ${cp(0x2212)} 20,00${NBSP}€ ÄÖÜ äöü ß … Łukasz Yıldız Çelik`
|
||||
assert.equal(bereinigeText(text), text)
|
||||
assert.equal(bereinigeText('a' + cp(0x301)), 'á'.normalize('NFC'))
|
||||
assert.equal(bereinigeText(null), '')
|
||||
assert.equal(bereinigeText(undefined), '')
|
||||
assert.equal(bereinigeText(42), '42')
|
||||
})
|
||||
|
||||
test('bereinigeText: Steuerzeichen, Zero-Width, weiche Trennung und BOM entfallen; Zeilenenden werden Zeilenumbrüche, Tab wird Leerzeichen', () => {
|
||||
const roh = `a${cp(0x200b)}b${cp(0xad)}c${cp(0xfeff)}d${cp(0)}e\tf\r\ng\rh${cp(0x2028)}i${cp(0x200d, 0xfe0f)}j`
|
||||
assert.equal(bereinigeText(roh), 'abcde f\ng\nh\nij')
|
||||
})
|
||||
|
||||
test('bereinigeText mit Figtree: schmale Leerzeichen werden Leerzeichen, kyrillische und japanische Zeichen sowie Emoji je ein Fragezeichen', () => {
|
||||
assert.equal(bereinigeText(`a${cp(0x202f)}b${cp(0x2009)}c`), 'a b c')
|
||||
assert.equal(bereinigeText(`${cp(0x416)}${cp(0x3042)}${cp(0x1f600)}`), '???')
|
||||
})
|
||||
|
||||
test('bereinigeText: Ersatzregeln bei fehlendem Glyph (Schrift ohne Zeichen über ASCII)', () => {
|
||||
const nurAscii = (code) => code < 0x80
|
||||
const fall = (eingabe) => bereinigeText(eingabe, nurAscii)
|
||||
assert.equal(fall('9,00' + NBSP + '€'), '9,00 EUR')
|
||||
assert.equal(fall(`${cp(0x201e)}x${cp(0x201c)} ${cp(0x201a)}y${cp(0x2019)}`), `"x" 'y'`)
|
||||
assert.equal(fall(`1 ${cp(0x2013)} 2 ${cp(0x2212)} 3 ${cp(0x2014)} 4`), '1 - 2 - 3 - 4')
|
||||
assert.equal(fall(`wait${cp(0x2026)} 3${cp(0xd7)}4 a${cp(0x2192)}b`), 'wait... 3x4 a->b')
|
||||
assert.equal(fall('Müller Çelik Ångström'), 'Muller Celik Angstrom')
|
||||
assert.equal(fall('Łukasz Ğ'), '?ukasz G')
|
||||
assert.equal(fall('e' + cp(0x301) + cp(0x301)), 'e')
|
||||
assert.equal(fall(cp(0x301)), '')
|
||||
})
|
||||
|
||||
test('bereinigeTexte: bearbeitet jede Zeichenkette in verschachtelten Objekten und Listen, andere Werte bleiben', () => {
|
||||
const roh = { a: ['x' + cp(0x200b) + 'y', { b: 'ż' + cp(0x1f600) }], n: 5, f: null, t: true }
|
||||
assert.deepEqual(bereinigeTexte(roh), { a: ['xy', { b: 'ż?' }], n: 5, f: null, t: true })
|
||||
})
|
||||
@@ -0,0 +1,303 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
stundenText, leistungszeitraumText, kalenderwoche, gueltigerTag, kuerzen,
|
||||
festePositionen, zusatzPositionen, pruefePositionen, aufwandPositionen,
|
||||
auftragDatum, anzahlungPosition, schlussPositionen, SCHLUSS_GRENZEN,
|
||||
} from '../server/services/auftraege/positionen.js'
|
||||
|
||||
/** Wirft die Funktion einen AuftragError mit diesem Status und dieser Meldung? */
|
||||
const wirft = (fn, status, message) =>
|
||||
assert.throws(fn, (err) => {
|
||||
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
|
||||
assert.equal(err.message, message)
|
||||
return true
|
||||
})
|
||||
const ANZAHL = 'Eine Rechnung braucht 1 bis 20 Positionen.'
|
||||
const ok = { description: 'Domain', quantity: 1, unitAmountCents: 1200 }
|
||||
|
||||
test('stundenText: Minuten als Stunden mit Komma, ohne überflüssige Nullen', () => {
|
||||
const faelle = [[0, '0'], [15, '0,25'], [30, '0,5'], [45, '0,75'], [60, '1'], [90, '1,5'], [135, '2,25'], [390, '6,5'], [600, '10']]
|
||||
for (const [minuten, text] of faelle) assert.equal(stundenText(minuten), text, `${minuten} Minuten`)
|
||||
})
|
||||
|
||||
test('leistungszeitraumText: ein Tag, ein Jahr, Jahreswechsel (Halbgeviertstrich U+2013 ohne Leerzeichen)', () => {
|
||||
assert.equal(leistungszeitraumText('2026-10-06', '2026-10-06'), '06.10.2026')
|
||||
assert.equal(leistungszeitraumText('2026-10-01', '2026-10-20'), '01.10.–20.10.2026')
|
||||
assert.equal(leistungszeitraumText('2026-09-28', '2026-10-05'), '28.09.–05.10.2026')
|
||||
assert.equal(leistungszeitraumText('2026-12-28', '2027-01-05'), '28.12.2026–05.01.2027')
|
||||
})
|
||||
|
||||
test('kalenderwoche: ISO-Woche samt ISO-Jahr, gueltigerTag: nur Tage, die es gibt', () => {
|
||||
const faelle = [
|
||||
['2026-10-05', 2026, 41], ['2026-10-06', 2026, 41], ['2026-10-11', 2026, 41], ['2026-10-12', 2026, 42],
|
||||
['2025-12-29', 2026, 1], ['2026-01-01', 2026, 1], ['2026-12-31', 2026, 53], ['2027-01-03', 2026, 53],
|
||||
['2027-01-04', 2027, 1], ['2024-12-30', 2025, 1], ['2021-01-03', 2020, 53],
|
||||
]
|
||||
for (const [tag, jahr, woche] of faelle) assert.deepEqual(kalenderwoche(tag), { jahr, woche }, tag)
|
||||
assert.throws(() => kalenderwoche('2026-02-30'), RangeError)
|
||||
assert.equal(gueltigerTag('2026-02-28'), true)
|
||||
for (const tag of ['2026-02-30', '2026-13-01', '06.10.2026', '2026-1-1', '', undefined, 20261006]) assert.equal(gueltigerTag(tag), false, String(tag))
|
||||
})
|
||||
|
||||
test('kuerzen: höchstens max Zeichen, Auslassungszeichen am Ende, kein halbes Emoji', () => {
|
||||
assert.equal(kuerzen('kurz', 10), 'kurz')
|
||||
assert.equal(kuerzen('abcdefghij', 10), 'abcdefghij')
|
||||
assert.equal(kuerzen('abcdefghijk', 10), 'abcdefghi…')
|
||||
assert.equal(kuerzen('ab cdefghijk', 4), 'ab…')
|
||||
assert.equal(kuerzen('abcd😀fghij', 6), 'abcd…')
|
||||
})
|
||||
|
||||
test('festePositionen: Eingabe wie bisher, Beschreibung getrimmt, Schlüssel pos-n, Betrag = Menge × Einzelpreis', () => {
|
||||
assert.deepEqual(festePositionen([{ description: ' Website ', quantity: 2, unitAmountCents: 5000 }, ok]), [
|
||||
{ schluessel: 'pos-1', beschreibung: 'Website', menge: 2, einzelpreisCents: 5000, betragCents: 10000 },
|
||||
{ schluessel: 'pos-2', beschreibung: 'Domain', menge: 1, einzelpreisCents: 1200, betragCents: 1200 },
|
||||
])
|
||||
assert.equal(festePositionen(Array.from({ length: 20 }, () => ok)).length, 20)
|
||||
})
|
||||
|
||||
test('festePositionen: Grenzen und Meldungen wie bei der bisherigen Rechnung', () => {
|
||||
wirft(() => festePositionen([]), 400, ANZAHL)
|
||||
wirft(() => festePositionen(undefined), 400, ANZAHL)
|
||||
wirft(() => festePositionen(Array.from({ length: 21 }, () => ok)), 400, ANZAHL)
|
||||
for (const description of ['', ' ', 'x'.repeat(201), 5]) wirft(() => festePositionen([{ ...ok, description }]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
|
||||
for (const quantity of [0, 1000, 1.5, '2', -1, null]) wirft(() => festePositionen([ok, { ...ok, quantity }]), 400, 'Position 2: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
|
||||
for (const unitAmountCents of [0, 10_000_001, 12.5, '5000', -100, null]) {
|
||||
wirft(() => festePositionen([{ ...ok, unitAmountCents }]), 400, 'Position 1: Der Einzelpreis muss zwischen 0,01 € und 100.000,00 € liegen.')
|
||||
}
|
||||
const neun = Array.from({ length: 9 }, (_, i) => ({ description: `Teil ${i + 1}`, quantity: 1, unitAmountCents: 10_000_000 }))
|
||||
wirft(() => festePositionen([...neun, { description: 'Rest', quantity: 1, unitAmountCents: 10_000_000 }]), 400, 'Rechnungssumme zu hoch.')
|
||||
assert.equal(festePositionen([...neun, { description: 'Rest', quantity: 1, unitAmountCents: 9_999_999 }]).at(-1).betragCents, 9_999_999)
|
||||
})
|
||||
|
||||
test('zusatzPositionen: leer erlaubt, Schlüssel zusatz-n, gleiche Prüfungen; pruefePositionen: Zahl (mit wählbarer Obergrenze), Texte, Summe mit Minuszeilen', () => {
|
||||
for (const leer of [undefined, null, []]) assert.deepEqual(zusatzPositionen(leer), [])
|
||||
assert.deepEqual(zusatzPositionen([{ description: 'Domain', quantity: 2, unitAmountCents: 1500 }]), [
|
||||
{ schluessel: 'zusatz-1', beschreibung: 'Domain', menge: 2, einzelpreisCents: 1500, betragCents: 3000 },
|
||||
])
|
||||
wirft(() => zusatzPositionen('Domain'), 400, ANZAHL)
|
||||
wirft(() => zusatzPositionen(Array.from({ length: 21 }, () => ok)), 400, ANZAHL)
|
||||
wirft(() => zusatzPositionen([{ ...ok, description: '' }]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
|
||||
|
||||
const pos = (beschreibung, cents) => ({ schluessel: 'x', beschreibung, menge: 1, einzelpreisCents: cents, betragCents: cents })
|
||||
assert.equal(pruefePositionen([pos('A', 5000), pos('abzüglich Anzahlung', -2000)]), 3000)
|
||||
assert.equal(pruefePositionen([pos('A', 99_999_999)]), 99_999_999)
|
||||
wirft(() => pruefePositionen([]), 400, ANZAHL)
|
||||
wirft(() => pruefePositionen(Array.from({ length: 21 }, () => pos('A', 1))), 400, ANZAHL)
|
||||
// eine Schlussrechnung darf bis 40 Zeilen haben: der Aufrufer hebt die Obergrenze an
|
||||
assert.equal(pruefePositionen(Array.from({ length: 40 }, () => pos('A', 1)), { max: 40 }), 40)
|
||||
wirft(() => pruefePositionen(Array.from({ length: 41 }, () => pos('A', 1)), { max: 40 }), 400, 'Eine Rechnung braucht 1 bis 40 Positionen.')
|
||||
wirft(() => pruefePositionen([], { max: 40 }), 400, 'Eine Rechnung braucht 1 bis 40 Positionen.')
|
||||
wirft(() => pruefePositionen([pos('A', 1), pos('', 1)]), 400, 'Position 2: Die Beschreibung braucht 1 bis 200 Zeichen.')
|
||||
wirft(() => pruefePositionen([pos('x'.repeat(201), 1)]), 400, 'Position 1: Die Beschreibung braucht 1 bis 200 Zeichen.')
|
||||
wirft(() => pruefePositionen([pos('A', 99_999_999), pos('B', 1)]), 400, 'Rechnungssumme zu hoch.')
|
||||
})
|
||||
|
||||
const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt
|
||||
const blatt = (id, datum, minuten, text = '', over = {}) => ({ id, ticketId: 't1', woid: 'WO-1', datum, minuten, text, aboZeit: false, kommentar: false, ...over })
|
||||
const werktage = (start, n) => {
|
||||
const tage = []
|
||||
for (let ms = Date.parse(`${start}T00:00:00Z`); tage.length < n; ms += 86_400_000) {
|
||||
const tag = new Date(ms)
|
||||
if (tag.getUTCDay() % 6 !== 0) tage.push(tag.toISOString().slice(0, 10))
|
||||
}
|
||||
return tage
|
||||
}
|
||||
|
||||
test('aufwandPositionen: eine Zeile je Tag mit Texten, Stunden und Satz (Menge 1, Betrag = Stunden × Satz)', () => {
|
||||
const erg = aufwandPositionen([blatt('w1', '2026-10-06', 90, 'Startseite, Kontaktformular'), blatt('w2', '2026-10-06', 45, '')], { satzCents: 10000 })
|
||||
assert.deepEqual(erg.positionen, [
|
||||
{ schluessel: '2026-10-06', beschreibung: `06.10.2026: Startseite, Kontaktformular – 2,25 Std. à 100,00${NBSP}€`, menge: 1, einzelpreisCents: 22500, betragCents: 22500 },
|
||||
])
|
||||
assert.deepEqual([erg.minuten, erg.von, erg.bis], [135, '2026-10-06', '2026-10-06'])
|
||||
})
|
||||
|
||||
test('aufwandPositionen: Texte eines Tages mit "; " verbunden, Doppelte entfallen, Leerraum vereinheitlicht, ohne Text "Arbeitszeit"', () => {
|
||||
const erg = aufwandPositionen([
|
||||
blatt('w1', '2026-10-06', 60, 'Startseite'), blatt('w2', '2026-10-06', 60, 'Kontaktformular\nmit Anhang'), blatt('w3', '2026-10-06', 15, ' Startseite '),
|
||||
blatt('w4', '2026-10-07', 30, ''),
|
||||
], { satzCents: 10000 })
|
||||
assert.deepEqual(erg.positionen.map((p) => p.beschreibung), [
|
||||
`06.10.2026: Startseite; Kontaktformular mit Anhang – 2,25 Std. à 100,00${NBSP}€`,
|
||||
`07.10.2026: Arbeitszeit – 0,5 Std. à 100,00${NBSP}€`,
|
||||
])
|
||||
assert.deepEqual(erg.positionen.map((p) => p.betragCents), [22500, 5000])
|
||||
})
|
||||
|
||||
test('aufwandPositionen: Reihenfolge der Eingabe egal (nach Tag und Kennung), von und bis, Satz ohne glatte Cent je Viertelstunde', () => {
|
||||
const erg = aufwandPositionen([blatt('w3', '2026-10-08', 15, 'C'), blatt('w1', '2026-10-06', 15, 'A'), blatt('w2', '2026-10-07', 15, 'B')], { satzCents: 9999 })
|
||||
assert.deepEqual(erg.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07', '2026-10-08'])
|
||||
assert.deepEqual([erg.von, erg.bis, erg.minuten], ['2026-10-06', '2026-10-08', 45])
|
||||
assert.equal(erg.positionen[0].beschreibung, `06.10.2026: A – 0,25 Std. à 99,99${NBSP}€`)
|
||||
assert.equal(erg.positionen[0].betragCents, 2500) // 15 Minuten × 99,99 €/Std. = 24,9975 € → ganze Cent gerundet
|
||||
})
|
||||
|
||||
test('aufwandPositionen: zeilentexte ersetzen den Text vor dem Gedankenstrich; leer oder unbekannt wirkt nicht; Zeile bleibt bei höchstens 200 Zeichen', () => {
|
||||
const standard = `06.10.2026: Startseite – 2,25 Std. à 100,00${NBSP}€`
|
||||
const mit = (zeilentexte, text = 'Startseite') =>
|
||||
aufwandPositionen([blatt('w1', '2026-10-06', 135, text)], { satzCents: 10000, zeilentexte }).positionen[0].beschreibung
|
||||
assert.equal(mit({ '2026-10-06': '06.10.2026: Neue Startseite' }), `06.10.2026: Neue Startseite – 2,25 Std. à 100,00${NBSP}€`)
|
||||
for (const wirkungslos of [undefined, null, {}, { '2026-10-06': ' ' }, { '2026-10-07': 'anderer Tag' }, { '2026-10-06': 5 }]) assert.equal(mit(wirkungslos), standard)
|
||||
const schluss = ` – 2,25 Std. à 100,00${NBSP}€`
|
||||
const ersetzt = mit({ '2026-10-06': 'x'.repeat(300) })
|
||||
assert.equal(ersetzt.length, 200)
|
||||
assert.ok(ersetzt.endsWith(`x…${schluss}`))
|
||||
const lang = mit(undefined, 'y'.repeat(300))
|
||||
assert.equal(lang.length, 200)
|
||||
assert.ok(lang.startsWith('06.10.2026: yyy'))
|
||||
assert.ok(lang.endsWith(`y…${schluss}`))
|
||||
})
|
||||
|
||||
test('aufwandPositionen: zu viele Tage -> die ältesten Wochen werden zu KW-Zeilen (ISO-Woche, älteste zuerst), Einzeltage bleiben Tageszeilen', () => {
|
||||
// KW 41: 5., 6., 7.10.; KW 42: 12., 13., 14.10.; KW 43: 19., 20.10.; je 90 Minuten
|
||||
const tage = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-12', '2026-10-13', '2026-10-14', '2026-10-19', '2026-10-20']
|
||||
const blaetter = tage.map((tag, i) => blatt(`w${i + 1}`, tag, 90, 'ABCDEFGH'[i]))
|
||||
const lauf = (maxZeilen) => aufwandPositionen(blaetter, { satzCents: 10000, maxZeilen })
|
||||
assert.equal(lauf(8).positionen.length, 8)
|
||||
assert.deepEqual(lauf(6).positionen.map((p) => p.schluessel), ['KW-2026-41', '2026-10-12', '2026-10-13', '2026-10-14', '2026-10-19', '2026-10-20'])
|
||||
assert.deepEqual(lauf(6).positionen[0], {
|
||||
schluessel: 'KW-2026-41', beschreibung: `KW 41/2026: A; B; C – 4,5 Std. à 100,00${NBSP}€`, menge: 1, einzelpreisCents: 45000, betragCents: 45000,
|
||||
})
|
||||
assert.deepEqual(lauf(5).positionen.map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', '2026-10-19', '2026-10-20'])
|
||||
assert.deepEqual(lauf(3).positionen.map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', 'KW-2026-43'])
|
||||
assert.equal(lauf(3).positionen[2].beschreibung, `KW 43/2026: G; H – 3 Std. à 100,00${NBSP}€`)
|
||||
for (const n of [8, 6, 5, 3]) assert.equal(lauf(n).positionen.reduce((s, p) => s + p.betragCents, 0), 8 * 15000, `Summe bei ${n} Zeilen`)
|
||||
assert.deepEqual([lauf(3).von, lauf(3).bis, lauf(3).minuten], ['2026-10-05', '2026-10-20', 720])
|
||||
wirft(() => lauf(2), 400, 'Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als 2 Zeilen). Bitte weniger Arbeitsblätter auswählen.')
|
||||
// eine Woche mit nur einem Tag wird nicht zusammengefasst (es spart keine Zeile)
|
||||
const einzel = [blatt('a', '2026-10-06', 60, 'A'), blatt('b', '2026-10-12', 60, 'B'), blatt('c', '2026-10-13', 60, 'C'), blatt('d', '2026-10-14', 60, 'D')]
|
||||
assert.deepEqual(aufwandPositionen(einzel, { satzCents: 10000, maxZeilen: 2 }).positionen.map((p) => p.schluessel), ['2026-10-06', 'KW-2026-42'])
|
||||
})
|
||||
|
||||
test('aufwandPositionen: Standard 20 Zeilen, zeilentexte auch für KW-Zeilen, Woche 53 über den Jahreswechsel, mehr als 20 Wochen -> 400', () => {
|
||||
const blaetter = werktage('2026-10-05', 25).map((tag, i) => blatt(`w${String(i + 1).padStart(2, '0')}`, tag, 60, `T${i + 1}`)) // KW 41 bis 45
|
||||
const erg = aufwandPositionen(blaetter, { satzCents: 10000, zeilentexte: { 'KW-2026-42': 'KW 42/2026: Relaunch Teil 2' } })
|
||||
assert.equal(erg.positionen.length, 17)
|
||||
assert.deepEqual(erg.positionen.slice(0, 3).map((p) => p.schluessel), ['KW-2026-41', 'KW-2026-42', '2026-10-19'])
|
||||
assert.equal(erg.positionen[1].beschreibung, `KW 42/2026: Relaunch Teil 2 – 5 Std. à 100,00${NBSP}€`)
|
||||
assert.equal(erg.minuten, 1500)
|
||||
|
||||
const wechsel = ['2026-12-30', '2026-12-31', '2027-01-01', '2027-01-04'].map((tag, i) => blatt(`j${i}`, tag, 60, `J${i}`))
|
||||
const zwei = aufwandPositionen(wechsel, { satzCents: 10000, maxZeilen: 2 })
|
||||
assert.deepEqual(zwei.positionen.map((p) => p.schluessel), ['KW-2026-53', '2027-01-04'])
|
||||
assert.equal(zwei.positionen[0].beschreibung, `KW 53/2026: J0; J1; J2 – 3 Std. à 100,00${NBSP}€`)
|
||||
|
||||
const montage = Array.from({ length: 21 }, (_, i) => new Date(Date.UTC(2026, 0, 5 + 7 * i)).toISOString().slice(0, 10))
|
||||
const wochen = montage.map((tag, i) => blatt(`m${i + 1}`, tag, 60, 'x'))
|
||||
wirft(() => aufwandPositionen(wochen, { satzCents: 10000 }), 400, 'Zu viele Arbeitstage für eine Rechnung (auch nach Kalenderwochen mehr als 20 Zeilen). Bitte weniger Arbeitsblätter auswählen.')
|
||||
assert.equal(aufwandPositionen(wochen.slice(0, 20), { satzCents: 10000 }).positionen.length, 20)
|
||||
})
|
||||
|
||||
test('aufwandPositionen: ungültiger Satz, leere Auswahl, kaputte Blätter', () => {
|
||||
const eins = [blatt('w1', '2026-10-06', 60, 'A')]
|
||||
for (const satzCents of [0, -1, 1.5, '10000', 10_000_001, undefined]) wirft(() => aufwandPositionen(eins, { satzCents }), 400, 'Der Stundensatz ist ungültig.')
|
||||
assert.deepEqual(aufwandPositionen([], { satzCents: 10000 }), { positionen: [], minuten: 0, von: null, bis: null })
|
||||
for (const kaputt of [{ datum: '06.10.2026' }, { datum: '2026-13-01' }, { minuten: 0 }, { minuten: 7.5 }, { minuten: '60' }]) {
|
||||
wirft(() => aufwandPositionen([blatt('w1', '2026-10-06', 60, 'A', kaputt)], { satzCents: 10000 }), 400, 'Ein Arbeitsblatt hat kein gültiges Datum oder keine Zeit.')
|
||||
}
|
||||
})
|
||||
|
||||
const AB = {
|
||||
id: 'a1', nummer: 'AB-2026-0002', titel: 'Website anelia-wolf.com', art: 'festpreis', status: 'versendet',
|
||||
gesendetAm: '2026-10-06T08:15:00.000+00:00', auftragVom: '2026-10-05', summeCents: 80000,
|
||||
positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 60000 }, { beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000 }],
|
||||
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' },
|
||||
}
|
||||
const ANZ_KOPF = 'gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (Website anelia-wolf.com)'
|
||||
|
||||
test('auftragDatum: Berliner Tag des Sendens, sonst der Tag der Absprache', () => {
|
||||
assert.equal(auftragDatum(AB), '2026-10-06')
|
||||
assert.equal(auftragDatum({ ...AB, gesendetAm: '2026-10-06T22:30:00.000+00:00' }), '2026-10-07')
|
||||
assert.equal(auftragDatum({ ...AB, gesendetAm: null }), '2026-10-05')
|
||||
})
|
||||
|
||||
test('anzahlungPosition: ohne Angaben der Prozentsatz aus dem Zahlungsplan (sonst 50 %), Text wie in der Spec', () => {
|
||||
assert.deepEqual(anzahlungPosition(AB, {}, 0), {
|
||||
schluessel: 'anzahlung', beschreibung: `Anzahlung 50 % ${ANZ_KOPF}`, menge: 1, einzelpreisCents: 40000, betragCents: 40000,
|
||||
})
|
||||
assert.equal(anzahlungPosition({ ...AB, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } }, {}, 0).betragCents, 40000)
|
||||
assert.equal(anzahlungPosition({ ...AB, zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' } }, undefined, 0).betragCents, 24000)
|
||||
})
|
||||
|
||||
test('anzahlungPosition: Prozent oder Betrag, Bezeichnung änderbar, Text je Variante, lange Titel gekürzt', () => {
|
||||
assert.equal(anzahlungPosition(AB, { prozent: 30 }, 0).betragCents, 24000)
|
||||
assert.equal(anzahlungPosition(AB, { prozent: 100 }, 0).betragCents, 80000)
|
||||
const teil = anzahlungPosition(AB, { prozent: 25, bezeichnung: ' Teilrechnung ' }, 0)
|
||||
assert.deepEqual([teil.beschreibung, teil.betragCents], [`Teilrechnung 25 % ${ANZ_KOPF}`, 20000])
|
||||
const betrag = anzahlungPosition(AB, { betragCents: 25000 }, 0)
|
||||
assert.deepEqual([betrag.beschreibung, betrag.betragCents], [`Anzahlung ${ANZ_KOPF}`, 25000])
|
||||
const komma = anzahlungPosition(AB, { prozent: 33.3 }, 0)
|
||||
assert.deepEqual([komma.beschreibung, komma.betragCents], [`Anzahlung 33,3 % ${ANZ_KOPF}`, 26640])
|
||||
const lang = anzahlungPosition({ ...AB, titel: 'T'.repeat(300) }, { prozent: 50 }, 0).beschreibung
|
||||
assert.equal(lang.length, 200)
|
||||
assert.ok(lang.startsWith('Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (TTT'))
|
||||
assert.ok(lang.endsWith('T…)'))
|
||||
assert.equal(anzahlungPosition({ ...AB, titel: '' }, { prozent: 50 }, 0).beschreibung, 'Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026')
|
||||
})
|
||||
|
||||
test('anzahlungPosition: höchstens Summe minus bereits Berechnetes, nichts mehr offen -> 409', () => {
|
||||
assert.equal(anzahlungPosition(AB, {}, 40000).betragCents, 40000)
|
||||
wirft(() => anzahlungPosition(AB, {}, 50000), 400, `Die Anzahlung darf höchstens 300,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
|
||||
wirft(() => anzahlungPosition(AB, { betragCents: 80001 }, 0), 400, `Die Anzahlung darf höchstens 800,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
|
||||
wirft(() => anzahlungPosition(AB, {}, 80000), 409, 'Nichts mehr offen.')
|
||||
wirft(() => anzahlungPosition(AB, {}, 90000), 409, 'Nichts mehr offen.')
|
||||
})
|
||||
|
||||
test('anzahlungPosition: ungültige Angaben', () => {
|
||||
wirft(() => anzahlungPosition(AB, { prozent: 30, betragCents: 100 }, 0), 400, 'Anzahlung entweder in Prozent oder als Betrag angeben.')
|
||||
for (const prozent of [0, -5, 100.5, Number.NaN, '50', Infinity]) wirft(() => anzahlungPosition(AB, { prozent }, 0), 400, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.')
|
||||
for (const betragCents of [0, -5, 12.5, '100', Number.NaN]) wirft(() => anzahlungPosition(AB, { betragCents }, 0), 400, 'Der Betrag muss eine ganze Zahl in Cent ab 1 sein.')
|
||||
wirft(() => anzahlungPosition(AB, { prozent: 0.0001 }, 0), 400, 'Der Betrag muss mindestens 0,01 € betragen.')
|
||||
for (const bezeichnung of ['', ' ', 'B'.repeat(61), 7]) wirft(() => anzahlungPosition(AB, { bezeichnung }, 0), 400, 'Die Bezeichnung braucht 1 bis 60 Zeichen.')
|
||||
})
|
||||
|
||||
const FRUEH7 = { invoiceId: 'in_7', rechnungsnummer: 'WEBKLAR-0007', datum: '2026-10-08', betragCents: 40000, art: 'anzahlung' }
|
||||
|
||||
test('schlussPositionen: alle AB-Positionen, Zusatz, dann je frühere Rechnung eine Minuszeile (negativer Betrag, Menge 1)', () => {
|
||||
assert.deepEqual(schlussPositionen(AB, [FRUEH7], [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1500 }]), [
|
||||
{ schluessel: 'ab-1', beschreibung: 'Website', menge: 1, einzelpreisCents: 60000, betragCents: 60000 },
|
||||
{ schluessel: 'ab-2', beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000, betragCents: 20000 },
|
||||
{ schluessel: 'zusatz-1', beschreibung: 'Domain-Registrierung', menge: 1, einzelpreisCents: 1500, betragCents: 1500 },
|
||||
{ schluessel: 'abzug-in_7', beschreibung: 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026', menge: 1, einzelpreisCents: -40000, betragCents: -40000 },
|
||||
])
|
||||
})
|
||||
|
||||
test('schlussPositionen: ohne frühere Rechnungen nur AB-Positionen; Teilrechnung für andere Arten; Abzüge nach Datum; Summe 0 ohne Zeile', () => {
|
||||
assert.deepEqual(schlussPositionen(AB, [], []).map((p) => p.schluessel), ['ab-1', 'ab-2'])
|
||||
assert.deepEqual(schlussPositionen(AB, []).map((p) => p.schluessel), ['ab-1', 'ab-2'])
|
||||
const spaeter = { invoiceId: 'in_9', rechnungsnummer: 'WEBKLAR-0009', datum: '2026-10-12', betragCents: 10000, art: 'festbetrag' }
|
||||
const ohneBetrag = { ...FRUEH7, invoiceId: 'in_0', betragCents: 0 }
|
||||
assert.deepEqual(schlussPositionen(AB, [spaeter, ohneBetrag, FRUEH7]).slice(2).map((p) => [p.schluessel, p.beschreibung, p.betragCents]), [
|
||||
['abzug-in_7', 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026', -40000],
|
||||
['abzug-in_9', 'abzüglich Teilrechnung, Rechnung WEBKLAR-0009 vom 12.10.2026', -10000],
|
||||
])
|
||||
})
|
||||
|
||||
test('schlussPositionen: Summe nie 0 oder darunter (409), nie über 99.999.999 (400)', () => {
|
||||
wirft(() => schlussPositionen(AB, [{ ...FRUEH7, betragCents: 80000 }]), 409, 'Nichts mehr offen.')
|
||||
wirft(() => schlussPositionen(AB, [{ ...FRUEH7, betragCents: 90000 }]), 409, 'Nichts mehr offen.')
|
||||
assert.equal(schlussPositionen(AB, [{ ...FRUEH7, betragCents: 79999 }]).reduce((s, p) => s + p.betragCents, 0), 1)
|
||||
const gross = { ...AB, positionen: Array.from({ length: 10 }, (_, i) => ({ beschreibung: `P${i}`, menge: 1, einzelpreisCents: 10_000_000 })) }
|
||||
wirft(() => schlussPositionen(gross, []), 400, 'Rechnungssumme zu hoch.')
|
||||
})
|
||||
|
||||
test('schlussPositionen: bis zu 40 Zeilen (20 AB-Positionen, 10 Nachträge, 10 Abzüge), jede Gruppe mit eigener Grenze und Meldung', () => {
|
||||
assert.deepEqual(SCHLUSS_GRENZEN, { abPositionen: 20, zusatz: 10, abzuege: 10, zeilen: 40 })
|
||||
const abMit = (n) => ({ ...AB, summeCents: n * 1000, positionen: Array.from({ length: n }, (_, i) => ({ beschreibung: `Pos ${i + 1}`, menge: 1, einzelpreisCents: 1000 })) })
|
||||
const abzug = (n) => ({ invoiceId: `in_k${String(n).padStart(2, '0')}`, rechnungsnummer: `WK-${String(n).padStart(4, '0')}`, datum: '2026-10-08', betragCents: 100, art: 'anzahlung' })
|
||||
const abzuege = (n) => Array.from({ length: n }, (_, i) => abzug(i + 1))
|
||||
const nachtraege = (n) => Array.from({ length: n }, (_, i) => ({ description: `Nachtrag ${i + 1}`, quantity: 1, unitAmountCents: 500 }))
|
||||
// 20 AB-Positionen und ein Abzug: gültig (21 Zeilen)
|
||||
assert.equal(schlussPositionen(abMit(20), abzuege(1)).length, 21)
|
||||
// volle Ausbaustufe: 20 + 10 + 10 = 40 Zeilen, Reihenfolge AB, Nachträge, Abzüge
|
||||
const voll = schlussPositionen(abMit(20), abzuege(10), nachtraege(10))
|
||||
assert.equal(voll.length, 40)
|
||||
assert.deepEqual([0, 19, 20, 29, 30, 39].map((i) => voll[i].schluessel), ['ab-1', 'ab-20', 'zusatz-1', 'zusatz-10', 'abzug-in_k01', 'abzug-in_k10'])
|
||||
assert.equal(voll.reduce((s, p) => s + p.betragCents, 0), 20000 + 5000 - 1000)
|
||||
// jede Gruppe über ihrer Grenze: 400 mit eigener Meldung
|
||||
wirft(() => schlussPositionen(abMit(20), abzuege(11)), 400, 'Die Schlussrechnung hätte 11 Abzüge für frühere Rechnungen; erlaubt sind höchstens 10.')
|
||||
wirft(() => schlussPositionen(abMit(20), [], nachtraege(11)), 400, 'Eine Schlussrechnung erlaubt höchstens 10 Zusatzpositionen.')
|
||||
wirft(() => schlussPositionen(abMit(21), []), 400, 'Die Auftragsbestätigung hat 21 Positionen; eine Schlussrechnung übernimmt höchstens 20.')
|
||||
// andere Eingaben für die Zusatzpositionen prüft zusatzPositionen wie sonst
|
||||
wirft(() => schlussPositionen(AB, [], 'Domain'), 400, ANZAHL)
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
// test/auftraegeProjektRechnungenStore.test.js
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
|
||||
|
||||
// Speicher der Projektrechnungen (Dokument-ID = Stripe-Rechnungs-ID). Nachbau von Appwrite mit Schema und Standard-Limit 25.
|
||||
const aw = await startAppwriteMock({ schema: { projektRechnungen: schemaKeys('projektRechnungen') }, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
|
||||
after(() => aw.close())
|
||||
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 { getProjektRechnung, listProjektRechnungen, createProjektRechnung, updateProjektRechnung, umhaengen } =
|
||||
await import('../server/services/auftraege/projektRechnungenStore.js')
|
||||
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||
|
||||
const COL = config.collections.projektRechnungen
|
||||
beforeEach(async () => {
|
||||
for (const id of [...aw.docs(COL).keys()]) await deleteDocument(COL, id)
|
||||
})
|
||||
|
||||
const DATEN = { kundenId: 'c1', projektId: 'p1', auftragId: 'ab1', art: 'aufwand', betragCents: 22500, arbeitsblattIds: ['w1', 'w2'], erstelltVon: 'kenso@webklar.com' }
|
||||
const seed = (id, felder = {}) =>
|
||||
aw.seed(COL, {
|
||||
$id: id, invoiceId: id, kundenId: 'c1', projektId: 'p1', auftragId: '', art: 'festbetrag', betragCents: 100,
|
||||
arbeitsblattIdsJson: '[]', rechnungsnummer: '', aktiv: true, erstelltAm: '2026-10-01T10:00:00.000Z', ...felder,
|
||||
})
|
||||
|
||||
test('createProjektRechnung: Dokument-ID ist die Rechnungs-ID, Standardwerte, Rückgabe in der Form aus V3', async () => {
|
||||
const rechnung = await createProjektRechnung('in_1SD6vPRxAbCdEf', DATEN)
|
||||
assert.deepEqual(
|
||||
{ ...rechnung, erstelltAm: undefined },
|
||||
{
|
||||
invoiceId: 'in_1SD6vPRxAbCdEf', kundenId: 'c1', projektId: 'p1', auftragId: 'ab1', art: 'aufwand', betragCents: 22500,
|
||||
arbeitsblattIds: ['w1', 'w2'], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com', erstelltAm: undefined,
|
||||
},
|
||||
)
|
||||
assert.match(rechnung.erstelltAm, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
|
||||
const doc = aw.docs(COL).get('in_1SD6vPRxAbCdEf')
|
||||
assert.equal(doc.invoiceId, 'in_1SD6vPRxAbCdEf')
|
||||
assert.equal(doc.arbeitsblattIdsJson, '["w1","w2"]')
|
||||
assert.equal(doc.aktiv, true)
|
||||
assert.deepEqual(await getProjektRechnung('in_1SD6vPRxAbCdEf'), rechnung)
|
||||
const minimal = await createProjektRechnung('in_test_1', { kundenId: 'c1', art: 'festbetrag', betragCents: 5000 })
|
||||
assert.deepEqual([minimal.projektId, minimal.auftragId, minimal.arbeitsblattIds, minimal.rechnungsnummer, minimal.aktiv], ['', '', [], '', true])
|
||||
})
|
||||
|
||||
test('Rechnungs-IDs wie Stripe sie vergibt (auch in_test_1 des Nachbaus); alles andere ist ein Programmfehler und legt nichts an', async () => {
|
||||
for (const id of ['in_test_1', 'in_1SD6vPRxAbCdEf', `in_${'a'.repeat(33)}`]) assert.equal((await createProjektRechnung(id, DATEN)).invoiceId, id)
|
||||
await Promise.all([...aw.docs(COL).keys()].map((id) => deleteDocument(COL, id)))
|
||||
for (const id of ['', 'x', 'in_', 'in_a/b', 'in_../x', 'in_a-b', 'in_a.b', 'IN_abc', `in_${'a'.repeat(34)}`, undefined, 7]) {
|
||||
await assert.rejects(() => createProjektRechnung(id, DATEN), TypeError, String(id))
|
||||
assert.equal(await getProjektRechnung(id), null, String(id))
|
||||
}
|
||||
const falsch = [{ kundenId: '' }, { art: 'frei' }, { art: 'xyz' }, { betragCents: 1.5 }, { betragCents: -1 }, { arbeitsblattIds: 'w1' }, { arbeitsblattIds: [''] }, { aktiv: 'ja' }]
|
||||
for (const abweichung of falsch) {
|
||||
await assert.rejects(() => createProjektRechnung('in_abc', { ...DATEN, ...abweichung }), TypeError, JSON.stringify(abweichung))
|
||||
}
|
||||
assert.equal(aw.docs(COL).size, 0)
|
||||
})
|
||||
|
||||
test('createProjektRechnung: eine doppelte Rechnungs-ID ist 409', async () => {
|
||||
await createProjektRechnung('in_abc', DATEN)
|
||||
await assert.rejects(() => createProjektRechnung('in_abc', DATEN), (err) => err instanceof AuftragError && err.status === 409)
|
||||
})
|
||||
|
||||
test('updateProjektRechnung ändert genannte Felder; eine unbekannte Rechnung ist 404', async () => {
|
||||
await createProjektRechnung('in_abc', DATEN)
|
||||
const gesendet = await updateProjektRechnung('in_abc', { rechnungsnummer: 'WEBKLAR-0009' })
|
||||
assert.deepEqual([gesendet.rechnungsnummer, gesendet.aktiv, gesendet.betragCents], ['WEBKLAR-0009', true, 22500])
|
||||
const storniert = await updateProjektRechnung('in_abc', { aktiv: false, arbeitsblattIds: ['w1'] })
|
||||
assert.deepEqual([storniert.aktiv, storniert.arbeitsblattIds, storniert.rechnungsnummer], [false, ['w1'], 'WEBKLAR-0009'])
|
||||
assert.equal(aw.docs(COL).get('in_abc').aktiv, false)
|
||||
for (const id of ['in_gibtsnicht', '../x', '']) {
|
||||
await assert.rejects(() => updateProjektRechnung(id, { aktiv: false }), (err) => err instanceof AuftragError && err.status === 404, id)
|
||||
}
|
||||
})
|
||||
|
||||
test('listProjektRechnungen: nur aktive, Filter, nach erstelltAm aufsteigend, mehr als 25 Treffer', async () => {
|
||||
seed('in_a', { erstelltAm: '2026-10-03T10:00:00.000Z', auftragId: 'ab1' })
|
||||
seed('in_b', { erstelltAm: '2026-10-01T10:00:00.000Z', auftragId: 'ab1' })
|
||||
seed('in_c', { aktiv: false, erstelltAm: '2026-10-02T10:00:00.000Z', auftragId: 'ab1' })
|
||||
seed('in_d', { kundenId: 'c2', projektId: 'p2', erstelltAm: '2026-10-04T10:00:00.000Z' })
|
||||
const ids = async (filter) => (await listProjektRechnungen(filter)).map((r) => r.invoiceId)
|
||||
assert.deepEqual(await ids({ kundenId: 'c1' }), ['in_b', 'in_a'])
|
||||
assert.deepEqual(await ids({ kundenId: 'c1', nurAktiv: false }), ['in_b', 'in_c', 'in_a'])
|
||||
assert.deepEqual(await ids({ auftragId: 'ab1' }), ['in_b', 'in_a'])
|
||||
assert.deepEqual(await ids({ projektId: 'p2' }), ['in_d'])
|
||||
assert.deepEqual(await ids(), ['in_b', 'in_a', 'in_d'])
|
||||
for (let i = 0; i < 30; i += 1) seed(`in_m${i}`, { kundenId: 'c9' })
|
||||
assert.equal((await listProjektRechnungen({ kundenId: 'c9' })).length, 30)
|
||||
})
|
||||
|
||||
test('listProjektRechnungen: ein angegebener, aber leerer oder ungültiger Filter ist ein Fehler, nie „alle“', async () => {
|
||||
seed('in_a')
|
||||
for (const filter of [{ kundenId: '' }, { projektId: null }, { auftragId: 7 }, { nurAktiv: 'ja' }]) {
|
||||
await assert.rejects(() => listProjektRechnungen(filter), TypeError, JSON.stringify(filter))
|
||||
}
|
||||
})
|
||||
|
||||
test('umhaengen hängt alle Dokumente einer AB (auch stornierte) an die neue AB, ist wiederholbar und lässt andere unberührt', async () => {
|
||||
seed('in_a', { auftragId: 'ab1' })
|
||||
seed('in_b', { auftragId: 'ab1', aktiv: false })
|
||||
seed('in_c', { auftragId: 'ab2' })
|
||||
assert.equal(await umhaengen('ab1', 'ab3'), 2)
|
||||
assert.deepEqual([...aw.docs(COL).values()].map((d) => [d.invoiceId, d.auftragId]).sort(), [['in_a', 'ab3'], ['in_b', 'ab3'], ['in_c', 'ab2']])
|
||||
assert.equal(await umhaengen('ab1', 'ab3'), 0)
|
||||
assert.equal(await umhaengen('ab3', 'ab3'), 0)
|
||||
for (const [alt, neu] of [['', 'ab3'], ['ab1', ''], [undefined, 'ab3'], ['ab1', null]]) {
|
||||
await assert.rejects(() => umhaengen(alt, neu), TypeError, JSON.stringify([alt, neu]))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,687 @@
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
|
||||
|
||||
// Projektkonto (Strang L1): reine Rechenregeln, Stripe lesen, Konten je Kunde und Projekt, Abzeichen "abgerechnet".
|
||||
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster financeStripeRead.test.js und die Tests von L0).
|
||||
// worksheets, workorders, websiteProjects und customers gehoeren dem Ticketsystem und haben im Nachbau kein Schema.
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
|
||||
datetimes: AUFTRAEGE_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const { seedFixtures } = await import('./support/fixtures.js')
|
||||
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { invalidateFinanceCache, listInvoices } = await import('../server/services/finance/stripeFinance.js')
|
||||
const { rechneKonto, rechneOhneProjekt, ladeStripeRechnungen, projektkontenFuerKunde, projektkontoDetail, abgerechnetFuerTicket } = await import(
|
||||
'../server/services/auftraege/projektkonto.js'
|
||||
)
|
||||
|
||||
const COLLECTIONS = ['websiteProjects', 'workorders', 'worksheets', 'auftraege', 'projektRechnungen']
|
||||
async function reset() {
|
||||
mock.restoreAll()
|
||||
invalidateFinanceCache()
|
||||
st.calls.length = 0
|
||||
st.invoices.clear()
|
||||
aw.resetListCalls()
|
||||
config.auftraege.mode = 'on'
|
||||
config.auftraege.pilotEmails = []
|
||||
for (const name of COLLECTIONS) {
|
||||
for (const id of aw.docs(config.collections[name]).keys()) await deleteDocument(config.collections[name], id)
|
||||
}
|
||||
}
|
||||
beforeEach(reset)
|
||||
after(() => Promise.all([aw.close(), st.close()]))
|
||||
seedFixtures(aw)
|
||||
|
||||
// ---------- reine Rechenregeln (Task 2) ----------
|
||||
|
||||
const ab = (over = {}) => ({
|
||||
id: 'a1',
|
||||
status: 'versendet',
|
||||
art: 'festpreis',
|
||||
summeCents: 80000,
|
||||
stundensatzCents: null,
|
||||
schaetzungMinuten: null,
|
||||
gesendetAm: '2026-10-06T08:00:00.000Z',
|
||||
erstelltAm: '2026-10-06T07:00:00.000Z',
|
||||
...over,
|
||||
})
|
||||
const rg = (id, status, totalCents, paidCents, auftragId = '', art = 'festbetrag') => ({ art, auftragId, invoice: { id, status, totalCents, paidCents } })
|
||||
const blatt = (id, minuten, over = {}) => ({ id, ticketId: 't1', woid: '100001', datum: '2026-10-06', minuten, text: '', aboZeit: false, kommentar: false, ...over })
|
||||
const NULL_ZEIT = { minuten: 0, aboMinuten: 0, offeneMinuten: 0, satzCents: 10000, wertCents: 0, offenerWertCents: 0 }
|
||||
|
||||
test('rechneKonto: ohne AB, Rechnungen und Zeit ist alles leer, der Satz ist 100 €', () => {
|
||||
assert.deepEqual(rechneKonto({}), {
|
||||
vereinbart: { festpreisCents: null, aufwand: null },
|
||||
berechnetCents: 0,
|
||||
bezahltCents: 0,
|
||||
offenCents: null,
|
||||
zeit: NULL_ZEIT,
|
||||
})
|
||||
})
|
||||
|
||||
test('rechneKonto: vereinbart sind nur Festpreis-ABs mit Status versendet, bestaetigt oder abgerechnet', () => {
|
||||
const konto = rechneKonto({
|
||||
auftraege: [
|
||||
ab({ id: 'a1', summeCents: 80000 }),
|
||||
ab({ id: 'a2', status: 'bestaetigt', summeCents: 20000 }),
|
||||
ab({ id: 'a3', status: 'abgerechnet', summeCents: 5000 }),
|
||||
...['entwurf', 'storniert', 'ersetzt', 'widerrufen'].map((status, i) => ab({ id: `x${i}`, status, summeCents: 99900 })),
|
||||
],
|
||||
})
|
||||
assert.deepEqual(konto.vereinbart, { festpreisCents: 105000, aufwand: null })
|
||||
})
|
||||
|
||||
test('rechneKonto: die jüngste gültige Aufwands-AB bestimmt Satz und Schätzung', () => {
|
||||
const aufwand = (over) => ab({ art: 'aufwand', summeCents: null, ...over })
|
||||
const konto = rechneKonto({
|
||||
auftraege: [
|
||||
aufwand({ id: 'a1', stundensatzCents: 9000, schaetzungMinuten: 600, gesendetAm: '2026-10-01T08:00:00.000Z' }),
|
||||
aufwand({ id: 'a2', stundensatzCents: 12000, schaetzungMinuten: 300, gesendetAm: '2026-10-05T08:00:00.000Z' }),
|
||||
aufwand({ id: 'a3', stundensatzCents: 15000, status: 'entwurf', gesendetAm: null, erstelltAm: '2026-10-06T08:00:00.000Z' }),
|
||||
aufwand({ id: 'a4', stundensatzCents: 20000, status: 'storniert', gesendetAm: '2026-10-06T09:00:00.000Z' }),
|
||||
],
|
||||
blaetter: [blatt('w1', 60)],
|
||||
})
|
||||
assert.deepEqual(konto.vereinbart, { festpreisCents: null, aufwand: { stundensatzCents: 12000, schaetzungMinuten: 300 } })
|
||||
assert.equal(konto.offenCents, null)
|
||||
assert.equal(konto.zeit.satzCents, 12000)
|
||||
assert.equal(konto.zeit.wertCents, 12000)
|
||||
})
|
||||
|
||||
test('rechneKonto: berechnet und bezahlt zählen nur finalisierte Rechnungen, Entwürfe und Stornos nicht', () => {
|
||||
const konto = rechneKonto({
|
||||
rechnungen: [
|
||||
rg('in_1', 'offen', 40000, 0),
|
||||
rg('in_2', 'ueberfaellig', 10000, 0),
|
||||
rg('in_3', 'bezahlt', 25000, 25000),
|
||||
rg('in_4', 'uneinbringlich', 5000, 0),
|
||||
rg('in_5', 'entwurf', 99900, 0),
|
||||
rg('in_6', 'storniert', 77700, 0),
|
||||
],
|
||||
})
|
||||
assert.equal(konto.berechnetCents, 80000)
|
||||
assert.equal(konto.bezahltCents, 25000)
|
||||
})
|
||||
|
||||
test('rechneKonto: Festpreis-AB plus Aufwandsrechnungen – offen rechnet nur mit den Rechnungen zur Festpreis-AB', () => {
|
||||
const konto = rechneKonto({
|
||||
auftraege: [ab({ id: 'a1', summeCents: 80000 })],
|
||||
rechnungen: [
|
||||
rg('in_1', 'bezahlt', 40000, 40000, 'a1', 'anzahlung'),
|
||||
rg('in_2', 'offen', 30000, 0, '', 'aufwand'),
|
||||
rg('in_3', 'bezahlt', 5000, 5000, '', 'festbetrag'),
|
||||
],
|
||||
})
|
||||
assert.equal(konto.berechnetCents, 75000)
|
||||
assert.equal(konto.bezahltCents, 45000)
|
||||
assert.equal(konto.offenCents, 40000)
|
||||
})
|
||||
|
||||
test('rechneKonto: offen ist nie negativ, Anzahlung plus Schlussrechnung ergeben genau 0', () => {
|
||||
const auftraege = [ab({ id: 'a1', summeCents: 80000 })]
|
||||
const anzahlung = rg('in_1', 'bezahlt', 40000, 40000, 'a1', 'anzahlung')
|
||||
assert.equal(rechneKonto({ auftraege, rechnungen: [anzahlung, rg('in_2', 'offen', 40000, 0, 'a1', 'schluss')] }).offenCents, 0)
|
||||
const nachtraege = rechneKonto({ auftraege, rechnungen: [anzahlung, rg('in_2', 'offen', 60000, 0, 'a1', 'schluss')] })
|
||||
assert.equal(nachtraege.offenCents, 0)
|
||||
assert.equal(nachtraege.berechnetCents, 100000)
|
||||
})
|
||||
|
||||
test('rechneKonto: Rechnungen zu einer stornierten AB mindern das Offene der gültigen AB nicht', () => {
|
||||
const konto = rechneKonto({
|
||||
auftraege: [ab({ id: 'a1', status: 'storniert', summeCents: 80000 }), ab({ id: 'a2', summeCents: 50000 })],
|
||||
rechnungen: [rg('in_1', 'offen', 40000, 0, 'a1', 'anzahlung')],
|
||||
})
|
||||
assert.equal(konto.vereinbart.festpreisCents, 50000)
|
||||
assert.equal(konto.berechnetCents, 40000)
|
||||
assert.equal(konto.offenCents, 50000)
|
||||
})
|
||||
|
||||
test('rechneKonto: ohne Stripe sind berechnet und bezahlt 0 und offen unbekannt, Vereinbartes und Zeit bleiben', () => {
|
||||
const konto = rechneKonto({
|
||||
auftraege: [ab({ id: 'a1', summeCents: 80000 })],
|
||||
rechnungen: [rg('in_1', 'bezahlt', 40000, 40000, 'a1', 'anzahlung')],
|
||||
blaetter: [blatt('w1', 60)],
|
||||
stripeOk: false,
|
||||
})
|
||||
assert.deepEqual([konto.berechnetCents, konto.bezahltCents, konto.offenCents], [0, 0, null])
|
||||
assert.equal(konto.vereinbart.festpreisCents, 80000)
|
||||
assert.deepEqual(konto.zeit, { ...NULL_ZEIT, minuten: 60, offeneMinuten: 60, wertCents: 10000, offenerWertCents: 10000 })
|
||||
})
|
||||
|
||||
test('rechneKonto: Zeit erfasst ohne Abo-Zeit, Kommentare und 0 Minuten – Abo getrennt, Berechnetes nicht mehr offen', () => {
|
||||
const blaetter = [
|
||||
blatt('w1', 60),
|
||||
blatt('w2', 30),
|
||||
blatt('w3', 45, { aboZeit: true }),
|
||||
blatt('w4', 15, { kommentar: true }),
|
||||
blatt('w5', 0),
|
||||
blatt('w6', 90, { aboZeit: true, kommentar: true }),
|
||||
]
|
||||
const abgerechnet = new Map([['w1', { invoiceId: 'in_1', rechnungsnummer: 'WEBKLAR-0001' }]])
|
||||
assert.deepEqual(rechneKonto({ blaetter, abgerechnet }).zeit, {
|
||||
minuten: 90,
|
||||
aboMinuten: 45,
|
||||
offeneMinuten: 30,
|
||||
satzCents: 10000,
|
||||
wertCents: 15000,
|
||||
offenerWertCents: 5000,
|
||||
})
|
||||
})
|
||||
|
||||
test('rechneKonto: der Wert der Zeit ist die Summe der je Berliner Tag gerundeten Werte (wie die Zeilen der Aufwandsrechnung)', () => {
|
||||
const mitSatz = (stundensatzCents) => [ab({ art: 'aufwand', summeCents: null, stundensatzCents })]
|
||||
const blaetter = [
|
||||
blatt('w1', 15, { datum: '2026-10-05' }),
|
||||
blatt('w2', 15, { datum: '2026-10-06' }),
|
||||
blatt('w3', 15, { datum: '2026-10-06' }),
|
||||
blatt('w4', 15, { datum: '2026-10-07' }),
|
||||
]
|
||||
const abgerechnet = new Map([['w2', { invoiceId: 'in_1', rechnungsnummer: 'WEBKLAR-0001' }]])
|
||||
// 99,90 €/Std.: 15 Minuten = 24,975 € → 2498 Cent, 30 Minuten = 49,95 € → 4995 Cent
|
||||
const krumm = rechneKonto({ auftraege: mitSatz(9990), blaetter, abgerechnet }).zeit
|
||||
// je Tag gerundet: 2498 (05.10.) + 4995 (06.10., zwei Blätter) + 2498 (07.10.) = 9991; je Blatt wären es 9992, über alle 60 Minuten 9990
|
||||
assert.equal(krumm.wertCents, 9991)
|
||||
// offen (w2 ist berechnet): drei Tage zu je 15 Minuten = 3 × 2498 = 7494; über alle 45 Minuten wären es 7493
|
||||
assert.deepEqual([krumm.minuten, krumm.offeneMinuten, krumm.offenerWertCents], [60, 45, 7494])
|
||||
// 95,00 €/Std.: jede Viertelstunde geht glatt auf (2375 Cent), Tag für Tag wie insgesamt
|
||||
const glatt = rechneKonto({ auftraege: mitSatz(9500), blaetter, abgerechnet }).zeit
|
||||
assert.deepEqual([glatt.wertCents, glatt.offenerWertCents], [9500, 7125])
|
||||
})
|
||||
|
||||
test('rechneOhneProjekt: nur finalisierte Rechnungen zählen', () => {
|
||||
const inv = (status, totalCents, paidCents) => ({ status, totalCents, paidCents })
|
||||
const rechnungen = [
|
||||
inv('offen', 12000, 0),
|
||||
inv('bezahlt', 8000, 8000),
|
||||
inv('entwurf', 5000, 0),
|
||||
inv('storniert', 4000, 0),
|
||||
inv('uneinbringlich', 1000, 0),
|
||||
inv('ueberfaellig', 2000, 500),
|
||||
]
|
||||
assert.deepEqual(rechneOhneProjekt(rechnungen), { berechnetCents: 23000, bezahltCents: 8500, anzahl: 4 })
|
||||
assert.deepEqual(rechneOhneProjekt([]), { berechnetCents: 0, bezahltCents: 0, anzahl: 0 })
|
||||
})
|
||||
|
||||
// ---------- Stripe lesen (Task 3) ----------
|
||||
|
||||
const noonBerlin = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1000)
|
||||
// Rohe Stripe-Rechnung wie in financeStripeRead.test.js; status in Stripes Schreibweise (draft, open, paid, void, uncollectible)
|
||||
const stripeInvoice = (
|
||||
id,
|
||||
{ status = 'open', total = 0, paid = 0, number = '', kunde = 'c1', created = '2026-10-01', due = '2099-12-31', abo = false } = {}
|
||||
) => ({
|
||||
id,
|
||||
object: 'invoice',
|
||||
number: number || null,
|
||||
status,
|
||||
customer: `cus_${kunde}`,
|
||||
customer_name: 'Kunde',
|
||||
customer_email: 'k@example.invalid',
|
||||
created: noonBerlin(created),
|
||||
due_date: noonBerlin(due),
|
||||
total,
|
||||
amount_due: total,
|
||||
amount_paid: paid,
|
||||
amount_remaining: total - paid,
|
||||
status_transitions: { paid_at: null },
|
||||
metadata: { webklarCustomerId: kunde, quelle: 'woms' },
|
||||
...(abo ? { parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_1' } } } : {}),
|
||||
lines: { object: 'list', data: [], has_more: false },
|
||||
})
|
||||
const pr = (invoiceId) => ({ invoiceId })
|
||||
|
||||
test('ladeStripeRechnungen: Liste zuerst, Fehlendes einzeln gelesen, Unbekanntes bleibt aussen vor', async () => {
|
||||
st.invoices.set('in_1', stripeInvoice('in_1', { total: 4000, number: 'WEBKLAR-0001' }))
|
||||
await listInvoices() // füllt den 60-Sekunden-Zwischenspeicher; in_9 fehlt dort
|
||||
st.invoices.set('in_9', stripeInvoice('in_9', { status: 'paid', total: 7000, paid: 7000, number: 'WEBKLAR-0009' }))
|
||||
st.calls.length = 0
|
||||
const result = await ladeStripeRechnungen([pr('in_1'), pr('in_9'), pr('in_404')])
|
||||
assert.equal(result.ok, true)
|
||||
assert.deepEqual([...result.nachId.keys()].sort(), ['in_1', 'in_9'])
|
||||
assert.deepEqual(result.alle.map((invoice) => invoice.id), ['in_1'])
|
||||
const gelesen = result.nachId.get('in_9')
|
||||
assert.deepEqual([gelesen.status, gelesen.totalCents, gelesen.paidCents], ['bezahlt', 7000, 7000])
|
||||
assert.deepEqual(st.calls.map((call) => `${call.method} ${call.path}`).sort(), ['GET /v1/invoices/in_404', 'GET /v1/invoices/in_9'])
|
||||
})
|
||||
|
||||
test('ladeStripeRechnungen: Stripe-Ausfall ergibt ok false und ein Protokoll ohne Fehlertext', async () => {
|
||||
const warn = mock.method(console, 'warn', () => {})
|
||||
st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'geheim-kaputt' })
|
||||
const result = await ladeStripeRechnungen([pr('in_1')])
|
||||
assert.deepEqual([result.ok, result.alle, [...result.nachId.keys()]], [false, [], []])
|
||||
assert.equal(warn.mock.callCount(), 1)
|
||||
assert.ok(!JSON.stringify(warn.mock.calls[0].arguments).includes('geheim-kaputt'))
|
||||
})
|
||||
|
||||
test('ladeStripeRechnungen: ein anderer Fehler als 404 beim Einzellesen ist ein Ausfall', async () => {
|
||||
mock.method(console, 'warn', () => {})
|
||||
st.invoices.set('in_1', stripeInvoice('in_1', { total: 4000 }))
|
||||
await listInvoices()
|
||||
st.invoices.set('in_9', stripeInvoice('in_9', { total: 7000 }))
|
||||
st.failNext({ method: 'GET', path: '/v1/invoices/in_9', status: 400, message: 'kaputt' })
|
||||
const result = await ladeStripeRechnungen([pr('in_1'), pr('in_9')])
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.nachId.size, 0)
|
||||
})
|
||||
|
||||
test('ladeStripeRechnungen: viele fehlende Rechnungen werden vollständig gelesen (auch IDs wie die des Stripe-Nachbaus)', async () => {
|
||||
await listInvoices()
|
||||
const ids = Array.from({ length: 25 }, (_, i) => `in_test_${i + 1}`)
|
||||
for (const id of ids) st.invoices.set(id, stripeInvoice(id, { status: 'paid', total: 100, paid: 100 }))
|
||||
st.calls.length = 0
|
||||
const result = await ladeStripeRechnungen(ids.map(pr))
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.nachId.size, 25)
|
||||
assert.equal(st.calls.filter((call) => call.method === 'GET' && /^\/v1\/invoices\/in_/.test(call.path)).length, 25)
|
||||
})
|
||||
|
||||
test('ladeStripeRechnungen: unmögliche Rechnungs-IDs gehen nie an Stripe', async () => {
|
||||
await listInvoices()
|
||||
st.calls.length = 0
|
||||
// Pfadzeichen, ohne Präfix, kein Text, nur das Präfix, zu lang (V1: 1 bis 33 Zeichen nach „in_“)
|
||||
const result = await ladeStripeRechnungen([pr('../customers'), pr('x'), pr(undefined), pr('in_'), pr(`in_${'x'.repeat(34)}`)])
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.nachId.size, 0)
|
||||
assert.equal(st.calls.length, 0)
|
||||
})
|
||||
|
||||
// ---------- Projektkonten eines Kunden (Task 4) ----------
|
||||
|
||||
const seedTicket = (id, woid, customerId = 'c1') =>
|
||||
aw.seed(config.collections.workorders, { $id: id, woid, customerId, topic: `Ticket ${woid}`, status: 'open' })
|
||||
const seedProjekt = (id, over = {}) =>
|
||||
aw.seed(config.collections.websiteProjects, {
|
||||
$id: id,
|
||||
customerId: 'c1',
|
||||
projectName: `Projekt ${id}`,
|
||||
ticketId: '',
|
||||
kind: 'website',
|
||||
archived: false,
|
||||
...over,
|
||||
})
|
||||
let blattNr = 0
|
||||
const seedBlatt = (id, ticketId, minuten, over = {}) =>
|
||||
aw.seed(config.collections.worksheets, {
|
||||
$id: id,
|
||||
wsid: String(100000 + (blattNr += 1)),
|
||||
woid: ticketId === 't2' ? '100002' : '100001',
|
||||
workorderId: ticketId,
|
||||
employeeId: 'e1',
|
||||
employeeName: 'Kenso Grimm',
|
||||
employeeShort: 'KNSO',
|
||||
serviceType: 'Remote',
|
||||
totalTime: minuten,
|
||||
supporters: '',
|
||||
startDate: '06.10.2026',
|
||||
endDate: '06.10.2026',
|
||||
startTime: '10:00',
|
||||
endTime: '11:00',
|
||||
details: 'Startseite überarbeitet',
|
||||
isComment: false,
|
||||
countsToPlan: false,
|
||||
...over,
|
||||
})
|
||||
const seedPr = (invoiceId, { arbeitsblattIds = [], ...over } = {}) =>
|
||||
aw.seed(config.collections.projektRechnungen, {
|
||||
$id: invoiceId,
|
||||
invoiceId,
|
||||
kundenId: 'c1',
|
||||
projektId: 'p1',
|
||||
auftragId: '',
|
||||
art: 'festbetrag',
|
||||
betragCents: 0,
|
||||
arbeitsblattIdsJson: JSON.stringify(arbeitsblattIds),
|
||||
rechnungsnummer: '',
|
||||
aktiv: true,
|
||||
erstelltVon: 'kenso@webklar.com',
|
||||
erstelltAm: '2026-10-01T10:00:00.000Z',
|
||||
...over,
|
||||
})
|
||||
const seedAuftrag = (id, over = {}) =>
|
||||
aw.seed(config.collections.auftraege, {
|
||||
$id: id,
|
||||
nummer: 'AB-2026-0001',
|
||||
jahr: 2026,
|
||||
laufnummer: 1,
|
||||
status: 'versendet',
|
||||
kundenId: 'c1',
|
||||
kundeJson: JSON.stringify({
|
||||
name: 'Kunde Eins', firma: '', strasse: 'Hauptstraße 1', plz: '67722', ort: 'Winnweiler', land: 'DE',
|
||||
email: 'k@example.invalid', kundenart: 'unternehmen', kundennummer: '',
|
||||
}),
|
||||
projektId: 'p1',
|
||||
titel: 'Website',
|
||||
zeitquelleJson: '["t1"]',
|
||||
art: 'festpreis',
|
||||
positionenJson: JSON.stringify([{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000 }]),
|
||||
stundensatzCents: null,
|
||||
schaetzungMinuten: null,
|
||||
summeCents: 80000,
|
||||
zahlungsplanJson: JSON.stringify({ art: 'nach_abnahme', prozent: null, text: '' }),
|
||||
zahlungszielTage: 14,
|
||||
auftragVom: '2026-10-05',
|
||||
auftragWeg: 'telefon',
|
||||
startAm: '',
|
||||
fertigBis: '',
|
||||
texteFassung: '2026-10-06',
|
||||
agbFassung: '2026-09-30',
|
||||
pdfDatei: `c1/${id}.pdf`,
|
||||
pdfSha256: 'a'.repeat(64),
|
||||
gesendetAm: '2026-10-06T08:00:00.000Z',
|
||||
gesendetAn: 'k@example.invalid',
|
||||
mailStatus: 'gesendet',
|
||||
mailFehler: '',
|
||||
tokenHash: 'b'.repeat(64),
|
||||
tokenGueltigBis: '2026-12-05T08:00:00.000Z',
|
||||
bestaetigtAm: null,
|
||||
bestaetigtUeber: '',
|
||||
bestaetigtPdfSha256: '',
|
||||
fruehbeginn: false,
|
||||
fruehbeginnFassung: '',
|
||||
ersetztId: '',
|
||||
ersetztDurchId: '',
|
||||
storniertAm: null,
|
||||
widerrufenAm: null,
|
||||
erstelltVon: 'kenso@webklar.com',
|
||||
erstelltAm: '2026-10-06T07:00:00.000Z',
|
||||
geaendertAm: '2026-10-06T08:00:00.000Z',
|
||||
...over,
|
||||
})
|
||||
|
||||
test('Projekte des Kunden: neueste zuerst, archivierte nur mit Geldbezug, Vorlagen und fremde Projekte nie', async () => {
|
||||
seedProjekt('p1', { $createdAt: '2026-09-01T10:00:00.000Z' })
|
||||
seedProjekt('p2', { $createdAt: '2026-09-02T10:00:00.000Z' })
|
||||
seedProjekt('p3', { $createdAt: '2026-09-03T10:00:00.000Z', archived: true }) // aktive Projektrechnung -> sichtbar
|
||||
seedProjekt('p4', { $createdAt: '2026-09-04T10:00:00.000Z', archived: true }) // ohne alles -> weg
|
||||
seedProjekt('p5', { $createdAt: '2026-09-05T10:00:00.000Z', archived: true }) // nur Entwurfs-AB -> weg
|
||||
seedProjekt('p6', { $createdAt: '2026-09-06T10:00:00.000Z', archived: true }) // gültige AB -> sichtbar
|
||||
seedProjekt('p7', { $createdAt: '2026-09-07T10:00:00.000Z', isTemplate: true }) // Vorlage -> weg
|
||||
seedProjekt('p8', { $createdAt: '2026-09-08T10:00:00.000Z', customerId: 'c2' }) // fremder Kunde -> weg
|
||||
seedPr('in_1', { projektId: 'p3' })
|
||||
st.invoices.set('in_1', stripeInvoice('in_1', { total: 1000, number: 'WEBKLAR-0001' }))
|
||||
seedAuftrag('a5', { projektId: 'p5', status: 'entwurf', nummer: '' })
|
||||
seedAuftrag('a6', { projektId: 'p6', status: 'versendet' })
|
||||
const result = await projektkontenFuerKunde('c1')
|
||||
assert.deepEqual(result.konten.map((konto) => konto.projektId), ['p6', 'p3', 'p2', 'p1'])
|
||||
})
|
||||
|
||||
test('Projekt ohne Ticket: leere Zeitquelle und Zeit 0, Rechnungen und AB zählen trotzdem', async () => {
|
||||
seedProjekt('p1', { ticketId: '', kind: 'webapp' })
|
||||
seedAuftrag('a1', { projektId: 'p1', summeCents: 50000, zeitquelleJson: '[]' })
|
||||
seedPr('in_1', { projektId: 'p1', auftragId: 'a1', art: 'anzahlung' })
|
||||
st.invoices.set('in_1', stripeInvoice('in_1', { status: 'paid', total: 25000, paid: 25000, number: 'WEBKLAR-0001' }))
|
||||
const result = await projektkontenFuerKunde('c1')
|
||||
assert.equal(result.konten.length, 1)
|
||||
const [konto] = result.konten
|
||||
assert.deepEqual(Object.keys(konto).sort(), [
|
||||
'berechnetCents', 'bezahltCents', 'name', 'offenCents', 'projektId', 'projektTyp', 'tickets', 'vereinbart', 'zeit', 'zeitquelle',
|
||||
])
|
||||
assert.deepEqual([konto.name, konto.projektTyp], ['Projekt p1', 'Web-App'])
|
||||
assert.deepEqual([konto.tickets, konto.zeitquelle], [[], []])
|
||||
assert.deepEqual(konto.zeit, { minuten: 0, aboMinuten: 0, offeneMinuten: 0, satzCents: 10000, wertCents: 0, offenerWertCents: 0 })
|
||||
assert.deepEqual([konto.vereinbart.festpreisCents, konto.berechnetCents, konto.bezahltCents, konto.offenCents], [50000, 25000, 25000, 25000])
|
||||
assert.equal(aw.listCalls(config.collections.worksheets).length, 0)
|
||||
})
|
||||
|
||||
test('Ticket mit mehreren Projekten: Zeit erfasst zählt in beiden Konten, offen nur einmal', async () => {
|
||||
seedTicket('t1', '100001')
|
||||
seedProjekt('p1', { ticketId: 't1', $createdAt: '2026-09-01T10:00:00.000Z' })
|
||||
seedProjekt('p2', { ticketId: 't1', $createdAt: '2026-09-02T10:00:00.000Z' })
|
||||
seedBlatt('w1', 't1', 60)
|
||||
seedBlatt('w2', 't1', 30)
|
||||
// p1 hat w1 schon in einer Aufwandsrechnung (Entwurf): das Blatt ist abgerechnet, die Rechnung zählt noch nicht als berechnet
|
||||
seedPr('in_1', { projektId: 'p1', art: 'aufwand', arbeitsblattIds: ['w1'] })
|
||||
st.invoices.set('in_1', stripeInvoice('in_1', { status: 'draft', total: 10000 }))
|
||||
const { konten } = await projektkontenFuerKunde('c1')
|
||||
const nach = Object.fromEntries(konten.map((konto) => [konto.projektId, konto]))
|
||||
const erwartet = { minuten: 90, aboMinuten: 0, offeneMinuten: 30, satzCents: 10000, wertCents: 15000, offenerWertCents: 5000 }
|
||||
assert.deepEqual(nach.p1.zeit, erwartet)
|
||||
assert.deepEqual(nach.p2.zeit, erwartet)
|
||||
assert.deepEqual([nach.p1.berechnetCents, nach.p2.berechnetCents], [0, 0])
|
||||
assert.deepEqual(nach.p2.tickets, [{ id: 't1', woid: '100001' }])
|
||||
})
|
||||
|
||||
test('Zeitquelle: die gültige AB bestimmt die Tickets, ein Entwurf nicht', async () => {
|
||||
seedTicket('t1', '100001')
|
||||
seedTicket('t2', '100002')
|
||||
seedProjekt('p1', { ticketId: 't1' })
|
||||
seedBlatt('w1', 't1', 60)
|
||||
seedBlatt('w2', 't2', 45)
|
||||
seedAuftrag('a1', { projektId: 'p1', status: 'entwurf', zeitquelleJson: '["t2"]' })
|
||||
let { konten } = await projektkontenFuerKunde('c1')
|
||||
assert.deepEqual(konten[0].zeitquelle, ['t1'])
|
||||
assert.equal(konten[0].zeit.minuten, 60)
|
||||
await updateDocument(config.collections.auftraege, 'a1', { status: 'versendet' })
|
||||
;({ konten } = await projektkontenFuerKunde('c1'))
|
||||
assert.deepEqual(konten[0].zeitquelle, ['t2'])
|
||||
assert.deepEqual(konten[0].tickets, [{ id: 't2', woid: '100002' }])
|
||||
assert.equal(konten[0].zeit.minuten, 45)
|
||||
})
|
||||
|
||||
test('Entwürfe, Stornos und inaktive Projektrechnungen zählen nicht, Blätter in Entwürfen sind trotzdem nicht mehr offen', async () => {
|
||||
seedTicket('t1', '100001')
|
||||
seedProjekt('p1', { ticketId: 't1' })
|
||||
seedBlatt('w1', 't1', 60)
|
||||
seedBlatt('w2', 't1', 30)
|
||||
seedPr('in_1', { art: 'aufwand', arbeitsblattIds: ['w1'] }) // Entwurf
|
||||
seedPr('in_2') // bei Stripe storniert
|
||||
seedPr('in_3', { aktiv: false }) // zurückgenommen (inaktiv)
|
||||
seedPr('in_4') // offen
|
||||
seedPr('in_5') // überfällig
|
||||
st.invoices.set('in_1', stripeInvoice('in_1', { status: 'draft', total: 10000 }))
|
||||
st.invoices.set('in_2', stripeInvoice('in_2', { status: 'void', total: 7000, number: 'WEBKLAR-0002' }))
|
||||
st.invoices.set('in_3', stripeInvoice('in_3', { status: 'void', total: 9000, number: 'WEBKLAR-0003' }))
|
||||
st.invoices.set('in_4', stripeInvoice('in_4', { status: 'open', total: 3000, number: 'WEBKLAR-0004' }))
|
||||
st.invoices.set('in_5', stripeInvoice('in_5', { status: 'open', total: 2000, number: 'WEBKLAR-0005', due: '2020-01-01' }))
|
||||
const result = await projektkontenFuerKunde('c1')
|
||||
const [konto] = result.konten
|
||||
assert.deepEqual([konto.berechnetCents, konto.bezahltCents], [5000, 0])
|
||||
assert.deepEqual([konto.zeit.minuten, konto.zeit.offeneMinuten], [90, 30])
|
||||
assert.deepEqual(result.ohneProjekt, { berechnetCents: 0, bezahltCents: 0, anzahl: 0 })
|
||||
assert.equal(result.stripeOk, true)
|
||||
})
|
||||
|
||||
test('Ohne Projekt: finalisierte Projekt-Rechnungen des Kunden ohne Zuordnung, ohne Abo, Entwürfe, Stornos und fremde Kunden', async () => {
|
||||
seedProjekt('p1')
|
||||
seedPr('in_16', { projektId: 'p1' })
|
||||
st.invoices.set('in_10', stripeInvoice('in_10', { status: 'open', total: 12000, number: 'WEBKLAR-0010' }))
|
||||
st.invoices.set('in_11', stripeInvoice('in_11', { status: 'paid', total: 8000, paid: 8000, number: 'WEBKLAR-0011' }))
|
||||
st.invoices.set('in_12', stripeInvoice('in_12', { status: 'draft', total: 5000 }))
|
||||
st.invoices.set('in_13', stripeInvoice('in_13', { status: 'void', total: 4000, number: 'WEBKLAR-0013' }))
|
||||
st.invoices.set('in_14', stripeInvoice('in_14', { status: 'paid', total: 2000, paid: 2000, number: 'WEBKLAR-0014', abo: true }))
|
||||
st.invoices.set('in_15', stripeInvoice('in_15', { status: 'open', total: 7000, number: 'WEBKLAR-0015', kunde: 'c2' }))
|
||||
st.invoices.set('in_16', stripeInvoice('in_16', { status: 'open', total: 3000, number: 'WEBKLAR-0016' }))
|
||||
const result = await projektkontenFuerKunde('c1')
|
||||
assert.deepEqual(result.ohneProjekt, { berechnetCents: 20000, bezahltCents: 8000, anzahl: 2 })
|
||||
assert.equal(result.konten[0].berechnetCents, 3000)
|
||||
})
|
||||
|
||||
test('Ohne Projekt fängt auch Projektrechnungen auf, deren Projekt es nicht mehr gibt', async () => {
|
||||
seedPr('in_20', { projektId: 'geloescht' })
|
||||
st.invoices.set('in_20', stripeInvoice('in_20', { status: 'open', total: 6000, number: 'WEBKLAR-0020' }))
|
||||
const result = await projektkontenFuerKunde('c1')
|
||||
assert.deepEqual(result.konten, [])
|
||||
assert.deepEqual(result.ohneProjekt, { berechnetCents: 6000, bezahltCents: 0, anzahl: 1 })
|
||||
})
|
||||
|
||||
test('Eine Rechnung, die der Zwischenspeicher nicht kennt, wird einzeln gelesen und zählt', async () => {
|
||||
seedProjekt('p1')
|
||||
seedPr('in_1', { projektId: 'p1' })
|
||||
await listInvoices() // Zwischenspeicher (noch ohne in_1) füllen
|
||||
st.invoices.set('in_1', stripeInvoice('in_1', { status: 'paid', total: 50000, paid: 50000, number: 'WEBKLAR-0001' }))
|
||||
const result = await projektkontenFuerKunde('c1')
|
||||
assert.deepEqual([result.stripeOk, result.konten[0].berechnetCents, result.konten[0].bezahltCents], [true, 50000, 50000])
|
||||
assert.deepEqual(result.ohneProjekt, { berechnetCents: 0, bezahltCents: 0, anzahl: 0 })
|
||||
})
|
||||
|
||||
test('Stripe-Ausfall: stripeOk false, Beträge 0, Zeit und Vereinbartes bleiben', async () => {
|
||||
mock.method(console, 'warn', () => {})
|
||||
seedTicket('t1', '100001')
|
||||
seedProjekt('p1', { ticketId: 't1' })
|
||||
seedBlatt('w1', 't1', 60)
|
||||
seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 })
|
||||
seedPr('in_1', { projektId: 'p1', auftragId: 'a1', art: 'anzahlung' })
|
||||
st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'kaputt' })
|
||||
const result = await projektkontenFuerKunde('c1')
|
||||
assert.equal(result.stripeOk, false)
|
||||
assert.deepEqual(result.ohneProjekt, { berechnetCents: 0, bezahltCents: 0, anzahl: 0 })
|
||||
const [konto] = result.konten
|
||||
assert.deepEqual([konto.berechnetCents, konto.bezahltCents, konto.offenCents, konto.vereinbart.festpreisCents], [0, 0, null, 80000])
|
||||
assert.deepEqual(konto.zeit, { minuten: 60, aboMinuten: 0, offeneMinuten: 60, satzCents: 10000, wertCents: 10000, offenerWertCents: 10000 })
|
||||
})
|
||||
|
||||
test('Zeitwert im Konto: je Berliner Tag gerundet, zum Satz der Aufwands-AB; der offene Wert ebenso', async () => {
|
||||
seedTicket('t1', '100001')
|
||||
seedProjekt('p1', { ticketId: 't1' })
|
||||
seedBlatt('w1', 't1', 15, { startDate: '05.10.2026', endDate: '05.10.2026' })
|
||||
seedBlatt('w2', 't1', 15, { startDate: '06.10.2026', endDate: '06.10.2026' })
|
||||
seedBlatt('w3', 't1', 15, { startDate: '06.10.2026', endDate: '06.10.2026' })
|
||||
seedBlatt('w4', 't1', 15, { startDate: '07.10.2026', endDate: '07.10.2026' })
|
||||
seedAuftrag('a1', { projektId: 'p1', art: 'aufwand', summeCents: null, positionenJson: '[]', stundensatzCents: 9990, schaetzungMinuten: 300 })
|
||||
seedPr('in_1', { projektId: 'p1', art: 'aufwand', arbeitsblattIds: ['w2'] })
|
||||
st.invoices.set('in_1', stripeInvoice('in_1', { status: 'draft', total: 2498 }))
|
||||
const { konten } = await projektkontenFuerKunde('c1')
|
||||
const [konto] = konten
|
||||
assert.deepEqual(konto.vereinbart, { festpreisCents: null, aufwand: { stundensatzCents: 9990, schaetzungMinuten: 300 } })
|
||||
assert.equal(konto.offenCents, null)
|
||||
// 99,90 €/Std.: 15 Minuten = 2498 Cent, 30 Minuten = 4995 Cent. Je Tag: 2498 + 4995 (zwei Blätter am 06.10.) + 2498 = 9991
|
||||
// (je Blatt wären es 9992, über alle 60 Minuten 9990). Offen ohne w2: drei Tage zu 15 Minuten = 3 × 2498 = 7494 (über 45 Minuten 7493)
|
||||
assert.deepEqual(konto.zeit, { minuten: 60, aboMinuten: 0, offeneMinuten: 45, satzCents: 9990, wertCents: 9991, offenerWertCents: 7494 })
|
||||
})
|
||||
|
||||
test('Schalter aus: ABs werden nicht gelesen, pilot nur für Pilotkunden', async () => {
|
||||
seedProjekt('p1')
|
||||
seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 })
|
||||
config.auftraege.mode = 'off'
|
||||
aw.resetListCalls()
|
||||
let result = await projektkontenFuerKunde('c1')
|
||||
assert.equal(result.auftraegeAktiv, false)
|
||||
assert.equal(result.konten[0].vereinbart.festpreisCents, null)
|
||||
assert.equal(aw.listCalls(config.collections.auftraege).length, 0)
|
||||
config.auftraege.mode = 'pilot'
|
||||
config.auftraege.pilotEmails = ['k@example.invalid']
|
||||
result = await projektkontenFuerKunde('c1')
|
||||
assert.equal(result.auftraegeAktiv, true)
|
||||
assert.equal(result.konten[0].vereinbart.festpreisCents, 80000)
|
||||
assert.equal((await projektkontenFuerKunde('c2')).auftraegeAktiv, false)
|
||||
})
|
||||
|
||||
test('kunde: Firmenname vor Name, Kundenart nur aus der Liste, unbekannter Kunde -> 404', async () => {
|
||||
aw.seed(config.collections.customers, { $id: 'c5', name: 'Hans Müller', companyName: 'Müller GmbH', email: 'h@example.invalid', kundenart: 'unternehmen' })
|
||||
aw.seed(config.collections.customers, { $id: 'c6', name: 'Max Muster', companyName: '', email: 'm@example.invalid', kundenart: 'komisch' })
|
||||
assert.deepEqual((await projektkontenFuerKunde('c5')).kunde, { id: 'c5', name: 'Müller GmbH', kundenart: 'unternehmen' })
|
||||
assert.deepEqual((await projektkontenFuerKunde('c6')).kunde, { id: 'c6', name: 'Max Muster', kundenart: '' })
|
||||
assert.deepEqual((await projektkontenFuerKunde('c1')).kunde, { id: 'c1', name: 'Kunde Eins', kundenart: '' })
|
||||
await assert.rejects(projektkontenFuerKunde('gibtsnicht'), { status: 404, message: 'Kunde nicht gefunden.' })
|
||||
})
|
||||
|
||||
// ---------- Projektkonto-Detail (Task 5) ----------
|
||||
|
||||
test('Detail: Konto, ABs, Rechnungen (neueste zuerst, Entwürfe dabei, Inaktive nicht) und offene Arbeitsblätter', async () => {
|
||||
seedTicket('t1', '100001')
|
||||
seedProjekt('p1', { ticketId: 't1' })
|
||||
seedBlatt('w1', 't1', 60) // in Rechnung in_2
|
||||
seedBlatt('w2', 't1', 30)
|
||||
seedBlatt('w3', 't1', 45, { countsToPlan: true }) // Abo-Zeit
|
||||
seedBlatt('w4', 't1', 15, { isComment: true }) // Kommentar
|
||||
seedBlatt('w5', 't1', 15, { startDate: '05.10.2026', endDate: '05.10.2026' })
|
||||
seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 })
|
||||
seedAuftrag('a2', { projektId: 'p1', status: 'entwurf', nummer: '', laufnummer: null, gesendetAm: null })
|
||||
seedPr('in_1', { auftragId: 'a1', art: 'anzahlung', rechnungsnummer: 'WEBKLAR-0001' })
|
||||
seedPr('in_2', { art: 'aufwand', arbeitsblattIds: ['w1'], rechnungsnummer: 'WEBKLAR-0002' })
|
||||
seedPr('in_3', { aktiv: false })
|
||||
seedPr('in_4', { art: 'festbetrag' })
|
||||
st.invoices.set('in_1', stripeInvoice('in_1', { status: 'open', total: 40000, number: 'WEBKLAR-0001', created: '2026-10-01' }))
|
||||
st.invoices.set('in_2', stripeInvoice('in_2', { status: 'paid', total: 10000, paid: 10000, number: 'WEBKLAR-0002', created: '2026-10-03' }))
|
||||
st.invoices.set('in_3', stripeInvoice('in_3', { status: 'void', total: 5000, number: 'WEBKLAR-0003', created: '2026-10-02' }))
|
||||
st.invoices.set('in_4', stripeInvoice('in_4', { status: 'draft', total: 5000, created: '2026-10-04' }))
|
||||
|
||||
const detail = await projektkontoDetail('p1')
|
||||
assert.deepEqual(Object.keys(detail).sort(), ['auftraege', 'konto', 'offeneArbeitsblaetter', 'rechnungen', 'stripeOk'])
|
||||
assert.equal(detail.stripeOk, true)
|
||||
const { konto } = detail
|
||||
assert.equal(konto.projektId, 'p1')
|
||||
assert.deepEqual(konto.vereinbart, { festpreisCents: 80000, aufwand: null })
|
||||
assert.deepEqual([konto.berechnetCents, konto.bezahltCents, konto.offenCents], [50000, 10000, 40000])
|
||||
assert.deepEqual(konto.zeit, { minuten: 105, aboMinuten: 45, offeneMinuten: 45, satzCents: 10000, wertCents: 17500, offenerWertCents: 7500 })
|
||||
assert.deepEqual(detail.rechnungen, [
|
||||
{ id: 'in_4', number: '', status: 'entwurf', createdDate: '2026-10-04', totalCents: 5000, paidCents: 0, art: 'festbetrag', auftragId: '' },
|
||||
{ id: 'in_2', number: 'WEBKLAR-0002', status: 'bezahlt', createdDate: '2026-10-03', totalCents: 10000, paidCents: 10000, art: 'aufwand', auftragId: '' },
|
||||
{ id: 'in_1', number: 'WEBKLAR-0001', status: 'offen', createdDate: '2026-10-01', totalCents: 40000, paidCents: 0, art: 'anzahlung', auftragId: 'a1' },
|
||||
])
|
||||
// sortiert nach Datum (w5 am 05.10., w2 am 06.10.); Abo-Zeit, Kommentar und Berechnetes fehlen
|
||||
assert.deepEqual(detail.offeneArbeitsblaetter.map((eintrag) => eintrag.id), ['w5', 'w2'])
|
||||
assert.deepEqual(detail.auftraege.map((auftrag) => [auftrag.id, auftrag.status]).sort(), [['a1', 'versendet'], ['a2', 'entwurf']])
|
||||
for (const auftrag of detail.auftraege) {
|
||||
for (const feld of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.ok(!(feld in auftrag), `${feld} darf nicht in der Antwort stehen`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Detail: unbekanntes Projekt und Projekt ohne Kunde -> 404', async () => {
|
||||
seedProjekt('p9', { customerId: '' })
|
||||
await assert.rejects(projektkontoDetail('gibtsnicht'), { status: 404, message: 'Projekt nicht gefunden.' })
|
||||
await assert.rejects(projektkontoDetail('p9'), { status: 404, message: 'Projekt nicht gefunden.' })
|
||||
})
|
||||
|
||||
test('Detail bei Stripe-Ausfall: stripeOk false, keine Rechnungen, Beträge 0, Arbeitsblätter und ABs bleiben', async () => {
|
||||
mock.method(console, 'warn', () => {})
|
||||
seedTicket('t1', '100001')
|
||||
seedProjekt('p1', { ticketId: 't1' })
|
||||
seedBlatt('w1', 't1', 60)
|
||||
seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 })
|
||||
seedPr('in_1', { auftragId: 'a1', art: 'anzahlung' })
|
||||
st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'kaputt' })
|
||||
const detail = await projektkontoDetail('p1')
|
||||
assert.equal(detail.stripeOk, false)
|
||||
assert.deepEqual(detail.rechnungen, [])
|
||||
assert.deepEqual([detail.konto.berechnetCents, detail.konto.bezahltCents, detail.konto.offenCents], [0, 0, null])
|
||||
assert.deepEqual(detail.offeneArbeitsblaetter.map((eintrag) => eintrag.id), ['w1'])
|
||||
assert.deepEqual(detail.auftraege.map((auftrag) => auftrag.id), ['a1'])
|
||||
})
|
||||
|
||||
test('Detail bei ausgeschaltetem Schalter: keine ABs, Vereinbart leer, ABs werden nicht gelesen', async () => {
|
||||
seedProjekt('p1')
|
||||
seedAuftrag('a1', { projektId: 'p1', summeCents: 80000 })
|
||||
config.auftraege.mode = 'off'
|
||||
aw.resetListCalls()
|
||||
const detail = await projektkontoDetail('p1')
|
||||
assert.deepEqual(detail.auftraege, [])
|
||||
assert.equal(detail.konto.vereinbart.festpreisCents, null)
|
||||
assert.equal(aw.listCalls(config.collections.auftraege).length, 0)
|
||||
})
|
||||
|
||||
// ---------- Abzeichen "abgerechnet" (Task 6) ----------
|
||||
|
||||
test('abgerechnetFuerTicket: Rechnungsnummer oder Entwurf, nur Blätter dieses Tickets, nur aktive Projektrechnungen des Kunden', async () => {
|
||||
seedTicket('t1', '100001')
|
||||
seedTicket('t2', '100002')
|
||||
seedBlatt('w1', 't1', 60)
|
||||
seedBlatt('w2', 't1', 30)
|
||||
seedBlatt('w3', 't1', 15)
|
||||
seedBlatt('w9', 't2', 45)
|
||||
seedPr('in_1', { rechnungsnummer: 'WEBKLAR-0007', arbeitsblattIds: ['w1'] })
|
||||
seedPr('in_2', { rechnungsnummer: '', arbeitsblattIds: ['w2', 'w9'] }) // Entwurf, ein Blatt gehört zu t2
|
||||
seedPr('in_3', { rechnungsnummer: 'WEBKLAR-0003', arbeitsblattIds: ['w3'], aktiv: false }) // zurückgenommen
|
||||
seedPr('in_4', { kundenId: 'c2', rechnungsnummer: 'WEBKLAR-0004', arbeitsblattIds: ['w1'] }) // anderer Kunde
|
||||
assert.deepEqual(await abgerechnetFuerTicket('t1'), { w1: 'WEBKLAR-0007', w2: 'Entwurf' })
|
||||
assert.deepEqual(await abgerechnetFuerTicket('t2'), { w9: 'Entwurf' })
|
||||
})
|
||||
|
||||
test('abgerechnetFuerTicket: unbekanntes Ticket, Ticket ohne Kunde oder nichts abgerechnet -> leer', async () => {
|
||||
seedTicket('t1', '100001')
|
||||
seedTicket('t3', '100003', '')
|
||||
seedBlatt('w1', 't1', 60)
|
||||
assert.deepEqual(await abgerechnetFuerTicket('gibtsnicht'), {})
|
||||
assert.deepEqual(await abgerechnetFuerTicket('t3'), {})
|
||||
assert.deepEqual(await abgerechnetFuerTicket('t1'), {})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
// Gemeinsame Testdaten für die Rechnungstests des Strangs L2. Erst nach useTestEnv() importieren (zieht config.js).
|
||||
import { config } from '../server/config.js'
|
||||
import { deleteDocument } from '../server/services/appwriteAdmin.js'
|
||||
import { createAuftrag, updateAuftrag } from '../server/services/auftraege/auftragStore.js'
|
||||
import { createProjektRechnung } from '../server/services/auftraege/projektRechnungenStore.js'
|
||||
|
||||
/** n Arbeitstage (Montag bis Freitag) ab start (YYYY-MM-DD, einschließlich). */
|
||||
export function werktage(start, n) {
|
||||
const tage = []
|
||||
for (let ms = Date.parse(`${start}T00:00:00Z`); tage.length < n; ms += 86_400_000) {
|
||||
const tag = new Date(ms)
|
||||
if (tag.getUTCDay() % 6 !== 0) tage.push(tag.toISOString().slice(0, 10))
|
||||
}
|
||||
return tage
|
||||
}
|
||||
|
||||
const deutsch = (tag) => tag.split('-').reverse().join('.')
|
||||
|
||||
/**
|
||||
* Kunden k1 (fest, mit Anschrift: Frau Wolf), k2 (fest), lead1 (Interessent); Projekte p1 (k1, Ticket t1), p2 (k2, Ticket t2),
|
||||
* p3 (k1, Ticket t4); Tickets t1 bis t4 (t2 gehört k2); Arbeitsblätter ws1 bis wsT3 (Oktober) und wl01 bis wl20 (Großprojekt).
|
||||
*/
|
||||
export function seedRechnungsDaten(aw) {
|
||||
const kunde = (id, over = {}) =>
|
||||
aw.seed('customers', { $id: id, email: `${id}@example.invalid`, name: `Kunde ${id}`, customerStatus: 'active', status: 'active', street: 'Hauptstr. 1', postalCode: '10115', city: 'Berlin', ...over })
|
||||
kunde('k1', { name: 'Anelia Wolf', email: 'wolf@example.invalid' })
|
||||
kunde('k2')
|
||||
kunde('lead1', { customerStatus: 'lead' })
|
||||
const projekt = (id, customerId, projectName, ticketId) => aw.seed('websiteProjects', { $id: id, customerId, projectName, ticketId })
|
||||
projekt('p1', 'k1', 'Website anelia-wolf.com', 't1')
|
||||
projekt('p2', 'k2', 'Fremdes Projekt', 't2')
|
||||
projekt('p3', 'k1', 'Großprojekt', 't4')
|
||||
for (const [id, customerId, woid] of [['t1', 'k1', 'WO-100'], ['t2', 'k2', 'WO-200'], ['t3', 'k1', 'WO-101'], ['t4', 'k1', 'WO-102']]) {
|
||||
aw.seed('workorders', { $id: id, customerId, woid, topic: `Ticket ${woid}`, status: 'occupied' })
|
||||
}
|
||||
const blatt = (id, workorderId, startDate, totalTime, details, over = {}) =>
|
||||
aw.seed('worksheets', { $id: id, workorderId, woid: 'WO', startDate, totalTime, details, serviceType: 'remote', countsToPlan: false, isComment: false, ...over })
|
||||
blatt('ws1', 't1', '06.10.2026', 90, 'Startseite')
|
||||
blatt('ws2', 't1', '06.10.2026', 45, 'Kontaktformular')
|
||||
blatt('ws3', 't1', '07.10.2026', 60, 'Impressum')
|
||||
blatt('wsAbo', 't1', '07.10.2026', 60, 'Wartung', { countsToPlan: true })
|
||||
blatt('wsKomm', 't1', '07.10.2026', 30, 'Notiz', { isComment: true })
|
||||
blatt('wsNull', 't1', '07.10.2026', 0, 'Leer')
|
||||
blatt('wsFremd', 't2', '07.10.2026', 60, 'Fremd')
|
||||
blatt('wsT3', 't3', '08.10.2026', 30, 'Zusatzticket')
|
||||
// Großprojekt: 20 Arbeitstage (vier Wochen, KW 45 bis 48) zu je 60 Minuten
|
||||
werktage('2026-11-02', 20).forEach((tag, i) => blatt(`wl${String(i + 1).padStart(2, '0')}`, 't4', deutsch(tag), 60, `Etappe ${i + 1}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine versendete Festpreis-AB des Kunden k1 zu p1 (80.000 Cent: Website 600 €, Texte 2 × 100 €; Anzahlung 50 %; AB-2026-0002,
|
||||
* gesendet am 06.10.2026) über die L0-Speicher anlegen. over gilt für createAuftrag, patch für das anschließende updateAuftrag
|
||||
* (Status, Nummer, bestaetigtAm, …).
|
||||
*/
|
||||
export async function legeAuftragAn(over = {}, patch = {}) {
|
||||
const roh = await createAuftrag({
|
||||
kundenId: 'k1', projektId: 'p1', titel: 'Website anelia-wolf.com', zeitquelle: [], art: 'festpreis',
|
||||
positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 60000 }, { beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000 }],
|
||||
stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
|
||||
zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14,
|
||||
auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '', erstelltVon: 'kenso@webklar.com',
|
||||
...over,
|
||||
})
|
||||
return updateAuftrag(roh.id, { status: 'versendet', nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2, gesendetAm: '2026-10-06T08:15:00.000Z', ...patch })
|
||||
}
|
||||
|
||||
const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000)
|
||||
|
||||
/**
|
||||
* Eine Projektrechnung wie nach createProjectInvoice: Stripe-Rechnung im Nachbau (status draft, open, paid oder void; ohneStripe: die
|
||||
* Rechnung gibt es bei Stripe nicht, z. B. im Dashboard gelöscht) plus aktiver Projektdatensatz. docNummer ist die Nummer im Datensatz
|
||||
* (Vorgabe: die der Rechnung, bei einem Entwurf leer).
|
||||
*/
|
||||
export async function legeRechnungAn(st, id, {
|
||||
status = 'draft', nummer = 'WEBKLAR-0007', total = 40000, art = 'aufwand', auftragId = '', projektId = 'p1',
|
||||
arbeitsblattIds = [], finalisiert = '2026-10-08T10:00:00.000Z', ohneStripe = false, docNummer,
|
||||
} = {}) {
|
||||
const entwurf = status === 'draft'
|
||||
if (!ohneStripe) {
|
||||
st.invoices.set(id, {
|
||||
id, object: 'invoice', number: entwurf ? null : nummer, status, customer: 'cus_x', created: unix(finalisiert), total, amount_due: total,
|
||||
amount_paid: status === 'paid' ? total : 0, amount_remaining: status === 'paid' ? 0 : total,
|
||||
status_transitions: { finalized_at: entwurf ? null : unix(finalisiert) }, metadata: { art }, lines: { object: 'list', data: [], has_more: false },
|
||||
})
|
||||
}
|
||||
await createProjektRechnung(id, {
|
||||
kundenId: 'k1', projektId, auftragId, art, betragCents: total, arbeitsblattIds,
|
||||
rechnungsnummer: docNummer ?? (entwurf ? '' : nummer), aktiv: true, erstelltVon: 'k', erstelltAm: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
/** Aufträge und Projektdatensätze löschen, Stripe-Nachbau (Rechnungen, Kunden, Aufrufe) leeren. */
|
||||
export async function leereRechnungsDaten(aw, st) {
|
||||
for (const sammlung of [config.collections.auftraege, config.collections.projektRechnungen]) {
|
||||
for (const id of aw.docs(sammlung).keys()) await deleteDocument(sammlung, id)
|
||||
}
|
||||
st.invoices.clear()
|
||||
st.customers.clear()
|
||||
st.calls.length = 0
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
|
||||
|
||||
// Nachbau von Appwrite mit dem Schema und den Datumsfeldern der neuen Collections und dem Standard-Limit 25 (wie in den Tests von L0)
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
|
||||
datetimes: AUFTRAEGE_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { seedRechnungsDaten, legeAuftragAn, legeRechnungAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
|
||||
const { baueRechnung } = await import('../server/services/auftraege/rechnungsbau.js')
|
||||
const { getProjektRechnung, updateProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
|
||||
const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
|
||||
after(() => Promise.all([aw.close(), st.close()]))
|
||||
seedRechnungsDaten(aw)
|
||||
config.auftraege.mode = 'on'
|
||||
beforeEach(() => leereRechnungsDaten(aw, st))
|
||||
|
||||
const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt
|
||||
const SCHALTER_AUS = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.'
|
||||
const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }]
|
||||
const wirft = (promise, status, message) =>
|
||||
assert.rejects(promise, (err) => {
|
||||
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
|
||||
assert.equal(err.message, message)
|
||||
return true
|
||||
})
|
||||
|
||||
test('baueRechnung frei: freie Positionen, nur Kopf-Metadaten, keine Zusatzfelder, kein Projektdatensatz', async () => {
|
||||
const e = await baueRechnung({ customerId: 'k1', items: POSTEN, createdBy: 'kenso@webklar.com' })
|
||||
assert.equal(e.art, 'frei')
|
||||
assert.equal(e.kunde.$id, 'k1')
|
||||
assert.equal(e.projekt, null)
|
||||
assert.equal(e.auftrag, null)
|
||||
assert.deepEqual(e.positionen, [{ schluessel: 'pos-1', beschreibung: 'Domain', menge: 1, einzelpreisCents: 1200, betragCents: 1200 }])
|
||||
assert.equal(e.summeCents, 1200)
|
||||
assert.equal(e.leistungszeitraum, '')
|
||||
assert.deepEqual(e.customFields, [])
|
||||
assert.deepEqual(e.metadata, { webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'frei' })
|
||||
assert.equal(e.projektRechnung, null)
|
||||
assert.equal((await baueRechnung({ customerId: 'k1', items: POSTEN })).metadata.erstelltVon, '')
|
||||
})
|
||||
|
||||
test('baueRechnung frei: Projekt, Auftrag, Arbeitsblätter und Anzahlung der Eingabe bleiben ohne Wirkung', async () => {
|
||||
const e = await baueRechnung({ customerId: 'k1', art: 'frei', items: POSTEN, projektId: 'p1', auftragId: 'a1', arbeitsblattIds: ['ws1'], anzahlung: { prozent: 10 } })
|
||||
assert.equal(e.projekt, null)
|
||||
assert.equal(e.auftrag, null)
|
||||
assert.equal(e.projektRechnung, null)
|
||||
assert.deepEqual(e.customFields, [])
|
||||
assert.deepEqual(Object.keys(e.metadata).sort(), ['art', 'erstelltVon', 'quelle', 'webklarCustomerId'])
|
||||
})
|
||||
|
||||
test('baueRechnung: Leistungszeitraum ist optional, wird getrimmt und hat höchstens 140 Zeichen', async () => {
|
||||
const e = await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: ' Oktober 2026 ' })
|
||||
assert.equal(e.leistungszeitraum, 'Oktober 2026')
|
||||
assert.deepEqual(e.customFields, [{ name: 'Leistungszeitraum', value: 'Oktober 2026' }])
|
||||
assert.equal((await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: 'z'.repeat(140) })).leistungszeitraum.length, 140)
|
||||
for (const leer of [undefined, null, '', ' ']) assert.deepEqual((await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: leer })).customFields, [])
|
||||
const MELDUNG = 'Der Leistungszeitraum muss ein Text mit höchstens 140 Zeichen sein.'
|
||||
for (const leistungszeitraum of ['z'.repeat(141), 5, {}]) await wirft(baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum }), 400, MELDUNG)
|
||||
})
|
||||
|
||||
test('baueRechnung: unbekannte Art, unbekannter oder nicht fester Kunde, ungültige Positionen -> 400 ohne Stripe-Aufruf', async () => {
|
||||
for (const art of ['quatsch', 'FREI', 5, {}]) await wirft(baueRechnung({ customerId: 'k1', art, items: POSTEN }), 400, 'Unbekannte Rechnungsart.')
|
||||
for (const customerId of [undefined, '', 'gibtEsNicht', '../x', 5]) await wirft(baueRechnung({ customerId, items: POSTEN }), 400, 'Kunde nicht gefunden.')
|
||||
await wirft(baueRechnung({ customerId: 'lead1', items: POSTEN }), 400, 'Rechnungen nur für feste Kunden.')
|
||||
await wirft(baueRechnung({ customerId: 'k1' }), 400, 'Eine Rechnung braucht 1 bis 20 Positionen.')
|
||||
assert.equal(st.calls.length, 0)
|
||||
})
|
||||
|
||||
test('baueRechnung festbetrag: Projekt, Zusatzfeld Projekt, Metadaten und Projektdatensatz', async () => {
|
||||
const items = [{ description: 'Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }]
|
||||
const e = await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items, createdBy: 'kenso@webklar.com' })
|
||||
assert.deepEqual(e.projekt, { id: 'p1', name: 'Website anelia-wolf.com', ticketId: 't1' })
|
||||
assert.equal(e.auftrag, null)
|
||||
assert.equal(e.summeCents, 50000)
|
||||
assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }])
|
||||
assert.deepEqual(e.metadata, { webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'festbetrag', projektId: 'p1', projektName: 'Website anelia-wolf.com' })
|
||||
const { erstelltAm, ...rest } = e.projektRechnung
|
||||
assert.match(erstelltAm, /^\d{4}-\d{2}-\d{2}T/)
|
||||
assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: '', art: 'festbetrag', betragCents: 50000, arbeitsblattIds: [], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' })
|
||||
// eine genannte auftragId bleibt bei dieser Art ohne Wirkung
|
||||
assert.equal((await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', auftragId: 'a1', items })).auftrag, null)
|
||||
})
|
||||
|
||||
test('baueRechnung festbetrag: lange Projektnamen werden auf 140 Zeichen gekürzt, leere ersetzt', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'plang', customerId: 'k1', projectName: 'P'.repeat(200), ticketId: '' })
|
||||
aw.seed('websiteProjects', { $id: 'psub', customerId: 'k1', projectName: '', subdomain: 'wolf-test', ticketId: '' })
|
||||
aw.seed('websiteProjects', { $id: 'pleer', customerId: 'k1', ticketId: '' })
|
||||
const e = await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'plang', items: POSTEN })
|
||||
assert.equal(e.customFields[0].value.length, 140)
|
||||
assert.ok(e.customFields[0].value.endsWith('P…'))
|
||||
assert.equal(e.metadata.projektName, e.customFields[0].value)
|
||||
const name = async (projektId) => (await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId, items: POSTEN })).customFields[0].value
|
||||
assert.equal(await name('psub'), 'wolf-test')
|
||||
assert.equal(await name('pleer'), 'Projekt')
|
||||
})
|
||||
|
||||
test('baueRechnung festbetrag: Projekt ist Pflicht, fremde und unbekannte Projekte sind 404 (kein Unterschied), ungültige Positionen 400', async () => {
|
||||
const mit = (projektId, items = POSTEN) => baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId, items })
|
||||
for (const projektId of [undefined, '', null, 5]) await wirft(mit(projektId), 400, 'Für diese Rechnungsart ist ein Projekt nötig.')
|
||||
for (const projektId of ['gibtEsNicht', 'p2', '../x']) await wirft(mit(projektId), 404, 'Projekt nicht gefunden.')
|
||||
await wirft(mit('p1', []), 400, 'Eine Rechnung braucht 1 bis 20 Positionen.')
|
||||
assert.equal(st.calls.length, 0)
|
||||
})
|
||||
|
||||
const eingabe = (over = {}) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', createdBy: 'kenso@webklar.com', ...over })
|
||||
const aufwandAb = (patch = {}, over = {}) =>
|
||||
legeAuftragAn(
|
||||
{ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 12000, schaetzungMinuten: 600, zahlungsplan: { art: 'monatlich', prozent: null, text: '' }, zeitquelle: ['t1', 't3'], ...over },
|
||||
{ nummer: 'AB-2026-0005', laufnummer: 5, status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z', ...patch }
|
||||
)
|
||||
|
||||
test('baueRechnung aufwand: alle offenen Blätter des Projekttickets, eine Zeile je Tag, Satz 100 €, Zeitraum, Zusatzfeld', async () => {
|
||||
const e = await baueRechnung(eingabe())
|
||||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 22500], ['2026-10-07', 10000]])
|
||||
assert.equal(e.positionen[0].beschreibung, `06.10.2026: Startseite; Kontaktformular – 2,25 Std. à 100,00${NBSP}€`)
|
||||
assert.equal(e.positionen[1].beschreibung, `07.10.2026: Impressum – 1 Std. à 100,00${NBSP}€`)
|
||||
assert.equal(e.summeCents, 32500)
|
||||
assert.equal(e.leistungszeitraum, '06.10.–07.10.2026')
|
||||
assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Leistungszeitraum', value: '06.10.–07.10.2026' }])
|
||||
assert.deepEqual(e.projekt, { id: 'p1', name: 'Website anelia-wolf.com', ticketId: 't1' })
|
||||
assert.equal(e.auftrag, null)
|
||||
assert.equal(e.metadata.art, 'aufwand')
|
||||
assert.equal(e.metadata.projektId, 'p1')
|
||||
const { erstelltAm, ...rest } = e.projektRechnung
|
||||
assert.ok(erstelltAm)
|
||||
assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: '', art: 'aufwand', betragCents: 32500, arbeitsblattIds: ['ws1', 'ws2', 'ws3'], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' })
|
||||
assert.equal(st.calls.length, 0, 'Aufwand liest nichts bei Stripe')
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: ausgewählte Blätter (auch von einem anderen Ticket des Kunden), Zusatzposition, eigener Zeitraum und Zeilentext', async () => {
|
||||
const e = await baueRechnung(eingabe({
|
||||
arbeitsblattIds: ['ws2', 'wsT3', 'ws2'], items: POSTEN, leistungszeitraum: ' Oktober 2026 ',
|
||||
zeilentexte: { '2026-10-08': '08.10.2026: Zusatzticket Impressum' },
|
||||
}))
|
||||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 7500], ['2026-10-08', 5000], ['zusatz-1', 1200]])
|
||||
assert.equal(e.positionen[1].beschreibung, `08.10.2026: Zusatzticket Impressum – 0,5 Std. à 100,00${NBSP}€`)
|
||||
assert.equal(e.summeCents, 13700)
|
||||
assert.equal(e.leistungszeitraum, 'Oktober 2026')
|
||||
assert.deepEqual(e.projektRechnung.arbeitsblattIds, ['ws2', 'wsT3'])
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: ungültige Auswahl -> 400 ohne Stripe-Aufruf (fremde, unbekannte, unabrechenbare, zu viele Blätter)', async () => {
|
||||
const mit = (arbeitsblattIds) => baueRechnung(eingabe({ arbeitsblattIds }))
|
||||
await wirft(mit([]), 400, 'Keine Arbeitsblätter ausgewählt.')
|
||||
for (const kaputt of ['ws1', [5], { 0: 'ws1' }]) await wirft(mit(kaputt), 400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.')
|
||||
for (const id of ['gibtEsNicht', 'wsFremd', '../x']) await wirft(mit(['ws1', id]), 400, 'Arbeitsblatt nicht gefunden.')
|
||||
for (const id of ['wsAbo', 'wsKomm', 'wsNull']) {
|
||||
await wirft(mit(['ws1', id]), 400, 'Mindestens ein Arbeitsblatt ist nicht abrechenbar (Abo-Zeit, Kommentar oder ohne Zeit).')
|
||||
}
|
||||
await wirft(mit(Array.from({ length: 401 }, (_, i) => `ws${i}`)), 400, 'Zu viele Arbeitsblätter ausgewählt (höchstens 400).')
|
||||
assert.equal(st.calls.length, 0)
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: bereits abgerechnete Blätter -> 409 mit der Nummer von Stripe oder Entwurf, stornierte geben frei, Standardauswahl nur Offenes', async () => {
|
||||
await legeRechnungAn(st, 'in_alt1', { status: 'open', nummer: 'WEBKLAR-0009', arbeitsblattIds: ['ws1'] })
|
||||
const mit = () => baueRechnung(eingabe({ arbeitsblattIds: ['ws1', 'ws3'] }))
|
||||
await wirft(mit(), 409, 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).')
|
||||
st.invoices.set('in_alt1', { ...st.invoices.get('in_alt1'), status: 'draft', number: null })
|
||||
await wirft(mit(), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
|
||||
assert.deepEqual((await baueRechnung(eingabe())).projektRechnung.arbeitsblattIds, ['ws2', 'ws3'])
|
||||
await updateProjektRechnung('in_alt1', { aktiv: false })
|
||||
assert.equal((await mit()).positionen.length, 2)
|
||||
await legeRechnungAn(st, 'in_alt2', { arbeitsblattIds: ['ws1', 'ws2', 'ws3'] })
|
||||
await wirft(baueRechnung(eingabe()), 409, 'Keine offenen Arbeitsblätter zum Abrechnen.')
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: Selbstheilung - eine im Stripe-Dashboard stornierte oder gelöschte Rechnung hält ihre Blätter nicht fest, eine fehlende Nummer wird nachgetragen', async () => {
|
||||
await legeRechnungAn(st, 'in_leer1', { status: 'void', nummer: 'WEBKLAR-0010', arbeitsblattIds: ['ws1'] })
|
||||
await legeRechnungAn(st, 'in_leer2', { ohneStripe: true, arbeitsblattIds: ['ws2'] })
|
||||
const e = await baueRechnung(eingabe({ arbeitsblattIds: ['ws1', 'ws2'] }))
|
||||
assert.deepEqual([e.positionen.length, e.summeCents], [1, 22500])
|
||||
for (const id of ['in_leer1', 'in_leer2']) assert.equal((await getProjektRechnung(id)).aktiv, false, id)
|
||||
// auch die Standardauswahl heilt, bevor sie "nichts offen" meldet
|
||||
await legeRechnungAn(st, 'in_leer3', { status: 'void', nummer: 'WEBKLAR-0012', arbeitsblattIds: ['ws1', 'ws2', 'ws3'] })
|
||||
assert.deepEqual((await baueRechnung(eingabe())).projektRechnung.arbeitsblattIds, ['ws1', 'ws2', 'ws3'])
|
||||
assert.equal((await getProjektRechnung('in_leer3')).aktiv, false)
|
||||
// bei Stripe finalisiert, im Datensatz fehlt die Nummer: die Meldung nennt die Nummer von Stripe, der Datensatz wird nachgetragen
|
||||
await legeRechnungAn(st, 'in_offen', { status: 'open', nummer: 'WEBKLAR-0011', docNummer: '', arbeitsblattIds: ['ws3'] })
|
||||
await wirft(baueRechnung(eingabe({ arbeitsblattIds: ['ws3'] })), 409, 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0011).')
|
||||
assert.equal((await getProjektRechnung('in_offen')).rechnungsnummer, 'WEBKLAR-0011')
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: Satz, Auftrag und Zeitquelle aus der jüngsten gültigen Aufwands-AB; ohne Schalter bleibt es bei 100 €', async () => {
|
||||
const ab = await aufwandAb()
|
||||
const e = await baueRechnung(eingabe())
|
||||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['2026-10-06', 27000], ['2026-10-07', 12000], ['2026-10-08', 6000]])
|
||||
assert.ok(e.positionen[0].beschreibung.endsWith(` – 2,25 Std. à 120,00${NBSP}€`))
|
||||
assert.equal(e.leistungszeitraum, '06.10.–08.10.2026')
|
||||
assert.equal(e.auftrag.nummer, 'AB-2026-0005')
|
||||
assert.deepEqual(e.customFields.map((f) => f.name), ['Projekt', 'Auftrag', 'Leistungszeitraum'])
|
||||
assert.equal(e.customFields[1].value, 'AB-2026-0005 vom 06.10.2026')
|
||||
assert.deepEqual([e.metadata.auftragId, e.metadata.auftragNummer], [ab.id, 'AB-2026-0005'])
|
||||
assert.equal(e.projektRechnung.auftragId, ab.id)
|
||||
// ein neuerer, aber stornierter Aufwandsauftrag ändert nichts
|
||||
await aufwandAb({ nummer: 'AB-2026-0006', status: 'storniert' }, { stundensatzCents: 15000 })
|
||||
assert.equal((await baueRechnung(eingabe())).auftrag.nummer, 'AB-2026-0005')
|
||||
config.auftraege.mode = 'off'
|
||||
try {
|
||||
const ohne = await baueRechnung(eingabe({ arbeitsblattIds: ['ws1'] }))
|
||||
assert.equal(ohne.auftrag, null)
|
||||
assert.equal(ohne.positionen[0].betragCents, 15000) // 90 Minuten zu 100 €
|
||||
await wirft(baueRechnung(eingabe({ auftragId: ab.id })), 400, SCHALTER_AUS)
|
||||
} finally {
|
||||
config.auftraege.mode = 'on'
|
||||
}
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: ausdrücklich genannter Auftrag muss passen (Aufwand, eigener Kunde, gültig, gleiches Projekt)', async () => {
|
||||
const festpreis = await legeAuftragAn()
|
||||
const fremd = await legeAuftragAn({ kundenId: 'k2', projektId: 'p2' }, { nummer: 'AB-2026-0008' })
|
||||
const entwurf = await aufwandAb({ nummer: 'AB-2026-0009', status: 'entwurf' })
|
||||
const anderes = await aufwandAb({ nummer: 'AB-2026-0010' }, { projektId: 'p3' })
|
||||
const mit = (auftragId) => baueRechnung(eingabe({ auftragId }))
|
||||
await wirft(mit(festpreis.id), 400, 'Eine Aufwandsrechnung passt nur zu einer Aufwands-Auftragsbestätigung.')
|
||||
await wirft(mit(fremd.id), 404, 'Auftrag nicht gefunden.')
|
||||
await wirft(mit('gibtEsNicht'), 404, 'Auftrag nicht gefunden.')
|
||||
await wirft(mit(entwurf.id), 400, 'Die Auftragsbestätigung ist nicht mehr gültig.')
|
||||
await wirft(mit(anderes.id), 400, 'Der Auftrag gehört nicht zu diesem Projekt.')
|
||||
const gueltig = await aufwandAb({ nummer: 'AB-2026-0011' })
|
||||
assert.equal((await mit(gueltig.id)).auftrag.id, gueltig.id)
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: Zusatzpositionen zählen zu den 20 Zeilen, die ältesten Wochen werden zu KW-Zeilen', async () => {
|
||||
const ohne = await baueRechnung(eingabe({ projektId: 'p3' }))
|
||||
assert.equal(ohne.positionen.length, 20)
|
||||
assert.equal(ohne.positionen[0].schluessel, '2026-11-02')
|
||||
const mit = await baueRechnung(eingabe({ projektId: 'p3', items: POSTEN }))
|
||||
assert.equal(mit.positionen.length, 17)
|
||||
assert.deepEqual([mit.positionen[0].schluessel, mit.positionen[1].schluessel, mit.positionen.at(-1).schluessel], ['KW-2026-45', '2026-11-09', 'zusatz-1'])
|
||||
assert.equal(mit.summeCents, 20 * 10000 + 1200)
|
||||
assert.ok(mit.positionen.every((p) => p.beschreibung.length <= 200))
|
||||
})
|
||||
|
||||
test('baueRechnung aufwand: Projekt ist Pflicht, fremdes Projekt 404, Projekt ohne offene Blätter 409, Zeilentexte müssen ein Objekt mit Texten sein', async () => {
|
||||
for (const projektId of [undefined, '', null]) await wirft(baueRechnung(eingabe({ projektId })), 400, 'Für diese Rechnungsart ist ein Projekt nötig.')
|
||||
await wirft(baueRechnung(eingabe({ projektId: 'p2' })), 404, 'Projekt nicht gefunden.')
|
||||
aw.seed('websiteProjects', { $id: 'pohne', customerId: 'k1', projectName: 'Ohne Ticket', ticketId: '' })
|
||||
await wirft(baueRechnung(eingabe({ projektId: 'pohne' })), 409, 'Keine offenen Arbeitsblätter zum Abrechnen.')
|
||||
for (const zeilentexte of [['x'], 'x', 5, { '2026-10-06': 5 }]) {
|
||||
await wirft(baueRechnung(eingabe({ zeilentexte })), 400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.')
|
||||
}
|
||||
assert.equal((await baueRechnung(eingabe({ zeilentexte: null }))).positionen.length, 2)
|
||||
})
|
||||
|
||||
/** Frühere Rechnung zur AB (Art anzahlung, wenn nicht anders gesagt): Stripe-Rechnung im Nachbau plus aktiver Projektdatensatz. */
|
||||
const fruehereRechnung = (ab, { id, art = 'anzahlung', status = 'open', ...optionen }) => legeRechnungAn(st, id, { art, status, auftragId: ab.id, ...optionen })
|
||||
|
||||
test('baueRechnung anzahlung: Position aus dem Zahlungsplan, Zusatzfelder Projekt und Auftrag, Metadaten, Projektdatensatz', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const e = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, createdBy: 'kenso@webklar.com' })
|
||||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.beschreibung, p.betragCents]), [
|
||||
['anzahlung', 'Anzahlung 50 % gemäß Auftragsbestätigung AB-2026-0002 vom 06.10.2026 (Website anelia-wolf.com)', 40000],
|
||||
])
|
||||
assert.equal(e.summeCents, 40000)
|
||||
assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }, { name: 'Auftrag', value: 'AB-2026-0002 vom 06.10.2026' }])
|
||||
assert.deepEqual(e.metadata, {
|
||||
webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'anzahlung',
|
||||
projektId: 'p1', projektName: 'Website anelia-wolf.com', auftragId: ab.id, auftragNummer: 'AB-2026-0002',
|
||||
})
|
||||
assert.equal(e.auftrag.id, ab.id)
|
||||
assert.equal(e.projekt.id, 'p1')
|
||||
const { erstelltAm, ...rest } = e.projektRechnung
|
||||
assert.ok(erstelltAm)
|
||||
assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: ab.id, art: 'anzahlung', betragCents: 40000, arbeitsblattIds: [], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' })
|
||||
assert.equal(st.calls.length, 0, 'ohne frühere Rechnungen gibt es nichts bei Stripe zu lesen')
|
||||
})
|
||||
|
||||
test('baueRechnung anzahlung: Prozent oder Betrag änderbar; nur finalisierte Rechnungen mindern das Offene', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const lauf = (anzahlung) => baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung })
|
||||
assert.ok((await lauf({ prozent: 25, bezeichnung: 'Teilrechnung' })).positionen[0].beschreibung.startsWith('Teilrechnung 25 % gemäß'))
|
||||
assert.equal((await lauf({ betragCents: 25000 })).summeCents, 25000)
|
||||
await fruehereRechnung(ab, { id: 'in_f1', status: 'open', total: 40000 })
|
||||
await fruehereRechnung(ab, { id: 'in_f2', status: 'void', total: 99000, nummer: 'WEBKLAR-0008' }) // storniert: zählt nicht
|
||||
await fruehereRechnung(ab, { id: 'in_f3', status: 'draft', total: 5000 }) // Entwurf einer Anzahlung: zählt nicht
|
||||
assert.equal((await lauf(undefined)).summeCents, 40000)
|
||||
assert.equal((await getProjektRechnung('in_f2')).aktiv, false, 'die im Dashboard stornierte Rechnung wurde freigegeben')
|
||||
await wirft(lauf({ betragCents: 40001 }), 400, `Die Anzahlung darf höchstens 400,00${NBSP}€ betragen (Summe der Auftragsbestätigung minus bereits berechnet).`)
|
||||
await fruehereRechnung(ab, { id: 'in_f4', status: 'paid', total: 40000, nummer: 'WEBKLAR-0009' })
|
||||
await wirft(lauf(undefined), 409, 'Nichts mehr offen.')
|
||||
for (const kaputt of ['viel', 5, ['x']]) await wirft(lauf(kaputt), 400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.')
|
||||
})
|
||||
|
||||
test('baueRechnung anzahlung: ein Entwurf der Schlussrechnung zur AB blockiert weitere Anzahlungen (409)', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
await fruehereRechnung(ab, { id: 'in_s1', status: 'draft', art: 'schluss', total: 40000 })
|
||||
await wirft(
|
||||
baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id }),
|
||||
409,
|
||||
'Zu dieser Auftragsbestätigung gibt es schon einen Entwurf der Schlussrechnung. Erst den Entwurf senden oder löschen.'
|
||||
)
|
||||
})
|
||||
|
||||
test('baueRechnung anzahlung/schluss: Voraussetzungen (Schalter, Auftrag, Festpreis, Status, Kunde, Projekt)', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
for (const art of ['anzahlung', 'schluss']) {
|
||||
await wirft(baueRechnung({ customerId: 'k1', art }), 400, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
|
||||
await wirft(baueRechnung({ customerId: 'k1', art, auftragId: 'gibtEsNicht' }), 404, 'Auftrag nicht gefunden.')
|
||||
await wirft(baueRechnung({ customerId: 'k2', art, auftragId: ab.id }), 404, 'Auftrag nicht gefunden.')
|
||||
await wirft(baueRechnung({ customerId: 'k1', art, auftragId: ab.id, projektId: 'p3' }), 400, 'Der Auftrag gehört nicht zu diesem Projekt.')
|
||||
config.auftraege.mode = 'off'
|
||||
try {
|
||||
await wirft(baueRechnung({ customerId: 'k1', art, auftragId: ab.id }), 400, SCHALTER_AUS)
|
||||
await wirft(baueRechnung({ customerId: 'k1', art }), 400, SCHALTER_AUS)
|
||||
} finally {
|
||||
config.auftraege.mode = 'on'
|
||||
}
|
||||
}
|
||||
const aufwand = await legeAuftragAn({ art: 'aufwand', positionen: [], summeCents: null, stundensatzCents: 10000 }, { nummer: 'AB-2026-0006' })
|
||||
await wirft(baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: aufwand.id }), 400, 'Anzahlung und Schlussrechnung gehen nur mit einer Festpreis-Auftragsbestätigung.')
|
||||
for (const [i, status] of ['entwurf', 'storniert', 'ersetzt', 'widerrufen', 'abgerechnet'].entries()) {
|
||||
const x = await legeAuftragAn({}, { nummer: `AB-2026-010${i}`, status })
|
||||
for (const art of ['anzahlung', 'schluss']) await wirft(baueRechnung({ customerId: 'k1', art, auftragId: x.id }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
|
||||
}
|
||||
const bestaetigt = await legeAuftragAn({}, { nummer: 'AB-2026-0007', status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z' })
|
||||
assert.equal((await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: bestaetigt.id })).summeCents, 40000)
|
||||
})
|
||||
|
||||
test('baueRechnung schluss: AB-Positionen, Zusatz und eine Minuszeile je finalisierter, nicht stornierter früherer Rechnung', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
await fruehereRechnung(ab, { id: 'in_f1', status: 'open', total: 40000, nummer: 'WEBKLAR-0007', finalisiert: '2026-10-08T10:00:00.000Z' })
|
||||
await fruehereRechnung(ab, { id: 'in_f2', status: 'void', total: 20000, nummer: 'WEBKLAR-0008' })
|
||||
const e = await baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id, items: [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1500 }] })
|
||||
assert.deepEqual(e.positionen.map((p) => [p.schluessel, p.betragCents]), [['ab-1', 60000], ['ab-2', 20000], ['zusatz-1', 1500], ['abzug-in_f1', -40000]])
|
||||
assert.equal(e.positionen.at(-1).beschreibung, 'abzüglich Anzahlung, Rechnung WEBKLAR-0007 vom 08.10.2026')
|
||||
assert.equal(e.summeCents, 41500)
|
||||
assert.deepEqual(e.customFields.map((f) => f.name), ['Projekt', 'Auftrag'])
|
||||
assert.equal(e.projektRechnung.art, 'schluss')
|
||||
assert.equal(e.projektRechnung.betragCents, 41500)
|
||||
assert.equal(e.projektRechnung.auftragId, ab.id)
|
||||
})
|
||||
|
||||
test('baueRechnung schluss: offener Entwurf zur AB blockiert (409), Rechnungen fremder ABs und bei Stripe gelöschte zählen nicht, nichts mehr offen -> 409', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const andere = await legeAuftragAn({}, { nummer: 'AB-2026-0003' })
|
||||
await fruehereRechnung(andere, { id: 'in_g1', status: 'draft', total: 11111 })
|
||||
await fruehereRechnung(andere, { id: 'in_g2', status: 'paid', total: 22222, nummer: 'WEBKLAR-0005' })
|
||||
const lauf = () => baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
|
||||
assert.equal((await lauf()).summeCents, 80000)
|
||||
await fruehereRechnung(ab, { id: 'in_h1', status: 'draft', total: 40000 })
|
||||
await wirft(lauf(), 409, `Erst den Entwurf über 400,00${NBSP}€ senden oder löschen.`)
|
||||
await updateProjektRechnung('in_h1', { aktiv: false })
|
||||
assert.equal((await lauf()).summeCents, 80000)
|
||||
await fruehereRechnung(ab, { id: 'in_h2', status: 'paid', total: 80000, nummer: 'WEBKLAR-0008' })
|
||||
await wirft(lauf(), 409, 'Nichts mehr offen.')
|
||||
await updateProjektRechnung('in_h2', { aktiv: false })
|
||||
// aktiver Projektdatensatz, aber die Rechnung gibt es bei Stripe nicht mehr (im Dashboard gelöscht): zählt nicht
|
||||
await fruehereRechnung(ab, { id: 'in_weg', ohneStripe: true, total: 5000, docNummer: 'X' })
|
||||
const e = await lauf()
|
||||
assert.equal(e.summeCents, 80000)
|
||||
assert.equal(e.positionen.length, 2)
|
||||
assert.equal((await getProjektRechnung('in_weg')).aktiv, false, 'der Datensatz der gelöschten Rechnung wurde freigegeben')
|
||||
})
|
||||
|
||||
test('baueRechnung anzahlung: AB ohne Projekt oder mit gelöschtem Projekt -> Zusatzfeld Projekt zeigt den AB-Titel; ein genanntes fremdes Projekt 404', async () => {
|
||||
const ohne = await legeAuftragAn({ projektId: '', titel: 'Logo-Entwurf' }, { nummer: 'AB-2026-0012' })
|
||||
const e = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id })
|
||||
assert.equal(e.projekt, null)
|
||||
assert.deepEqual(e.customFields[0], { name: 'Projekt', value: 'Logo-Entwurf' })
|
||||
assert.equal(e.metadata.projektId, undefined)
|
||||
assert.equal(e.projektRechnung.projektId, '')
|
||||
const weg = await legeAuftragAn({ projektId: 'pWeg' }, { nummer: 'AB-2026-0013' })
|
||||
const w = await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: weg.id })
|
||||
assert.equal(w.projekt, null)
|
||||
assert.deepEqual([w.metadata.projektId, w.projektRechnung.projektId, w.customFields[0].value], ['pWeg', 'pWeg', 'Website anelia-wolf.com'])
|
||||
assert.equal((await baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id, projektId: 'p1' })).projekt.id, 'p1')
|
||||
await wirft(baueRechnung({ customerId: 'k1', art: 'anzahlung', auftragId: ohne.id, projektId: 'p2' }), 404, 'Projekt nicht gefunden.')
|
||||
})
|
||||
|
||||
test('baueRechnung schluss: eine im Dashboard stornierte Schlussrechnung gibt die AB wieder frei (Selbstheilung), eine offene hält sie', async () => {
|
||||
const ab = await legeAuftragAn({}, { status: 'abgerechnet', bestaetigtAm: '2026-10-07T09:00:00.000Z' })
|
||||
await fruehereRechnung(ab, { id: 'in_sl1', art: 'schluss', status: 'open', total: 80000 })
|
||||
await wirft(baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
|
||||
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
|
||||
st.invoices.set('in_sl1', { ...st.invoices.get('in_sl1'), status: 'void' }) // im Dashboard storniert
|
||||
const e = await baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
|
||||
assert.equal(e.summeCents, 80000)
|
||||
assert.equal((await getAuftrag(ab.id)).status, 'bestaetigt')
|
||||
assert.equal((await getProjektRechnung('in_sl1')).aktiv, false)
|
||||
})
|
||||
|
||||
test('baueRechnung schluss: bis zu 40 Zeilen (20 AB-Positionen, 10 Nachträge, 10 Abzüge); mehr Abzüge oder Nachträge -> 400', async () => {
|
||||
const positionen = Array.from({ length: 20 }, (_, i) => ({ beschreibung: `Pos ${i + 1}`, menge: 1, einzelpreisCents: 1000 }))
|
||||
const ab = await legeAuftragAn({ positionen, summeCents: 20000 }, { nummer: 'AB-2026-0014' })
|
||||
const nachtraege = (n) => Array.from({ length: n }, (_, i) => ({ description: `Nachtrag ${i + 1}`, quantity: 1, unitAmountCents: 500 }))
|
||||
const lauf = (items) => baueRechnung({ customerId: 'k1', art: 'schluss', auftragId: ab.id, items })
|
||||
const abzug = (i) => fruehereRechnung(ab, { id: `in_z${String(i).padStart(2, '0')}`, status: 'paid', total: 100, nummer: `WK-${String(i).padStart(4, '0')}` })
|
||||
assert.equal((await lauf(undefined)).positionen.length, 20)
|
||||
await abzug(1)
|
||||
const eins = await lauf(undefined)
|
||||
assert.deepEqual([eins.positionen.length, eins.summeCents], [21, 19900], '20 AB-Positionen und ein Abzug sind gültig')
|
||||
for (let i = 2; i <= 10; i += 1) await abzug(i)
|
||||
const voll = await lauf(nachtraege(10))
|
||||
assert.deepEqual([voll.positionen.length, voll.summeCents, voll.projektRechnung.betragCents], [40, 24000, 24000])
|
||||
await wirft(lauf(nachtraege(11)), 400, 'Eine Schlussrechnung erlaubt höchstens 10 Zusatzpositionen.')
|
||||
await abzug(11)
|
||||
await wirft(lauf(undefined), 400, 'Die Schlussrechnung hätte 11 Abzüge für frühere Rechnungen; erlaubt sind höchstens 10.')
|
||||
})
|
||||
@@ -0,0 +1,590 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
// Rechtstexte der Aufträge und Rechnungen (Strang L7; Spec Abschnitt 8 „Rechtsrunde“, Übersicht V7).
|
||||
// Geprüft werden die Wortlaute in server/services/auftraege/texte.js gegen die gesetzlichen Muster (docs/rechtliches/quellen/), die Seiten
|
||||
// und PDFs unter public/rechtliches/ (Satz „auftraege“ von scripts/build-legal-html.mjs und build-legal-pdfs.sh), die AGB-Kopie gegen die
|
||||
// Website und die Abschnitte der Datenschutzerklärung. Es wird nur gelesen, geschrieben wird nur in temporären Verzeichnissen.
|
||||
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const lies = (rel) => readFileSync(join(ROOT, rel), 'utf8')
|
||||
const sha256 = (daten) => createHash('sha256').update(daten).digest('hex')
|
||||
const norm = (text) => String(text).replace(/\s+/g, ' ').trim()
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const texte = await import('../server/services/auftraege/texte.js')
|
||||
const { AGB_FASSUNG, AUFTRAG_WEGE } = await import('../server/services/auftraege/konstanten.js')
|
||||
const { widerrufsbelehrungFunktion } = await import('../server/services/termine/rechtstexte.js')
|
||||
const gen = await import('../scripts/build-legal-html.mjs')
|
||||
|
||||
const T = texte.AB_TEXTE
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------- Exporte und Formen (V7)
|
||||
|
||||
const V7_EXPORTE = ['ABSENDER', 'AB_TEXTE', 'BESTAETIGUNG_TEXT', 'FRUEHBEGINN_FASSUNG', 'FRUEHBEGINN_TEXT', 'TEXTE_FASSUNG', 'WIDERRUFSBELEHRUNG_PROJEKT', 'WIDERRUFSFORMULAR']
|
||||
|
||||
test('texte_exporte: genau die Exporte und Formen aus V7 der Übersicht, Fassungen im Format JJJJ-MM-TT', () => {
|
||||
assert.deepEqual(Object.keys(texte).sort(), [...V7_EXPORTE].sort())
|
||||
for (const fassung of [texte.TEXTE_FASSUNG, texte.FRUEHBEGINN_FASSUNG]) {
|
||||
assert.match(fassung, /^\d{4}-\d{2}-\d{2}$/)
|
||||
assert.ok(fassung >= '2026-10-06', `Fassung ${fassung} liegt vor der Rechtsrunde`)
|
||||
}
|
||||
assert.deepEqual(texte.ABSENDER, {
|
||||
name: 'WEBklar GbR', strasse: 'Am Schwimmbad 10', plzOrt: '67722 Winnweiler', telefon: '0176 23726355',
|
||||
email: 'support@webklar.com', web: 'webklar.com', vertretenDurch: 'Kenso Grimm und Justin Klein',
|
||||
})
|
||||
assert.deepEqual(Object.keys(T).sort(), ['agb', 'aufwand', 'einleitung', 'ersetzt', 'gruss', 'kleinunternehmer', 'mitwirkung', 'termine', 'titel', 'zahlungsplan'])
|
||||
assert.deepEqual(Object.keys(T.zahlungsplan).sort(), ['anzahlung', 'frei', 'monatlich', 'nach_abnahme'])
|
||||
for (const name of ['einleitung', 'aufwand', 'termine', 'agb', 'ersetzt']) assert.equal(typeof T[name], 'function', name)
|
||||
for (const name of ['anzahlung', 'frei', 'monatlich', 'nach_abnahme']) assert.equal(typeof T.zahlungsplan[name], 'function', name)
|
||||
for (const name of ['titel', 'kleinunternehmer', 'mitwirkung', 'gruss']) assert.equal(typeof T[name], 'string', name)
|
||||
assert.equal(typeof texte.BESTAETIGUNG_TEXT, 'string')
|
||||
assert.equal(typeof texte.FRUEHBEGINN_TEXT, 'string')
|
||||
assert.deepEqual(Object.keys(texte.WIDERRUFSBELEHRUNG_PROJEKT), ['titel', 'abschnitte'])
|
||||
for (const abschnitt of texte.WIDERRUFSBELEHRUNG_PROJEKT.abschnitte) {
|
||||
assert.deepEqual(Object.keys(abschnitt), ['ueberschrift', 'absaetze'])
|
||||
assert.ok(abschnitt.absaetze.length > 0 && abschnitt.absaetze.every((s) => typeof s === 'string' && s !== ''))
|
||||
}
|
||||
assert.deepEqual(Object.keys(texte.WIDERRUFSFORMULAR), ['titel', 'einleitung', 'zeilen'])
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------- Textbausteine der AB
|
||||
|
||||
test('ab_texte_einleitung: Datum und Weg der Absprache; Schlüssel aus AUFTRAG_WEGE wird zum Klartext; Pflichtwert fehlt = Fehler', () => {
|
||||
assert.equal(T.titel, 'Auftragsbestätigung')
|
||||
assert.equal(
|
||||
T.einleitung({ datum: '05.10.2026', weg: 'telefonisch' }),
|
||||
'Vielen Dank für Ihren Auftrag vom 05.10.2026 (telefonisch). Wir bestätigen ihn wie folgt:',
|
||||
)
|
||||
for (const [schluessel, klartext] of Object.entries(AUFTRAG_WEGE)) {
|
||||
const erwartet = `Vielen Dank für Ihren Auftrag vom 05.10.2026 (${klartext}). Wir bestätigen ihn wie folgt:`
|
||||
assert.equal(T.einleitung({ datum: '05.10.2026', weg: schluessel }), erwartet, schluessel)
|
||||
assert.equal(T.einleitung({ datum: '05.10.2026', weg: klartext }), erwartet, klartext)
|
||||
}
|
||||
assert.equal(T.einleitung({ datum: '05.10.2026', weg: '' }), 'Vielen Dank für Ihren Auftrag vom 05.10.2026. Wir bestätigen ihn wie folgt:')
|
||||
assert.equal(T.einleitung({ datum: '05.10.2026', weg: 'constructor' }), 'Vielen Dank für Ihren Auftrag vom 05.10.2026 (constructor). Wir bestätigen ihn wie folgt:', 'kein Treffer in der Prototypkette')
|
||||
assert.throws(() => T.einleitung({ weg: 'telefon' }), TypeError)
|
||||
assert.throws(() => T.einleitung(), TypeError)
|
||||
})
|
||||
|
||||
test('ab_texte_aufwand: Stundensatz, 15-Minuten-Schritte, Unterstützung (AGB Nr. 9), unverbindliche Schätzung und Zusage nach § 649 BGB (Kostenanschlag) nur mit Schätzung', () => {
|
||||
const regel = 'Wir rechnen nach Aufwand ab: 100,00 € pro Stunde, erfasst je Arbeitsschritt in Schritten von 15 Minuten; angefangene 15 Minuten werden aufgerundet. '
|
||||
+ 'Wirkt eine weitere Person unterstützend mit, berechnen wir für sie die Hälfte der Arbeitszeit (AGB Nr. 9).'
|
||||
const schaetzung = (stunden, wort) => `${regel} Der geschätzte Umfang beträgt etwa ${stunden} ${wort}; die Schätzung ist unverbindlich. Zeichnet sich eine deutliche Überschreitung ab, melden wir uns vorher.`
|
||||
assert.equal(T.aufwand({ satz: '100,00 €', schaetzung: '10' }), schaetzung('10', 'Stunden'))
|
||||
assert.equal(T.aufwand({ satz: '100,00 €', schaetzung: '1' }), schaetzung('1', 'Stunde'))
|
||||
assert.equal(T.aufwand({ satz: '100,00 €', schaetzung: '7,5' }), schaetzung('7,5', 'Stunden'))
|
||||
// ohne Schätzung gibt es nichts zu überschreiten: weder Schätzung noch Zusage
|
||||
for (const leer of [null, undefined, '']) assert.equal(T.aufwand({ satz: '100,00 €', schaetzung: leer }), regel)
|
||||
assert.throws(() => T.aufwand({ schaetzung: '10' }), TypeError)
|
||||
})
|
||||
|
||||
test('ab_texte_kleinunternehmer_mitwirkung_agb_ersetzt_gruss: Wortlaut', () => {
|
||||
assert.equal(T.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
|
||||
assert.equal(
|
||||
T.mitwirkung,
|
||||
'Mitwirkung und Abnahme richten sich nach Nr. 7 und Nr. 8 unserer Allgemeinen Geschäftsbedingungen. '
|
||||
+ 'Bitte stellen Sie uns die benötigten Inhalte, Daten und Freigaben rechtzeitig bereit.',
|
||||
)
|
||||
assert.equal(
|
||||
T.agb({ fassung: '30.09.2026' }),
|
||||
'Es gelten unsere Allgemeinen Geschäftsbedingungen in der Fassung vom 30.09.2026, die dieser Bestätigung beiliegen.',
|
||||
)
|
||||
assert.equal(T.ersetzt({ nummer: 'AB-2026-0001', datum: '05.10.2026' }), 'Diese Auftragsbestätigung ersetzt AB-2026-0001 vom 05.10.2026.')
|
||||
assert.equal(T.gruss, 'Mit freundlichen Grüßen')
|
||||
assert.throws(() => T.agb({}), TypeError)
|
||||
assert.throws(() => T.ersetzt({ nummer: 'AB-2026-0001' }), TypeError)
|
||||
})
|
||||
|
||||
test('ab_texte_zahlungsplan: vier Pläne, Zahlungsziel als „innerhalb von n Tagen nach Rechnungsdatum“', () => {
|
||||
const Z = T.zahlungsplan
|
||||
const ziel14 = 'innerhalb von 14 Tagen nach Rechnungsdatum'
|
||||
assert.equal(Z.nach_abnahme({ ziel: 14 }), `Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar ${ziel14}.`)
|
||||
assert.equal(
|
||||
Z.anzahlung({ prozent: 50, anzahlung: '400,00 €', rest: '400,00 €', ziel: 14 }),
|
||||
`Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; zahlbar jeweils ${ziel14}.`,
|
||||
)
|
||||
assert.equal(Z.anzahlung({ prozent: 33.5, anzahlung: '335,00 €', rest: '665,00 €', ziel: 7 }), 'Anzahlung 33,5 % (335,00 €) nach Auftragsbestätigung, Rest (665,00 €) nach Abnahme; zahlbar jeweils innerhalb von 7 Tagen nach Rechnungsdatum.')
|
||||
assert.equal(Z.monatlich({ ziel: 14 }), `Wir stellen den angefallenen Aufwand monatlich in Rechnung; zahlbar ${ziel14}.`)
|
||||
assert.equal(
|
||||
Z.frei({ text: 'Je ein Drittel bei Auftrag, zur Halbzeit und nach Abnahme', ziel: 7 }),
|
||||
'Je ein Drittel bei Auftrag, zur Halbzeit und nach Abnahme. Zahlbar jeweils innerhalb von 7 Tagen nach Rechnungsdatum.',
|
||||
)
|
||||
assert.equal(Z.frei({ text: 'Zahlung in zwei Raten.', ziel: 14 }), `Zahlung in zwei Raten. Zahlbar jeweils ${ziel14}.`, 'kein doppelter Punkt')
|
||||
// Zahlungsziel: 0 = sofort, 1 = Einzahl, Strings und Zahlen, ungültig oder fehlend = 14 Tage
|
||||
assert.equal(Z.nach_abnahme({ ziel: 0 }), 'Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar sofort nach Rechnungsstellung.')
|
||||
assert.equal(Z.nach_abnahme({ ziel: 1 }), 'Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar innerhalb von 1 Tag nach Rechnungsdatum.')
|
||||
assert.equal(Z.nach_abnahme({ ziel: '30' }), 'Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar innerhalb von 30 Tagen nach Rechnungsdatum.')
|
||||
for (const ungueltig of [undefined, null, '', 'bald', 7.5, -3, NaN]) {
|
||||
assert.equal(Z.nach_abnahme({ ziel: ungueltig }), `Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar ${ziel14}.`, String(ungueltig))
|
||||
}
|
||||
assert.equal(Z.nach_abnahme(), `Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar ${ziel14}.`)
|
||||
// Pflichtwerte
|
||||
assert.throws(() => Z.anzahlung({ prozent: 50, anzahlung: '400,00 €', ziel: 14 }), TypeError)
|
||||
assert.throws(() => Z.frei({ text: ' ', ziel: 14 }), TypeError)
|
||||
})
|
||||
|
||||
test('ab_texte_termine: Beginn und voraussichtliche Fertigstellung; für Privatpersonen der Satz zum Beginn nach Ablauf der Widerrufsfrist', () => {
|
||||
const privat = 'Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.'
|
||||
assert.equal(T.termine({ start: '08.10.2026', fertig: '31.10.2026' }), 'Beginn: 08.10.2026; voraussichtliche Fertigstellung: 31.10.2026.')
|
||||
assert.equal(T.termine({ start: '08.10.2026', fertig: null }), 'Beginn: 08.10.2026.')
|
||||
assert.equal(T.termine({ start: null, fertig: '31.10.2026' }), 'Voraussichtliche Fertigstellung: 31.10.2026.')
|
||||
assert.equal(T.termine({ start: null, fertig: null }), null)
|
||||
assert.equal(T.termine({}), null)
|
||||
assert.equal(T.termine({ start: '08.10.2026', fertig: '31.10.2026', privat: false }), 'Beginn: 08.10.2026; voraussichtliche Fertigstellung: 31.10.2026.')
|
||||
assert.equal(T.termine({ start: '08.10.2026', fertig: '31.10.2026', privat: true }), `Beginn: 08.10.2026; voraussichtliche Fertigstellung: 31.10.2026. ${privat}`)
|
||||
assert.equal(T.termine({ start: null, fertig: null, privat: true }), privat, 'der Satz steht auch ohne Termine')
|
||||
})
|
||||
|
||||
test('ab_texte_stil: Sie-Form, keine Gedankenstriche im Fließtext, keine veraltete Zählung, kein „undefined“', () => {
|
||||
const alle = [
|
||||
T.titel,
|
||||
T.einleitung({ datum: '05.10.2026', weg: 'telefon' }),
|
||||
T.aufwand({ satz: '100,00 €', schaetzung: '10' }),
|
||||
T.aufwand({ satz: '100,00 €' }),
|
||||
T.kleinunternehmer,
|
||||
T.zahlungsplan.nach_abnahme({ ziel: 14 }),
|
||||
T.zahlungsplan.anzahlung({ prozent: 50, anzahlung: '400,00 €', rest: '400,00 €', ziel: 14 }),
|
||||
T.zahlungsplan.monatlich({ ziel: 14 }),
|
||||
T.zahlungsplan.frei({ text: 'Je ein Drittel', ziel: 7 }),
|
||||
T.termine({ start: '08.10.2026', fertig: '31.10.2026', privat: true }),
|
||||
T.mitwirkung,
|
||||
T.agb({ fassung: '30.09.2026' }),
|
||||
T.ersetzt({ nummer: 'AB-2026-0001', datum: '05.10.2026' }),
|
||||
T.gruss,
|
||||
texte.BESTAETIGUNG_TEXT,
|
||||
texte.FRUEHBEGINN_TEXT,
|
||||
]
|
||||
for (const text of alle) {
|
||||
assert.doesNotMatch(text, /[–—]/, `Gedankenstrich: ${text}`)
|
||||
assert.doesNotMatch(text, /\bdein|\bdu\b|\bdir\b|\bdich\b/i, `Du-Form: ${text}`)
|
||||
assert.doesNotMatch(text, /undefined|null|NaN|\{\{|\}\}/, `Platzhalter: ${text}`)
|
||||
assert.doesNotMatch(text, /Abs\. 4/, `veraltete Zählung: ${text}`)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------- Bestätigung und Frühbeginn
|
||||
|
||||
test('bestaetigung_text: Häkchen im Wortlaut der Spezifikation (Alternative mit Bezug auf die AGB ist Entscheidung E2 der Rechtsrunde)', () => {
|
||||
assert.equal(texte.BESTAETIGUNG_TEXT, 'Ich bestätige den Auftrag zu diesen Bedingungen.')
|
||||
})
|
||||
|
||||
const NORMEN = lies('docs/rechtliches/quellen/normen-auftraege-2026-10-06.txt')
|
||||
|
||||
test('normen_zaehlung: § 356 Abs. 5 Nr. 2 BGB regelt das Erlöschen bei entgeltlichen Dienstleistungen (Abs. 4 ist die Höchstfrist), § 357a Abs. 2 BGB den Wertersatz', () => {
|
||||
const n = norm(NORMEN)
|
||||
// Abs. 4 = Höchstfrist, kein Erlöschen durch Leistung
|
||||
assert.ok(n.includes('(4) Das Widerrufsrecht erlischt spätestens zwölf Monate und 14 Tage nach dem in Absatz 2 oder § 355 Absatz 2 Satz 2 genannten Zeitpunkt.'))
|
||||
// Abs. 5 Nr. 2 = Vertrag mit Preis: Zustimmung (a), bei Verträgen außerhalb von Geschäftsräumen Datenträger (b), Kenntnis (c)
|
||||
const abs5 = n.slice(n.indexOf('(5) Das Widerrufsrecht erlischt bei Verträgen über die Erbringung von Dienstleistungen'), n.indexOf('(6) Das Widerrufsrecht erlischt bei Verträgen über die Bereitstellung'))
|
||||
assert.ok(abs5.length > 500, 'Absatz 5 fehlt in der Quelle')
|
||||
const nr2 = abs5.slice(abs5.indexOf('2. bei einem Vertrag, der den Verbraucher zur Zahlung eines Preises verpflichtet'), abs5.indexOf('3. bei einem Vertrag, bei dem der Verbraucher den Unternehmer ausdrücklich aufgefordert hat'))
|
||||
for (const teil of [
|
||||
'mit der vollständigen Erbringung der Dienstleistung, wenn der Verbraucher vor Beginn der Erbringung',
|
||||
'a) ausdrücklich zugestimmt hat, dass der Unternehmer mit der Erbringung der Dienstleistung vor Ablauf der Widerrufsfrist beginnt,',
|
||||
'b) bei einem außerhalb von Geschäftsräumen geschlossenen Vertrag die Zustimmung nach Buchstabe a auf einem dauerhaften Datenträger übermittelt hat und',
|
||||
'c) seine Kenntnis davon bestätigt hat, dass sein Widerrufsrecht mit vollständiger Vertragserfüllung durch den Unternehmer erlischt,',
|
||||
]) assert.ok(nr2.includes(teil), `§ 356 Abs. 5 Nr. 2: ${teil}`)
|
||||
// Nr. 1 betrifft unentgeltliche Verträge, Abs. 6 die Bereitstellung digitaler Inhalte: beides passt nicht zum Projektauftrag
|
||||
assert.ok(abs5.includes('1. bei einem Vertrag, der den Verbraucher nicht zur Zahlung eines Preises verpflichtet'))
|
||||
// § 357a Abs. 2: Wertersatz für Dienstleistungen mit Preis, ausdrückliches Verlangen als Voraussetzung
|
||||
assert.ok(n.includes('(2) Der Verbraucher hat Wertersatz für die bis zum Widerruf erbrachten Dienstleistungen, für die der Vertrag die Zahlung eines Preises vorsieht'))
|
||||
assert.ok(n.includes('1. der Verbraucher von dem Unternehmer ausdrücklich verlangt hat, dass mit der Leistung vor Ablauf der Widerrufsfrist begonnen werden soll,'))
|
||||
assert.ok(n.includes('Bei der Berechnung des Wertersatzes ist der vereinbarte Gesamtpreis zu Grunde zu legen.'))
|
||||
})
|
||||
|
||||
test('normen_kostenanschlag: § 649 BGB ist der Kostenanschlag (Abs. 2: unverzügliche Anzeige), § 650 BGB regelt etwas anderes', () => {
|
||||
const n = norm(NORMEN)
|
||||
const block = (nr) => {
|
||||
const a = n.indexOf(`Quelle: https://www.gesetze-im-internet.de/bgb/__${nr}.html`)
|
||||
assert.ok(a >= 0, `§ ${nr} fehlt im Nachweis`)
|
||||
const b = n.indexOf('Quelle: ', a + 10)
|
||||
return n.slice(a, b < 0 ? undefined : b)
|
||||
}
|
||||
const kostenanschlag = block(649)
|
||||
assert.ok(kostenanschlag.includes('§ 649 Kostenanschlag'), 'amtliche Überschrift von § 649')
|
||||
assert.ok(kostenanschlag.includes('(1) Ist dem Vertrag ein Kostenanschlag zugrunde gelegt worden'))
|
||||
assert.ok(kostenanschlag.includes('(2) Ist eine solche Überschreitung des Anschlags zu erwarten, so hat der Unternehmer dem Besteller unverzüglich Anzeige zu machen.'))
|
||||
// § 650 trägt eine andere Überschrift; der Kostenanschlag kommt in seinem Gesetzestext nicht vor
|
||||
const werklieferung = block(650)
|
||||
assert.ok(werklieferung.includes('§ 650 Werklieferungsvertrag; Verbrauchervertrag über die Herstellung digitaler Produkte'))
|
||||
assert.ok(!werklieferung.slice(werklieferung.indexOf('§ 650 Werklieferungsvertrag')).includes('Kostenanschlag'))
|
||||
// das Ergebnis der Prüfung vom 06.10.2026 steht mit dem Zitat im Kopf der Datei
|
||||
assert.ok(n.includes('Der Kostenanschlag steht in § 649 BGB (amtliche Überschrift „Kostenanschlag“).'))
|
||||
assert.ok(n.includes('ein Verweis auf „§ 650“ für den Kostenanschlag ist falsch'))
|
||||
assert.doesNotMatch(lies('server/services/auftraege/texte.js'), /§ 650/, 'texte.js nennt § 650 nicht')
|
||||
})
|
||||
|
||||
test('normen_kundenseiten: Widerruf formfrei (§ 355 Abs. 1), Widerrufsfunktion und Eingangsbestätigung (§ 356a Abs. 2 und 4), digitale Produkte (§ 650 Abs. 2)', () => {
|
||||
const n = norm(NORMEN)
|
||||
assert.ok(n.includes('Der Widerruf erfolgt durch Erklärung gegenüber dem Unternehmer. Aus der Erklärung muss der Entschluss des Verbrauchers zum Widerruf des Vertrags eindeutig hervorgehen.'))
|
||||
assert.ok(n.includes('2. Angaben zur Identifizierung des Vertrags oder des Teils des Vertrags, den der Verbraucher widerrufen möchte,'))
|
||||
assert.ok(n.includes('(4) Der Unternehmer hat dem Verbraucher, wenn dieser die Bestätigungsfunktion aktiviert hat, auf einem dauerhaften Datenträger unverzüglich eine Eingangsbestätigung zu übermitteln, die zumindest den Inhalt der Widerrufserklärung nach Absatz 2 sowie das Datum und die Uhrzeit ihres Eingangs enthält.'))
|
||||
assert.ok(n.includes('(2) Auf einen Verbrauchervertrag, bei dem der Unternehmer sich verpflichtet, 1. digitale Inhalte herzustellen,'))
|
||||
assert.ok(n.includes('sind die §§ 633 bis 639 über die Rechte bei Mängeln sowie § 640 über die Abnahme nicht anzuwenden.'))
|
||||
})
|
||||
|
||||
// Zeichen, die Figtree Regular und SemiBold haben (die Schrift des PDFs, server/assets/fonts/ von Strang L4): Basic Latin, Umlaute, ß, § und die
|
||||
// typografischen Zeichen der Texte. Geprüft am 06.10.2026 mit fontkit (hasGlyphForCodePoint, @expo-google-fonts/figtree 0.4.1, beide Schnitte).
|
||||
const FIGTREE_ZEICHEN = new Set([
|
||||
...Array.from({ length: 95 }, (_, i) => String.fromCodePoint(0x20 + i)),
|
||||
...'äöüÄÖÜß§€–—„“‚‘’”…−•→',
|
||||
String.fromCodePoint(0xa0),
|
||||
'\n',
|
||||
])
|
||||
|
||||
test('zeichenvorrat_figtree: texte.js enthält nur Zeichen der PDF-Schrift Figtree (sonst ersetzt das PDF still ein Zeichen und weicht vom freigegebenen Wortlaut ab)', () => {
|
||||
const fremd = [...new Set(lies('server/services/auftraege/texte.js'))].filter((zeichen) => !FIGTREE_ZEICHEN.has(zeichen))
|
||||
const bezeichnet = fremd.map((zeichen) => `U+${zeichen.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')} ${zeichen}`)
|
||||
assert.deepEqual(bezeichnet, [], 'Zeichen außerhalb des geprüften Zeichenvorrats (auch in Kommentaren): vor der Aufnahme gegen Figtree prüfen und FIGTREE_ZEICHEN ergänzen; der Wächter-Test von L4 (auftraegePdfDaten) prüft nach dem Merge gegen die Schrift selbst')
|
||||
})
|
||||
|
||||
test('fruehbeginn_text: Verlangen, Kenntnis vom Erlöschen und Wertersatz in einem Satzgefüge; zitiert § 356 Abs. 5 Nr. 2 und § 357a Abs. 2 BGB', () => {
|
||||
assert.equal(texte.FRUEHBEGINN_TEXT,
|
||||
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Ausführung dieses Projektauftrags beginnt. '
|
||||
+ 'Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). '
|
||||
+ 'Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).')
|
||||
const saetze = texte.FRUEHBEGINN_TEXT.split(/(?<=\.) (?=Mir ist bekannt|Widerrufe ich)/)
|
||||
assert.equal(saetze.length, 3, 'drei Sätze: Verlangen, Kenntnis, Wertersatz')
|
||||
// Verlangen (§ 357a Abs. 2 Nr. 1) und Zustimmung (§ 356 Abs. 5 Nr. 2 Buchstabe a): ausdrücklich, vor Ablauf der Widerrufsfrist, für diesen Auftrag
|
||||
assert.ok(saetze[0].startsWith('Ich verlange ausdrücklich') && saetze[0].includes('vor Ablauf der Widerrufsfrist') && saetze[0].includes('dieses Projektauftrags'))
|
||||
// Kenntnis (§ 356 Abs. 5 Nr. 2 Buchstabe c): Erlöschen mit vollständiger Erbringung
|
||||
assert.ok(saetze[1].startsWith('Mir ist bekannt') && saetze[1].includes('erlischt') && saetze[1].includes('vollständigen Erbringung'))
|
||||
// Wertersatz (§ 357a Abs. 2): angemessener Betrag für die bis dahin erbrachte Leistung
|
||||
assert.ok(saetze[2].includes('angemessenen Betrag') && saetze[2].includes('bis dahin erbrachte Leistung'))
|
||||
assert.doesNotMatch(texte.FRUEHBEGINN_TEXT, /§ 356 Abs\. 4|§ 356 Abs\. 6|§ 357 Abs\. 8/)
|
||||
// dasselbe Satzende wie bei den Funktionen (News, Terminplaner, Webshop, Öffnungszeiten): gleiche Normen, gleiche Wertersatz-Formel
|
||||
const ende = '(§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).'
|
||||
assert.ok(texte.FRUEHBEGINN_TEXT.endsWith(ende))
|
||||
assert.ok(config.funktionen.consentText.endsWith(ende), 'die Erklärung der Funktionen hat dasselbe Satzende')
|
||||
// beide Zitate stehen in der Quelle der Normen
|
||||
assert.ok(norm(NORMEN).includes('Quelle: https://www.gesetze-im-internet.de/bgb/__356.html'))
|
||||
assert.ok(norm(NORMEN).includes('Quelle: https://www.gesetze-im-internet.de/bgb/__357a.html'))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------- Widerrufsbelehrung und Formular
|
||||
|
||||
const ANLAGE_1 = lies('docs/rechtliches/quellen/anlage-1.txt')
|
||||
const ANLAGE_2 = lies('docs/rechtliches/quellen/anlage-2.txt')
|
||||
const zeilenVon = (text) => text.split('\n').map((z) => z.trim())
|
||||
const KONTAKT = 'WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com'
|
||||
|
||||
const B = texte.WIDERRUFSBELEHRUNG_PROJEKT
|
||||
const F = texte.WIDERRUFSFORMULAR
|
||||
/** Die Belehrung als ein Text: Titel, Überschriften und Absätze in Dokumentreihenfolge. */
|
||||
const belehrungText = () => [B.titel, ...B.abschnitte.flatMap((a) => [a.ueberschrift, ...a.absaetze])].join('\n')
|
||||
|
||||
test('belehrung_muster: jeder Absatz der Anlage 1 steht wörtlich darin, nur die Lücken sind gefüllt (Frist „des Vertragsabschlusses.“, Kontaktangaben)', () => {
|
||||
const z = zeilenVon(ANLAGE_1)
|
||||
const von = z.indexOf('Widerrufsbelehrung')
|
||||
const bis = z.findIndex((s) => s.startsWith('Gestaltungshinweise:'))
|
||||
assert.ok(von >= 0 && bis > von, 'Aufbau von anlage-1.txt hat sich geändert')
|
||||
const muster = z.slice(von, bis).filter(Boolean)
|
||||
assert.equal(muster.length, 8, 'Anlage 1: Titel, Widerrufsrecht, 3 Absätze, Folgen des Widerrufs und 1 Absatz')
|
||||
const frist = /Dienstleistungsvertrags[^„]*„([^“]+)“/.exec(ANLAGE_1)?.[1]
|
||||
assert.equal(frist, 'des Vertragsabschlusses.', 'Textbaustein a) für Dienstleistungsverträge')
|
||||
const text = belehrungText()
|
||||
for (const zeile of muster) {
|
||||
const erwartet = zeile.replace('ab dem Tag .', `ab dem Tag ${frist}`).replace('uns ( )', `uns (${KONTAKT})`)
|
||||
assert.ok(text.includes(erwartet), `Absatz fehlt oder ist verändert: ${erwartet}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('belehrung_widerrufsfunktion: Satzrahmen der Anlage wörtlich, die Lücke „…“ ist die Adresse der Widerrufsseite', () => {
|
||||
const m = /„(Sie können Ihr Widerrufsrecht auch online unter) … \[[^\]]+\] (ausüben\.) (Wenn Sie diese Online-Funktion nutzen, [^“]+?)\s*“/.exec(ANLAGE_1)
|
||||
assert.ok(m, 'Gestaltungshinweis zur Widerrufsfunktion fehlt oder hat sich geändert in anlage-1.txt')
|
||||
assert.ok(m[3].includes('Eingangsbestätigung') && m[3].includes('Uhrzeit ihres Eingangs'))
|
||||
const absatz = `${m[1]} https://project.webklar.com/widerrufen.html ${m[2]} ${m[3]}`
|
||||
assert.ok(belehrungText().includes(absatz), 'Absatz zur Widerrufsfunktion fehlt oder weicht vom Satzrahmen der Anlage ab')
|
||||
// die Widerrufsseite kennt die Vertragsart „Projektauftrag“ (L5/L6); die Adresse ist dieselbe wie bei den Funktionen
|
||||
assert.equal(belehrungText().split('https://project.webklar.com/widerrufen.html').length - 1, 1)
|
||||
})
|
||||
|
||||
test('belehrung_wertersatz: Wertersatz für Dienstleistungen, Wasser/Gas/Strom/Fernwärme gestrichen, keine Bausteine zu Waren', () => {
|
||||
const m = /Haben Sie verlangt, dass die Dienstleistungen oder Lieferung von Wasser\/Gas\/Strom\/Fernwärme \[Unzutreffendes streichen\] (während[^“]+?)\s*“/.exec(ANLAGE_1)
|
||||
assert.ok(m, 'Wertersatz-Absatz fehlt in anlage-1.txt')
|
||||
const text = belehrungText()
|
||||
assert.ok(text.includes(`Haben Sie verlangt, dass die Dienstleistungen ${m[1]}`), 'Wertersatz-Absatz fehlt oder weicht ab')
|
||||
assert.doesNotMatch(text, /Wasser|Gas\b|Strom|Fernwärme|\[Unzutreffendes|Rücksendung|in Besitz genommen|Wertverlust/)
|
||||
})
|
||||
|
||||
test('formular_muster: Muster-Widerrufsformular der Anlage 2 wörtlich, Unternehmer in der „An“-Zeile, (*)-Alternativen unverändert', () => {
|
||||
const z = zeilenVon(ANLAGE_2)
|
||||
const start = z.indexOf('Muster-Widerrufsformular')
|
||||
assert.ok(start >= 0, 'Aufbau von anlage-2.txt hat sich geändert')
|
||||
const inhalt = z.slice(start).filter((s) => s && s !== '–')
|
||||
assert.equal(inhalt.length, 10, 'Anlage 2: Titel, Einleitung, 7 Zeilen und der Hinweis (*)')
|
||||
const zeilen = [F.titel, F.einleitung, ...F.zeilen.map((s) => s.replace(/^– /, ''))]
|
||||
let anZeilen = 0
|
||||
for (const zeile of inhalt) {
|
||||
if (zeile.startsWith('An [hier ist')) {
|
||||
anZeilen += 1
|
||||
assert.ok(zeilen.includes('An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:'), '„An“-Zeile mit dem Unternehmer fehlt')
|
||||
} else {
|
||||
assert.ok(zeilen.includes(zeile), `Formularzeile fehlt oder ist verändert: ${zeile}`)
|
||||
}
|
||||
}
|
||||
assert.equal(anZeilen, 1)
|
||||
assert.equal(F.titel, 'Muster-Widerrufsformular')
|
||||
// Aufzählungszeichen des Musters vor jeder Formularzeile, der Hinweis (*) steht zuletzt ohne Zeichen
|
||||
assert.ok(F.zeilen.slice(0, -1).every((s) => s.startsWith('– ')))
|
||||
assert.equal(F.zeilen.at(-1), '(*) Unzutreffendes streichen.')
|
||||
})
|
||||
|
||||
test('belehrung_gleich_funktionen: Belehrung und Formular sind wörtlich die Belehrung für die Bestellung einer Funktion', () => {
|
||||
const absaetze = [
|
||||
B.titel,
|
||||
...B.abschnitte.flatMap((a) => [a.ueberschrift, ...a.absaetze]),
|
||||
F.titel,
|
||||
F.einleitung,
|
||||
F.zeilen.slice(0, -1).join('\n'),
|
||||
F.zeilen.at(-1),
|
||||
]
|
||||
assert.equal(absaetze.join('\n\n'), widerrufsbelehrungFunktion())
|
||||
assert.deepEqual(B.abschnitte.map((a) => a.ueberschrift), ['Widerrufsrecht', 'Folgen des Widerrufs'])
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------- AGB-Kopie
|
||||
|
||||
const AGB_MD = 'docs/rechtliches/agb.md'
|
||||
// Quelle der Website-AGB auf dem Server (Repository webklar.com). Fehlt die Datei (anderer Rechner, Container), entfällt nur der Vergleich mit der Website.
|
||||
const AGB_WEBSITE = process.env.WEBKLAR_AGB_QUELLE || '/home/webklar/apps/webklar.com/build/src/pages/AGB.tsx'
|
||||
// SHA-256 des normierten Klartexts der Website-AGB vom 30.09.2026 (Commit 72f278b der Website; Titelblock und Nr. 1 bis 14). Ändert sich der Text der Kopie,
|
||||
// braucht sie eine neue Fassung: AGB_FASSUNG hochziehen, agb.md ändern, Seite und PDF neu erzeugen, diesen Hash eintragen.
|
||||
const AGB_TEXT_SHA256 = 'f3374352ef97e6050bb0908c1b4abd806f759851b5b85fa9d030512d46b36659'
|
||||
|
||||
/** Textknoten der Kopie: alles nach der Fassungszeile, ohne Überschriftszeichen, Aufzählungszeichen, Absatznummern „(1)“ und Fettdruck. */
|
||||
function agbKnotenAusMarkdown(md) {
|
||||
const zeilen = md.replace(/\r/g, '').split('\n')
|
||||
const start = zeilen.findIndex((z) => z.startsWith('**Fassung '))
|
||||
assert.ok(start > 0, 'agb.md: Fassungszeile fehlt')
|
||||
return zeilen
|
||||
.slice(start + 1)
|
||||
.filter((z) => z.trim() !== '')
|
||||
.map((z) => z.replace(/^#{1,3} /, '').replace(/^- /, '').replace(/^\(\d+\) /, '').replace(/\*\*/g, '').replace(/\s+/g, ' ').trim())
|
||||
}
|
||||
|
||||
/** Textknoten der Website-Seite AGB.tsx: vom Seitentitel bis zum Ende des Vertragstexts, ohne JSX-Kommentare und Markup. */
|
||||
function agbKnotenAusWebsite(tsx) {
|
||||
const von = tsx.indexOf('<h1 className="wk-seite__titel">')
|
||||
const bis = tsx.indexOf('</article>')
|
||||
assert.ok(von > 0 && bis > von, 'AGB.tsx: Aufbau hat sich geändert (Seitentitel oder article nicht gefunden)')
|
||||
return tsx
|
||||
.slice(von, bis)
|
||||
.replace(/\{\/\*[\s\S]*?\*\/\}/g, ' ')
|
||||
.replace(/\{" "\}/g, ' ')
|
||||
.replace(/<[^>]*>/g, '\n')
|
||||
.split('\n')
|
||||
.map((z) => z.replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
const agbText = (knoten) => norm(knoten.join(' '))
|
||||
/** Die erste Stelle, an der zwei Texte auseinanderlaufen, mit etwas Umgebung (für die Fehlermeldung). */
|
||||
const ersterUnterschied = (a, b) => {
|
||||
let i = 0
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) i += 1
|
||||
return `Kopie: „…${a.slice(Math.max(0, i - 40), i + 60)}…“, Website: „…${b.slice(Math.max(0, i - 40), i + 60)}…“`
|
||||
}
|
||||
|
||||
test('agb_fassung: AGB_FASSUNG ist der 30.09.2026, der Tag der letzten Textänderung der Website-AGB (Commit 72f278b); die Kopie trägt dieselbe Fassung', () => {
|
||||
assert.equal(AGB_FASSUNG, '2026-09-30')
|
||||
assert.ok(lies(AGB_MD).startsWith(`# Allgemeine Geschäftsbedingungen\n\n**Fassung ${AGB_FASSUNG}**`))
|
||||
})
|
||||
|
||||
test('agb_md_aufbau: vierzehn Abschnitte in Reihenfolge; die Verweise der Auftragsbestätigung auf Nr. 7, 8 und 9 treffen', () => {
|
||||
const md = lies(AGB_MD)
|
||||
const abschnitte = md.split('\n').filter((z) => /^## \d+\. /.test(z)).map((z) => z.replace(/^## /, ''))
|
||||
assert.deepEqual(abschnitte.map((z) => Number.parseInt(z, 10)), Array.from({ length: 14 }, (_, i) => i + 1))
|
||||
const titel = Object.fromEntries(abschnitte.map((z) => [Number.parseInt(z, 10), z.replace(/^\d+\. /, '')]))
|
||||
assert.equal(titel[7], 'Mitwirkungspflichten des Kunden')
|
||||
assert.equal(titel[8], 'Abnahme')
|
||||
assert.equal(titel[9], 'Vergütung und Zahlung')
|
||||
assert.ok(T.mitwirkung.includes('Nr. 7 und Nr. 8'))
|
||||
assert.ok(T.aufwand({ satz: '100,00 €' }).includes('(AGB Nr. 9)'))
|
||||
const nr9 = md.slice(md.indexOf('## 9. '), md.indexOf('## 10. '))
|
||||
assert.ok(nr9.includes('in Schritten von 15 Minuten') && nr9.includes('die Hälfte der Arbeitszeit'), 'Nr. 9 enthält die Regel, auf die die Auftragsbestätigung verweist')
|
||||
// der Titelblock der Website steht als Überschrift und Parteien, die Verweise auf die Abos bleiben Text
|
||||
assert.ok(md.includes('## Kaufvertrag – WEBklar (Modularer Projektvertrag)'))
|
||||
assert.ok(md.includes('**zwischen**') && md.includes('**und**'))
|
||||
})
|
||||
|
||||
test('agb_text_pin: der normierte Text der Kopie hat den festgehaltenen SHA-256 (keine versehentliche Änderung eines Rechtstextes)', () => {
|
||||
const hash = sha256(agbText(agbKnotenAusMarkdown(lies(AGB_MD))))
|
||||
assert.equal(hash, AGB_TEXT_SHA256, `Der Text der AGB-Kopie hat sich geändert (Hash jetzt ${hash}). Änderungen nur mit neuer Fassung: AGB_FASSUNG hochziehen, Seite und PDF neu erzeugen.`)
|
||||
})
|
||||
|
||||
test('agb_text_gleich_website: die Kopie ist wortgleich mit der Seite AGB.tsx der Website (Titelblock und Nr. 1 bis 14)', (t) => {
|
||||
if (!existsSync(AGB_WEBSITE)) return t.skip(`Website-AGB nicht erreichbar: ${AGB_WEBSITE}`)
|
||||
const website = agbText(agbKnotenAusWebsite(readFileSync(AGB_WEBSITE, 'utf8')))
|
||||
const kopie = agbText(agbKnotenAusMarkdown(lies(AGB_MD)))
|
||||
assert.ok(kopie === website, `Die AGB-Kopie weicht von den AGB der Website ab (${ersterUnterschied(kopie, website)}). Ist die Website neu, braucht die Kopie eine neue Fassung (AGB_FASSUNG, agb.md, Seite, PDF, Hash).`)
|
||||
assert.equal(sha256(website), AGB_TEXT_SHA256)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------- Seiten der Widerrufsbelehrung und der AGB
|
||||
|
||||
// jedes Dokument wird in den Tests neu geholt, damit ein fehlender Generator nur die betroffenen Tests scheitern lässt (nicht das Laden der Datei)
|
||||
const dokumente = () => gen.auftraegeDokumente()
|
||||
const klartext = (html) =>
|
||||
html
|
||||
.replace(/<style>[\s\S]*?<\/style>/g, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
const quelleVon = (d) => (d.markdown ? d.markdown() : lies(d.md))
|
||||
|
||||
test('auftraegeDokumente: widerruf (aus texte.js) und agb (aus docs/rechtliches/agb.md) mit Quelle, Ziel und Navigation auf die Fassungen', () => {
|
||||
const DOKUMENTE = dokumente()
|
||||
assert.deepEqual(DOKUMENTE.map((d) => d.doc), ['widerruf', 'agb'])
|
||||
assert.deepEqual(
|
||||
DOKUMENTE.map((d) => [d.md, d.html]),
|
||||
[
|
||||
[null, `public/rechtliches/widerrufsbelehrung-projektauftrag-${texte.TEXTE_FASSUNG}.html`],
|
||||
['docs/rechtliches/agb.md', `public/rechtliches/agb-${AGB_FASSUNG}.html`],
|
||||
],
|
||||
)
|
||||
assert.deepEqual(DOKUMENTE.map((d) => d.meta.fassung), [texte.TEXTE_FASSUNG, AGB_FASSUNG])
|
||||
const adressen = DOKUMENTE.map((d) => d.html.replace(/^public/, ''))
|
||||
for (const d of DOKUMENTE) {
|
||||
const hrefs = d.meta.nav.map((l) => l.href)
|
||||
for (const a of adressen) assert.ok(hrefs.includes(a), `Navigation von ${d.doc} verlinkt ${a}`)
|
||||
assert.ok(hrefs.includes('/abo-bedingungen.html') && hrefs.includes('/datenschutz.html'))
|
||||
}
|
||||
// andere Fassungen wirken auf Dateinamen und Meta; die Quelle der Belehrung folgt der Fassung
|
||||
const andere = gen.auftraegeDokumente('2027-01-02', '2027-01-03')
|
||||
assert.equal(andere[0].html, 'public/rechtliches/widerrufsbelehrung-projektauftrag-2027-01-02.html')
|
||||
assert.equal(andere[1].html, 'public/rechtliches/agb-2027-01-03.html')
|
||||
assert.ok(andere[0].markdown().includes('**Fassung 2027-01-02**'))
|
||||
})
|
||||
|
||||
test('auftraege_html_gleich_generator: die eingecheckten Seiten sind genau render(Quelle), deterministisch, ohne Datum außer den Fassungen; --check Exit 0', () => {
|
||||
const DOKUMENTE = dokumente()
|
||||
for (const d of DOKUMENTE) {
|
||||
const md = quelleVon(d)
|
||||
const html = gen.render(md, d.meta)
|
||||
assert.equal(lies(d.html), html, `${d.html} weicht vom Generator ab (node scripts/build-legal-html.mjs auftraege)`)
|
||||
assert.equal(gen.render(md, d.meta), html, 'deterministisch: zweiter Lauf gleiche Bytes')
|
||||
assert.doesNotMatch(html.replaceAll(texte.TEXTE_FASSUNG, '').replaceAll(AGB_FASSUNG, ''), /\b\d{4}-\d{2}-\d{2}\b/, `${d.html}: Datum außer den Fassungen`)
|
||||
}
|
||||
const lauf = spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', 'auftraege', '--check'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
|
||||
assert.equal(lauf.status, 0, lauf.stdout + lauf.stderr)
|
||||
assert.equal([...lauf.stdout.matchAll(/^ok /gm)].length, 2)
|
||||
})
|
||||
|
||||
test('auftraege_html_text_gleich_quelle: der sichtbare Text jeder Seite ist genau der Text der Quelle (plus Navigation und Marke)', () => {
|
||||
const DOKUMENTE = dokumente()
|
||||
for (const d of DOKUMENTE) {
|
||||
const aus = quelleVon(d).replace(/\r/g, '').split('\n').filter((z) => z.trim() !== '').map((z) => z.replace(/^#{1,3} /, '').replace(/^- /, ''))
|
||||
const erwartet = norm(aus.join(' ').replace(/\*\*|`/g, ''))
|
||||
const html = lies(d.html)
|
||||
const main = html.slice(html.indexOf('<main>'), html.indexOf('</main>')).replace(/<nav class="top">[\s\S]*?<\/nav>/, '').replace('<div class="brand">WEBKLAR</div>', '')
|
||||
assert.equal(klartext(main), erwartet, `${d.html}: sichtbarer Text weicht von der Quelle ab`)
|
||||
}
|
||||
})
|
||||
|
||||
test('auftraege_html_sauber: keine Kommentare, Entwurfsmarken, Skripte oder Ereignis-Attribute; Fassung im Kopf; Links nur relativ oder https', () => {
|
||||
const DOKUMENTE = dokumente()
|
||||
for (const d of DOKUMENTE) {
|
||||
const html = lies(d.html)
|
||||
assert.doesNotMatch(html, /<!--|Entwurf|ENTWURF|Prüfpunkt|<script|<iframe|<img|<form|<link|\sstyle=|\ssrc=/, `${d.html}: verbotener Inhalt`)
|
||||
assert.doesNotMatch(html, /\son[a-z]+\s*=/i, `${d.html}: Ereignis-Attribut`)
|
||||
assert.doesNotMatch(html, /⚖|\bQ\d+\b|\{\{|\}\}|undefined|NaN/, `${d.html}: Marker oder Platzhalter`)
|
||||
assert.ok(html.includes(`<strong>Fassung ${d.meta.fassung}</strong>`), `${d.html}: Fassung fehlt im Kopf`)
|
||||
assert.ok(html.includes(`<meta name="webklar-fassung" content="${d.meta.fassung}">`))
|
||||
assert.match(html, /<html lang="de">/)
|
||||
assert.match(html, /<title>[^<]+ – WEBklar<\/title>/)
|
||||
const hrefs = [...html.matchAll(/\shref="([^"]*)"/g)].map((m) => m[1])
|
||||
assert.ok(hrefs.length >= 6)
|
||||
for (const h of hrefs) assert.ok(/^\/(?!\/)/.test(h) || /^https:\/\//.test(h), `${d.html}: Link ${h}`)
|
||||
assert.ok(html.includes(`content: "WEBklar · ${d.meta.seitenkopf}"`))
|
||||
assert.equal([...html.matchAll(/<h1>/g)].length, 1)
|
||||
assert.ok(html.includes('<div class="brand">WEBKLAR</div>'))
|
||||
}
|
||||
})
|
||||
|
||||
test('auftraege_lauf: --check meldet fehlende oder abweichende Seiten mit Exit 1 und schreibt nichts; ohne --check werden beide Seiten geschrieben; Aufruf nennt alle Sätze', () => {
|
||||
const DOKUMENTE = dokumente()
|
||||
const dir = mkdtempSync(join(tmpdir(), 'legalhtml-auftraege-'))
|
||||
try {
|
||||
mkdirSync(join(dir, 'docs/rechtliches'), { recursive: true })
|
||||
mkdirSync(join(dir, 'public/rechtliches'), { recursive: true })
|
||||
cpSync(join(ROOT, AGB_MD), join(dir, AGB_MD))
|
||||
const zeilen = []
|
||||
const ruf = (...args) => gen.lauf(args, { root: dir, log: (z) => zeilen.push(z), fehler: (z) => zeilen.push(z) })
|
||||
// noch keine Seiten: --check meldet beide und schreibt nichts
|
||||
assert.equal(ruf('auftraege', '--check'), 1)
|
||||
assert.equal(zeilen.filter((z) => z.startsWith('ABWEICHT')).length, 2)
|
||||
assert.equal(existsSync(join(dir, DOKUMENTE[0].html)), false, '--check schreibt nichts')
|
||||
// schreiben, dann stimmt --check, und die Bytes sind die der eingecheckten Seiten
|
||||
assert.equal(ruf('auftraege'), 0)
|
||||
assert.equal(ruf('auftraege', '--check'), 0)
|
||||
for (const d of DOKUMENTE) assert.equal(readFileSync(join(dir, d.html), 'utf8'), lies(d.html), 'dieselben Bytes wie die eingecheckte Seite')
|
||||
// eine Seite von Hand geändert, die andere Quelle geändert: Abweichung, die Dateien bleiben wie sie sind
|
||||
const ziel = join(dir, DOKUMENTE[1].html)
|
||||
const handgemacht = readFileSync(ziel, 'utf8').replace('<h2>1. Vertragsgegenstand</h2>', '<h2>1. Vertragsgegenstand (von Hand)</h2>')
|
||||
assert.notEqual(handgemacht, readFileSync(ziel, 'utf8'))
|
||||
writeFileSync(ziel, handgemacht)
|
||||
zeilen.length = 0
|
||||
assert.equal(ruf('auftraege', '--check'), 1)
|
||||
assert.deepEqual(zeilen.filter((z) => z.startsWith('ABWEICHT')), [`ABWEICHT ${DOKUMENTE[1].html}`])
|
||||
assert.equal(readFileSync(ziel, 'utf8'), handgemacht, '--check ändert die Datei nicht')
|
||||
writeFileSync(ziel, lies(DOKUMENTE[1].html))
|
||||
writeFileSync(join(dir, AGB_MD), `${lies(AGB_MD)}\n## 15. Neu\n\n(1) Neuer Absatz.\n`)
|
||||
assert.equal(ruf('auftraege', '--check'), 1)
|
||||
// falscher Aufruf: Exit 2, die Meldung nennt termine, shop und auftraege
|
||||
zeilen.length = 0
|
||||
assert.equal(ruf('analyse'), 2)
|
||||
assert.equal(ruf('auftraege', '--schreiben'), 2)
|
||||
assert.equal(ruf(), 2)
|
||||
assert.match(zeilen[0], /Aufruf: node scripts\/build-legal-html\.mjs termine\|shop \[--check\]/)
|
||||
assert.match(zeilen[0], /node scripts\/build-legal-html\.mjs auftraege \[--check\]/)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------- PDFs
|
||||
|
||||
test('auftraege_pdf_dateien: je Seite ein PDF unter demselben Namen, Prüfsumme der HTML-Seite daneben (HTML geändert ohne neues PDF fällt auf)', () => {
|
||||
const DOKUMENTE = dokumente()
|
||||
const mindest = { widerruf: { seiten: 1, bytes: 5 * 1024 }, agb: { seiten: 2, bytes: 20 * 1024 } }
|
||||
for (const d of DOKUMENTE) {
|
||||
const pdf = d.html.replace(/\.html$/, '.pdf')
|
||||
const bytes = readFileSync(join(ROOT, pdf))
|
||||
assert.equal(bytes.subarray(0, 5).toString('latin1'), '%PDF-', `${pdf}: kein PDF`)
|
||||
assert.ok(bytes.length > mindest[d.doc].bytes, `${pdf}: ${bytes.length} Bytes sind zu wenig`)
|
||||
assert.ok(bytes.subarray(-1024).toString('latin1').includes('%%EOF'), `${pdf}: PDF ist abgeschnitten`)
|
||||
const seiten = bytes.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []
|
||||
assert.ok(seiten.length >= mindest[d.doc].seiten, `${pdf}: ${seiten.length} Seiten`)
|
||||
const summe = lies(`${pdf}.sha256`)
|
||||
assert.match(summe, /^[0-9a-f]{64}\n$/, `${pdf}.sha256: nur die Prüfsumme und ein Zeilenende`)
|
||||
assert.equal(summe.trim(), sha256(readFileSync(join(ROOT, d.html))), `${d.html} wurde geändert, ohne das PDF neu zu drucken (scripts/build-legal-pdfs.sh auftraege)`)
|
||||
}
|
||||
// die AGB liegen unter dem Namen, den die Auftragsbestätigung (Mail K31 und Bestätigungsseite) verlinkt und anhängt
|
||||
assert.ok(existsSync(join(ROOT, `public/rechtliches/agb-${AGB_FASSUNG}.pdf`)))
|
||||
})
|
||||
|
||||
test('auftraege_script_modus: build-legal-pdfs.sh kennt auftraege (Fassungen aus texte.js und konstanten.js), all druckt es mit, bestehende Modi und Grenzen unverändert', () => {
|
||||
const sh = lies('scripts/build-legal-pdfs.sh')
|
||||
assert.match(sh, /^auftraege\)\n {2}auftraege_pdfs\n {2};;$/m)
|
||||
for (const name of ['TEXTE_VERSION', 'TEXTE_FASSUNG', 'AGB_VERSION', 'AGB_FASSUNG', 'widerrufsbelehrung-projektauftrag:$texte', 'agb:$agb']) assert.ok(sh.includes(name), name)
|
||||
// die Funktionen stehen vor shop_pdfs (test/buildLegalHtml.test.js liest ab dort den Modus shop) und zeigen auf die HTML-Quelle
|
||||
const auftraege = sh.slice(sh.indexOf('\nauftraege_pdfs() {'), sh.indexOf('\nshop_pdfs() {'))
|
||||
assert.ok(auftraege.length > 100 && sh.indexOf('\nauftraege_pdfs() {') > 0, 'auftraege_pdfs steht vor shop_pdfs')
|
||||
assert.ok(auftraege.includes('sha256sum "$quelle"') && auftraege.includes('echo "$summe" > "$ziel.sha256"') && auftraege.includes('(unveraendert)') && auftraege.includes('${FORCE:-}'))
|
||||
assert.ok(auftraege.includes('node scripts/build-legal-html.mjs auftraege'), 'fehlt die HTML-Seite, nennt die Meldung den Generator')
|
||||
assert.ok(sh.slice(sh.indexOf('\nall)'), sh.indexOf('\n*)')).includes(' auftraege_pdfs\n'), 'all druckt auch diese PDFs')
|
||||
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|shop|datenschutz]') && sh.includes('$0 auftraege'), 'Aufruf nennt alle Modi')
|
||||
// je Chrome-Aufruf höchstens 120 Sekunden, kein paralleler Lauf, Node-Aufrufe mit Heap-Grenze und Zeitgrenze
|
||||
assert.match(sh, /^ {2}timeout 120 "\$CHROME" /m)
|
||||
assert.equal([...sh.matchAll(/timeout 60 node --max-old-space-size=256 /g)].length, 4, 'je Fassungsfunktion ein Aufruf: termine, shop, texte und agb')
|
||||
assert.ok(!/ &$|^\s*wait\b|xargs -P/m.test(sh), 'keine parallelen Läufe')
|
||||
assert.equal(spawnSync('bash', ['-n', 'scripts/build-legal-pdfs.sh'], { cwd: ROOT, encoding: 'utf8' }).status, 0)
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { starteUmgebung, KUNDE_ID, PROJEKT_ID, TICKET_ID, FREMD, VON, MELDUNG, festpreisEingabe, aufwandEingabe, kundeDoc, baueDeps } from './helpers/auftraegeAb.js'
|
||||
|
||||
const umg = await starteUmgebung()
|
||||
const { aw, config } = umg
|
||||
const { startTestApp, send } = await import('./support/testApp.js')
|
||||
const { default: router } = await import('../server/routes/admin/auftraege.js')
|
||||
const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { SCHALTER_AUS_MELDUNG } = await import('../server/services/auftraege/konstanten.js')
|
||||
const { legeEntwurfAn, sendeAuftrag, setzeDepsFuerTests } = await import('../server/services/auftraege/auftraege.js')
|
||||
const app = await startTestApp({ '/api/admin/finanzen/auftraege': router })
|
||||
after(async () => {
|
||||
await app.close()
|
||||
await umg.schliesse()
|
||||
})
|
||||
|
||||
let kontext // Attrappen für PDF, Token und Mail des aktuellen Tests
|
||||
beforeEach(async () => {
|
||||
await umg.zuruecksetzen()
|
||||
kontext = baueDeps()
|
||||
setzeDepsFuerTests(kontext.deps)
|
||||
})
|
||||
const BASIS = '/api/admin/finanzen/auftraege'
|
||||
const api = (methode, pfad, body, jwt = 'jwt-admin') => send(methode, BASIS + pfad, { headers: { Authorization: `Bearer ${jwt}` }, body })
|
||||
const entwurfAnlegen = (patch) => legeEntwurfAn(festpreisEingabe(patch), { von: VON })
|
||||
|
||||
const ZUGRIFF = [
|
||||
['GET', `/?kunde=${KUNDE_ID}`], ['POST', '/'], ['GET', '/a_1'], ['PATCH', '/a_1'], ['DELETE', '/a_1'], ['GET', '/a_1/pdf'],
|
||||
['POST', '/a_1/senden'], ['POST', '/a_1/erinnern'], ['POST', '/a_1/neue-fassung'], ['POST', '/a_1/stornieren'], ['POST', '/a_1/projekt'],
|
||||
]
|
||||
|
||||
test('Zugriff: ohne JWT 401, ohne Admin-Label 403 – bei jeder Route', async () => {
|
||||
for (const [methode, pfad] of ZUGRIFF) {
|
||||
const body = ['GET', 'DELETE'].includes(methode) ? undefined : {}
|
||||
assert.equal((await send(methode, BASIS + pfad, { body })).status, 401, `${methode} ${pfad} ohne JWT`)
|
||||
for (const jwt of ['jwt-staff', 'jwt-kunde']) assert.equal((await api(methode, pfad, body, jwt)).status, 403, `${methode} ${pfad} mit ${jwt}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Schalter: ist die Funktion für den Kunden aus, antworten Liste, Anlegen, Lesen, Ändern, Löschen und PDF mit 404 und der festen Meldung', async () => {
|
||||
const a = await entwurfAnlegen() // angelegt, solange der Schalter an ist
|
||||
config.auftraege.mode = 'off'
|
||||
const routen = [['GET', `/?kunde=${KUNDE_ID}`], ['POST', '/', festpreisEingabe()], ['GET', `/${a.id}`], ['PATCH', `/${a.id}`, { titel: 'x' }], ['DELETE', `/${a.id}`], ['GET', `/${a.id}/pdf`]]
|
||||
for (const [methode, pfad, body] of routen) {
|
||||
const r = await api(methode, pfad, body)
|
||||
assert.deepEqual([r.status, r.body], [404, { error: SCHALTER_AUS_MELDUNG }], `${methode} ${pfad}`)
|
||||
}
|
||||
assert.equal((await getAuftrag(a.id)).titel, 'Website Beispiel') // nichts wurde geändert oder gelöscht
|
||||
config.auftraege.mode = 'pilot'
|
||||
config.auftraege.pilotEmails = ['anna@example.invalid'] // E-Mail des Kunden
|
||||
assert.equal((await api('GET', `/${a.id}`)).status, 200)
|
||||
config.auftraege.pilotEmails = ['jemand@example.invalid']
|
||||
assert.equal((await api('GET', `/${a.id}`)).status, 404)
|
||||
})
|
||||
|
||||
test('GET Liste: Aufträge des Kunden, neueste zuerst, ohne interne Felder; 400 ohne Kunde, 404 bei unbekanntem Kunden', async () => {
|
||||
const a = await entwurfAnlegen()
|
||||
const b = await legeEntwurfAn(aufwandEingabe(), { von: VON })
|
||||
const r = await api('GET', `/?kunde=${KUNDE_ID}`)
|
||||
assert.equal(r.status, 200)
|
||||
assert.deepEqual(r.body.auftraege.map((x) => x.id), [b.id, a.id])
|
||||
for (const x of r.body.auftraege) {
|
||||
for (const feld of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.equal(feld in x, false, feld)
|
||||
assert.equal(typeof x.pdfVorhanden, 'boolean')
|
||||
}
|
||||
const ohneKunde = await api('GET', '/')
|
||||
assert.deepEqual([ohneKunde.status, ohneKunde.body], [400, { error: 'Kunde fehlt.' }])
|
||||
assert.equal((await api('GET', '/?kunde=c_gibtsnicht')).status, 404)
|
||||
assert.equal((await api('GET', '/?kunde=../x')).status, 404)
|
||||
})
|
||||
|
||||
test('POST: legt einen Entwurf an (200, AuftragTeam mit Texten); Fehler als { error } mit festen Meldungen', async () => {
|
||||
const r = await api('POST', '/', festpreisEingabe())
|
||||
assert.equal(r.status, 200)
|
||||
const a = r.body.auftrag
|
||||
assert.deepEqual([a.status, a.titel, a.summeCents, a.erstelltVon], ['entwurf', 'Website Beispiel', 80000, 'kenso@webklar.com'])
|
||||
assert.deepEqual([typeof a.zahlungsplanText, a.pdfVorhanden, 'tokenHash' in a], ['string', false, false])
|
||||
const ohnePositionen = await api('POST', '/', festpreisEingabe({ positionen: [] }))
|
||||
assert.deepEqual([ohnePositionen.status, ohnePositionen.body], [400, { error: MELDUNG.positionen }])
|
||||
const ohneKunde = await api('POST', '/', festpreisEingabe({ kundenId: undefined }))
|
||||
assert.deepEqual([ohneKunde.status, ohneKunde.body], [400, { error: 'Kunde: Bitte einen Kunden angeben.' }])
|
||||
assert.equal((await api('POST', '/', festpreisEingabe({ kundenId: 'c_gibtsnicht' }))).status, 404)
|
||||
})
|
||||
|
||||
test('GET, PATCH, DELETE: Entwurf lesen, ändern, löschen; gesendete AB ist unveränderlich (409); ungültige Kennungen 404', async () => {
|
||||
const a = await entwurfAnlegen()
|
||||
const gelesen = await api('GET', `/${a.id}`)
|
||||
assert.deepEqual([gelesen.status, gelesen.body.auftrag.id], [200, a.id])
|
||||
const geaendert = await api('PATCH', `/${a.id}`, { titel: 'Neu' })
|
||||
assert.deepEqual([geaendert.status, geaendert.body.auftrag.titel], [200, 'Neu'])
|
||||
const falsch = await api('PATCH', `/${a.id}`, { zahlungszielTage: 99 })
|
||||
assert.deepEqual([falsch.status, falsch.body], [400, { error: 'Zahlungsziel: Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.' }])
|
||||
const geloescht = await api('DELETE', `/${a.id}`)
|
||||
assert.deepEqual([geloescht.status, geloescht.body], [200, { ok: true }])
|
||||
assert.deepEqual((await api('GET', `/${a.id}`)).body, { error: MELDUNG.auftragFehlt })
|
||||
for (const kennung of ['a%20b', 'x'.repeat(40), '..%2F..']) assert.equal((await api('GET', `/${kennung}`)).status, 404, kennung)
|
||||
const b = await entwurfAnlegen()
|
||||
await sendeAuftrag(b.id, { von: VON })
|
||||
const patch = await api('PATCH', `/${b.id}`, { titel: 'Nachträglich' })
|
||||
assert.deepEqual([patch.status, patch.body], [409, { error: MELDUNG.nurEntwurfAendern }])
|
||||
const loeschen = await api('DELETE', `/${b.id}`)
|
||||
assert.deepEqual([loeschen.status, loeschen.body], [409, { error: MELDUNG.nurEntwurfLoeschen }])
|
||||
})
|
||||
|
||||
test('GET pdf: Entwurf als Vorschau, gesendete AB aus der gespeicherten Datei, mit Kopfzeilen', async () => {
|
||||
const entwurf = await entwurfAnlegen()
|
||||
const vorschau = await api('GET', `/${entwurf.id}/pdf`)
|
||||
assert.equal(vorschau.status, 200)
|
||||
assert.equal(vorschau.body, '%PDF-1.4 Entwurf')
|
||||
assert.match(vorschau.headers.get('content-type'), /^application\/pdf/)
|
||||
assert.equal(vorschau.headers.get('content-disposition'), 'inline; filename="Auftragsbestaetigung-Entwurf.pdf"')
|
||||
assert.equal(vorschau.headers.get('cache-control'), 'no-store')
|
||||
const { auftrag } = await sendeAuftrag(entwurf.id, { von: VON }) // Attrappen aus setzeDepsFuerTests
|
||||
const gespeichert = await api('GET', `/${entwurf.id}/pdf`)
|
||||
assert.equal(gespeichert.body, `%PDF-1.4 ${auftrag.nummer}`)
|
||||
assert.equal(gespeichert.headers.get('content-disposition'), `inline; filename="${auftrag.nummer}.pdf"`)
|
||||
assert.equal((await api('GET', '/a_gibtsnicht/pdf')).status, 404)
|
||||
})
|
||||
|
||||
test('Unerwartete Fehler: 500 mit fester Meldung, nichts von Appwrite gelangt an die Seite', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const a = await entwurfAnlegen()
|
||||
aw.failNext({ collection: config.collections.auftraege, method: 'GET', status: 500, message: 'GEHEIM-Appwrite-Detail' })
|
||||
const r = await api('GET', `/${a.id}`)
|
||||
assert.deepEqual([r.status, r.body], [500, { error: 'Interner Fehler.' }])
|
||||
})
|
||||
|
||||
test('POST senden: 200 ohne Token und Dateipfad; Kundendaten-, PDF- und Statusfehler mit festen Meldungen; Mail-Fehler als mailFehler: true', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const a = await entwurfAnlegen()
|
||||
aw.seed(config.collections.customers, kundeDoc({ email: '' }))
|
||||
let r = await api('POST', `/${a.id}/senden`)
|
||||
assert.deepEqual([r.status, r.body], [400, { error: MELDUNG.keineMail }])
|
||||
aw.seed(config.collections.customers, kundeDoc({ street: '' }))
|
||||
r = await api('POST', `/${a.id}/senden`)
|
||||
assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.adresse }])
|
||||
aw.seed(config.collections.customers, kundeDoc())
|
||||
kontext = baueDeps({ pdfFehler: new Error('pdfkit') })
|
||||
setzeDepsFuerTests(kontext.deps)
|
||||
r = await api('POST', `/${a.id}/senden`)
|
||||
assert.deepEqual([r.status, r.body], [500, { error: MELDUNG.pdf }])
|
||||
kontext = baueDeps({ mailFehler: new Error('smtp') })
|
||||
setzeDepsFuerTests(kontext.deps)
|
||||
r = await api('POST', `/${a.id}/senden`)
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.mailFehler, true)
|
||||
assert.deepEqual([r.body.auftrag.status, r.body.auftrag.mailStatus, r.body.auftrag.pdfVorhanden], ['versendet', 'fehler', true])
|
||||
assert.match(r.body.auftrag.nummer, /^AB-\d{4}-0001$/)
|
||||
for (const feld of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.equal(feld in r.body.auftrag, false, feld)
|
||||
r = await api('POST', `/${a.id}/senden`)
|
||||
assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.nurEntwurfSenden }])
|
||||
})
|
||||
|
||||
test('POST erinnern, neue-fassung, stornieren: Antworten, Folgezustände und 409 bei falschem Status', async () => {
|
||||
const a = await entwurfAnlegen()
|
||||
let r = await api('POST', `/${a.id}/erinnern`)
|
||||
assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.keineErinnerung }])
|
||||
r = await api('POST', `/${a.id}/senden`)
|
||||
assert.deepEqual([r.status, r.body.mailFehler, r.body.auftrag.status], [200, false, 'versendet'])
|
||||
r = await api('POST', `/${a.id}/erinnern`)
|
||||
assert.deepEqual([r.status, r.body.mailFehler, r.body.auftrag.status], [200, false, 'versendet'])
|
||||
assert.equal(kontext.calls.mail.at(-1).erinnerung, true)
|
||||
r = await api('POST', `/${a.id}/neue-fassung`)
|
||||
assert.deepEqual([r.status, r.body.auftrag.status, r.body.auftrag.ersetztId], [200, 'entwurf', a.id])
|
||||
r = await api('POST', `/${a.id}/neue-fassung`)
|
||||
assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.schonEntwurf }])
|
||||
r = await api('POST', `/${a.id}/stornieren`)
|
||||
assert.deepEqual([r.status, r.body.auftrag.status], [200, 'storniert'])
|
||||
r = await api('POST', `/${a.id}/stornieren`)
|
||||
assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.keinStorno }])
|
||||
})
|
||||
|
||||
test('POST projekt: ordnet einer AB ohne Projekt das Projekt zu (200, AuftragTeam); 400 ohne oder mit fremdem Projekt, 404 bei unbekannter AB, 409 bei vorhandenem Projekt oder falschem Status', async () => {
|
||||
const a = await entwurfAnlegen({ projektId: '', titel: 'Beratung ohne Projekt' })
|
||||
await api('POST', `/${a.id}/senden`)
|
||||
let r = await api('POST', `/${a.id}/projekt`, {})
|
||||
assert.deepEqual([r.status, r.body], [400, { error: MELDUNG.projektFehlt }])
|
||||
r = await api('POST', `/${a.id}/projekt`)
|
||||
assert.deepEqual([r.status, r.body], [400, { error: MELDUNG.projektFehlt }])
|
||||
r = await api('POST', `/${a.id}/projekt`, { projektId: FREMD.projekt })
|
||||
assert.deepEqual([r.status, r.body], [400, { error: MELDUNG.projektFremd }])
|
||||
r = await api('POST', '/a_gibtsnicht/projekt', { projektId: PROJEKT_ID })
|
||||
assert.deepEqual([r.status, r.body], [404, { error: MELDUNG.auftragFehlt }])
|
||||
assert.equal((await getAuftrag(a.id)).projektId, '') // die abgelehnten Aufrufe haben nichts geändert
|
||||
r = await api('POST', `/${a.id}/projekt`, { projektId: PROJEKT_ID })
|
||||
assert.equal(r.status, 200)
|
||||
assert.deepEqual([r.body.auftrag.projektId, r.body.auftrag.zeitquelle, r.body.auftrag.status], [PROJEKT_ID, [TICKET_ID], 'versendet'])
|
||||
for (const feld of ['tokenHash', 'pdfDatei', 'bestaetigtPdfSha256']) assert.equal(feld in r.body.auftrag, false, feld)
|
||||
r = await api('POST', `/${a.id}/projekt`, { projektId: PROJEKT_ID })
|
||||
assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.schonProjekt }])
|
||||
const b = await entwurfAnlegen({ projektId: '', titel: 'Zweite Beratung ohne Projekt' })
|
||||
await api('POST', `/${b.id}/senden`)
|
||||
await api('POST', `/${b.id}/stornieren`)
|
||||
r = await api('POST', `/${b.id}/projekt`, { projektId: PROJEKT_ID })
|
||||
assert.deepEqual([r.status, r.body], [409, { error: MELDUNG.keinProjekt }])
|
||||
})
|
||||
|
||||
test('Schalter aus: auch senden, erinnern, neue-fassung, stornieren und projekt antworten 404 mit der festen Meldung', async () => {
|
||||
const a = await entwurfAnlegen({ projektId: '', titel: 'Beratung ohne Projekt' })
|
||||
config.auftraege.mode = 'off'
|
||||
for (const aktion of ['senden', 'erinnern', 'neue-fassung', 'stornieren', 'projekt']) {
|
||||
const r = await api('POST', `/${a.id}/${aktion}`, aktion === 'projekt' ? { projektId: PROJEKT_ID } : undefined)
|
||||
assert.deepEqual([r.status, r.body], [404, { error: SCHALTER_AUS_MELDUNG }], aktion)
|
||||
}
|
||||
const danach = await getAuftrag(a.id)
|
||||
assert.deepEqual([danach.status, danach.projektId], ['entwurf', ''])
|
||||
assert.equal(kontext.calls.pdf.length + kontext.calls.mail.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
|
||||
|
||||
// Routen des Projektkontos (Strang L1): Admin (requireAppwriteStaff) und Team (requireStaffJwt). Muster: financeRoutesBank.test.js
|
||||
// (JWT-Köpfe des Appwrite-Nachbaus), shopTeam.test.js (Team-Eingang) und den Gerüsttest von L0 (auftraegeGeruest.test.js).
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
|
||||
datetimes: AUFTRAEGE_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const { seedFixtures } = await import('./support/fixtures.js')
|
||||
const { startTestApp, rawGet } = await import('./support/testApp.js')
|
||||
const { default: projektkontenRoutes } = await import('../server/routes/admin/projektkonten.js')
|
||||
const { default: teamRoutes } = await import('../server/routes/team/index.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js')
|
||||
|
||||
const app = await startTestApp({ '/api/admin/finanzen/projektkonten': projektkontenRoutes, '/api/team': teamRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
seedFixtures(aw)
|
||||
|
||||
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
|
||||
const staff = { headers: { Authorization: 'Bearer jwt-staff' } }
|
||||
const ohneLabel = { headers: { Authorization: 'Bearer jwt-kunde' } }
|
||||
const BASIS = '/api/admin/finanzen/projektkonten'
|
||||
|
||||
const COLLECTIONS = ['websiteProjects', 'workorders', 'worksheets', 'auftraege', 'projektRechnungen']
|
||||
async function reset() {
|
||||
mock.restoreAll()
|
||||
invalidateFinanceCache()
|
||||
st.calls.length = 0
|
||||
st.invoices.clear()
|
||||
config.auftraege.mode = 'on'
|
||||
config.auftraege.pilotEmails = []
|
||||
for (const name of COLLECTIONS) {
|
||||
for (const id of aw.docs(config.collections[name]).keys()) await deleteDocument(config.collections[name], id)
|
||||
}
|
||||
}
|
||||
beforeEach(reset)
|
||||
|
||||
const blatt = (id, minuten) => ({
|
||||
$id: id, wsid: `10000${id.slice(1)}`, woid: '100001', workorderId: 't1', employeeId: 'e1', employeeName: 'Kenso Grimm', employeeShort: 'KNSO',
|
||||
serviceType: 'Remote', totalTime: minuten, supporters: '', startDate: '06.10.2026', endDate: '06.10.2026', details: 'Startseite',
|
||||
isComment: false, countsToPlan: false,
|
||||
})
|
||||
function seedBasis() {
|
||||
aw.seed(config.collections.workorders, { $id: 't1', woid: '100001', customerId: 'c1', topic: 'Website', status: 'open' })
|
||||
aw.seed(config.collections.websiteProjects, { $id: 'p1', customerId: 'c1', projectName: 'Website Müller', ticketId: 't1', kind: 'website', archived: false })
|
||||
aw.seed(config.collections.worksheets, blatt('w1', 60))
|
||||
aw.seed(config.collections.worksheets, blatt('w2', 30))
|
||||
}
|
||||
|
||||
test('Projektkonto-Routen: ohne JWT 401, ohne Admin-Label 403', async () => {
|
||||
for (const path of [`${BASIS}?kunde=c1`, `${BASIS}/p1`]) {
|
||||
assert.equal((await rawGet(path)).status, 401, `${path} ohne JWT`)
|
||||
assert.equal((await rawGet(path, { headers: { Authorization: 'Bearer jwt-unbekannt' } })).status, 401, `${path} mit falschem JWT`)
|
||||
assert.equal((await rawGet(path, staff)).status, 403, `${path} als Mitarbeiter ohne Admin-Label`)
|
||||
assert.equal((await rawGet(path, ohneLabel)).status, 403, `${path} als Konto ohne Label`)
|
||||
}
|
||||
})
|
||||
|
||||
test('GET ?kunde=: ohne, leer, doppelt oder mit unmöglicher Kennung -> 400', async () => {
|
||||
for (const query of ['', '?kunde=', '?kunde=c1&kunde=c2', '?kunde=../x', `?kunde=${'c'.repeat(37)}`, '?kunde[a]=b']) {
|
||||
const res = await rawGet(`${BASIS}${query}`, admin)
|
||||
assert.equal(res.status, 400, query)
|
||||
assert.deepEqual(res.body, { error: 'Kunde fehlt oder ist ungültig.' }, query)
|
||||
}
|
||||
})
|
||||
|
||||
test('GET ?kunde=<unbekannt> -> 404', async () => {
|
||||
const res = await rawGet(`${BASIS}?kunde=gibtsnicht`, admin)
|
||||
assert.equal(res.status, 404)
|
||||
assert.deepEqual(res.body, { error: 'Kunde nicht gefunden.' })
|
||||
})
|
||||
|
||||
test('GET ?kunde=c1: Projektkonten mit Zeit, Antwort mit genau den vereinbarten Schlüsseln', async () => {
|
||||
seedBasis()
|
||||
const res = await rawGet(`${BASIS}?kunde=c1`, admin)
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['auftraegeAktiv', 'konten', 'kunde', 'ohneProjekt', 'stripeOk'])
|
||||
assert.deepEqual(res.body.kunde, { id: 'c1', name: 'Kunde Eins', kundenart: '' })
|
||||
assert.deepEqual(res.body.ohneProjekt, { berechnetCents: 0, bezahltCents: 0, anzahl: 0 })
|
||||
assert.equal(res.body.stripeOk, true)
|
||||
assert.equal(res.body.auftraegeAktiv, true)
|
||||
assert.deepEqual(res.body.konten.map((konto) => [konto.projektId, konto.name, konto.projektTyp, konto.zeit.minuten]), [['p1', 'Website Müller', 'Website', 90]])
|
||||
assert.deepEqual(res.body.konten[0].tickets, [{ id: 't1', woid: '100001' }])
|
||||
})
|
||||
|
||||
test('GET /:projektId: Konto, ABs, Rechnungen und offene Arbeitsblätter; unbekannt und unmöglich -> 404', async () => {
|
||||
seedBasis()
|
||||
const res = await rawGet(`${BASIS}/p1`, admin)
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['auftraege', 'konto', 'offeneArbeitsblaetter', 'rechnungen', 'stripeOk'])
|
||||
assert.equal(res.body.konto.projektId, 'p1')
|
||||
assert.deepEqual(res.body.offeneArbeitsblaetter.map((eintrag) => eintrag.id), ['w1', 'w2'])
|
||||
for (const id of ['gibtsnicht', '%20x']) {
|
||||
const fehler = await rawGet(`${BASIS}/${id}`, admin)
|
||||
assert.equal(fehler.status, 404, id)
|
||||
assert.deepEqual(fehler.body, { error: 'Projekt nicht gefunden.' }, id)
|
||||
}
|
||||
})
|
||||
|
||||
test('Stripe-Ausfall: beide Routen antworten 200 mit stripeOk false', async () => {
|
||||
mock.method(console, 'warn', () => {})
|
||||
seedBasis()
|
||||
st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'kaputt' })
|
||||
const liste = await rawGet(`${BASIS}?kunde=c1`, admin)
|
||||
assert.equal(liste.status, 200)
|
||||
assert.equal(liste.body.stripeOk, false)
|
||||
assert.equal(liste.body.konten[0].zeit.minuten, 90)
|
||||
st.failNext({ method: 'GET', path: '/v1/invoices', status: 400, message: 'kaputt' })
|
||||
const detail = await rawGet(`${BASIS}/p1`, admin)
|
||||
assert.equal(detail.status, 200)
|
||||
assert.equal(detail.body.stripeOk, false)
|
||||
})
|
||||
|
||||
test('Fehler in Appwrite: 500 mit fester Meldung, ohne Text von Appwrite', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
seedBasis()
|
||||
aw.failNext({ collection: config.collections.websiteProjects, method: 'GET', status: 500, message: 'geheim-appwrite' })
|
||||
const res = await rawGet(`${BASIS}?kunde=c1`, admin)
|
||||
assert.equal(res.status, 500)
|
||||
assert.deepEqual(res.body, { error: 'Interner Fehler.' })
|
||||
})
|
||||
|
||||
// ---------- Team-Route (Task 8) ----------
|
||||
|
||||
const ABGERECHNET = '/api/team/arbeitsblaetter/abgerechnet'
|
||||
const seedPr = (invoiceId, { arbeitsblattIds = [], ...over } = {}) =>
|
||||
aw.seed(config.collections.projektRechnungen, {
|
||||
$id: invoiceId, invoiceId, kundenId: 'c1', projektId: 'p1', auftragId: '', art: 'festbetrag', betragCents: 0,
|
||||
arbeitsblattIdsJson: JSON.stringify(arbeitsblattIds), rechnungsnummer: '', aktiv: true,
|
||||
erstelltVon: 'kenso@webklar.com', erstelltAm: '2026-10-01T10:00:00.000Z', ...over,
|
||||
})
|
||||
|
||||
test('Abzeichen-Route: ohne JWT 401, Konto ohne Label 403, Mitarbeiter und Admin dürfen', async () => {
|
||||
const path = `${ABGERECHNET}?ticketId=t1`
|
||||
assert.equal((await rawGet(path)).status, 401)
|
||||
assert.equal((await rawGet(path, ohneLabel)).status, 403)
|
||||
assert.equal((await rawGet(path, staff)).status, 200)
|
||||
assert.equal((await rawGet(path, admin)).status, 200)
|
||||
})
|
||||
|
||||
test('Abzeichen-Route: ticketId fehlt, ist leer, doppelt oder unmöglich -> 400', async () => {
|
||||
for (const query of ['', '?ticketId=', '?ticketId=t1&ticketId=t2', '?ticketId=../x', `?ticketId=${'t'.repeat(37)}`, '?ticketId[a]=b']) {
|
||||
const res = await rawGet(`${ABGERECHNET}${query}`, staff)
|
||||
assert.equal(res.status, 400, query)
|
||||
assert.deepEqual(res.body, { error: 'ticketId fehlt oder ist ungültig.' }, query)
|
||||
}
|
||||
})
|
||||
|
||||
test('Abzeichen-Route: { abgerechnet: { Blatt: Rechnungsnummer | Entwurf } } ohne Beträge, unbekanntes Ticket leer', async () => {
|
||||
seedBasis()
|
||||
seedPr('in_1', { rechnungsnummer: 'WEBKLAR-0007', arbeitsblattIds: ['w1'] })
|
||||
seedPr('in_2', { rechnungsnummer: '', arbeitsblattIds: ['w2'] })
|
||||
const res = await rawGet(`${ABGERECHNET}?ticketId=t1`, staff)
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(res.body, { abgerechnet: { w1: 'WEBKLAR-0007', w2: 'Entwurf' } })
|
||||
assert.ok(!/cents|betrag|in_1|in_2/i.test(JSON.stringify(res.body)), 'weder Beträge noch Rechnungs-IDs')
|
||||
const unbekannt = await rawGet(`${ABGERECHNET}?ticketId=gibtsnicht`, staff)
|
||||
assert.equal(unbekannt.status, 200)
|
||||
assert.deepEqual(unbekannt.body, { abgerechnet: {} })
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
|
||||
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
|
||||
datetimes: AUFTRAEGE_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { seedRechnungsDaten, legeAuftragAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
|
||||
const { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js')
|
||||
const { startTestApp, post, rawGet } = await import('./support/testApp.js')
|
||||
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
|
||||
|
||||
const app = await startTestApp({ '/api/admin/finanzen': financeRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
seedRechnungsDaten(aw)
|
||||
config.auftraege.mode = 'on'
|
||||
beforeEach(async () => {
|
||||
await leereRechnungsDaten(aw, st)
|
||||
invalidateFinanceCache()
|
||||
resetInvoiceCreateGuardForTests()
|
||||
})
|
||||
|
||||
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
|
||||
const staff = { headers: { Authorization: 'Bearer jwt-staff' } }
|
||||
const base = '/api/admin/finanzen'
|
||||
const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }]
|
||||
const SCHALTER_AUS = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.'
|
||||
const schreibaufrufe = () => st.calls.filter((c) => c.method !== 'GET')
|
||||
const erwarte = async (pfad, body, status, error) => {
|
||||
const res = await post(`${base}${pfad}`, body, admin)
|
||||
assert.equal(res.status, status, JSON.stringify(res.body))
|
||||
assert.deepEqual(res.body, { error })
|
||||
}
|
||||
|
||||
test('POST /rechnungen: der bisherige Körper (ohne art) legt eine freie A4-Rechnung an', async () => {
|
||||
const res = await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN, daysUntilDue: 14, memo: '', senden: false, idempotencyKey: 'form-1' }, admin)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual([res.body.rechnung.art, res.body.rechnung.status, res.body.rechnung.totalCents], ['frei', 'entwurf', 1200])
|
||||
assert.equal(st.calls.find((c) => c.method === 'POST' && c.path === '/v1/invoices').body['rendering[pdf][page_size]'], 'a4')
|
||||
})
|
||||
|
||||
test('POST /rechnungen: Festbetrag, Aufwand, Anzahlung und Schluss tragen Projekt, Auftrag und Art in der Antwort', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const antwort = async (body) => {
|
||||
const res = await post(`${base}/rechnungen`, body, admin)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
return res.body.rechnung
|
||||
}
|
||||
const fest = await antwort({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN })
|
||||
assert.deepEqual([fest.art, fest.projektId, fest.projektName, fest.auftragId], ['festbetrag', 'p1', 'Website anelia-wolf.com', ''])
|
||||
const aufwand = await antwort({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2'] })
|
||||
assert.deepEqual([aufwand.art, aufwand.totalCents], ['aufwand', 22500])
|
||||
const anzahlung = await antwort({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 }, senden: 'true' })
|
||||
assert.deepEqual([anzahlung.art, anzahlung.status, anzahlung.totalCents, anzahlung.auftragNummer], ['anzahlung', 'offen', 20000, 'AB-2026-0002'])
|
||||
const schluss = await antwort({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
|
||||
assert.deepEqual([schluss.art, schluss.totalCents], ['schluss', 60000])
|
||||
})
|
||||
|
||||
test('POST /rechnungen/vorschau: Positionen, Summe, Zeitraum und Zusatzfelder, ohne Stripe-Schreibzugriff und ohne Datensatz', async () => {
|
||||
const res = await post(`${base}/rechnungen/vorschau`, { customerId: 'k1', art: 'aufwand', projektId: 'p1' }, admin)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['customFields', 'leistungszeitraum', 'positionen', 'summeCents'])
|
||||
assert.equal(res.body.summeCents, 32500)
|
||||
assert.deepEqual(res.body.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07'])
|
||||
assert.equal(res.body.leistungszeitraum, '06.10.–07.10.2026')
|
||||
assert.deepEqual(res.body.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum'])
|
||||
assert.equal(schreibaufrufe().length, 0)
|
||||
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
|
||||
})
|
||||
|
||||
test('Fehler kommen mit Status und nur der Meldung an (400, 404, 409), Schalter aus 400; nichts bei Stripe', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
|
||||
await erwarte(pfad, { customerId: 'k1', art: 'quatsch', items: POSTEN }, 400, 'Unbekannte Rechnungsart.')
|
||||
await erwarte(pfad, { customerId: 'gibtEsNicht', items: POSTEN }, 400, 'Kunde nicht gefunden.')
|
||||
await erwarte(pfad, { customerId: 'k1', art: 'festbetrag', projektId: 'p2', items: POSTEN }, 404, 'Projekt nicht gefunden.')
|
||||
await erwarte(pfad, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['wsFremd'] }, 400, 'Arbeitsblatt nicht gefunden.')
|
||||
await erwarte(pfad, { customerId: 'k1', art: 'schluss', auftragId: 'gibtEsNicht' }, 404, 'Auftrag nicht gefunden.')
|
||||
config.auftraege.mode = 'off'
|
||||
try {
|
||||
await erwarte(pfad, { customerId: 'k1', art: 'anzahlung', auftragId: ab.id }, 400, SCHALTER_AUS)
|
||||
} finally {
|
||||
config.auftraege.mode = 'on'
|
||||
}
|
||||
}
|
||||
assert.equal(schreibaufrufe().length, 0)
|
||||
})
|
||||
|
||||
test('Abgerechnete Arbeitsblätter: Anlegen und Vorschau antworten 409 mit der Meldung', async () => {
|
||||
const res = await post(`${base}/rechnungen`, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] }, admin)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
|
||||
await erwarte(pfad, { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws3'], memo: 'zweite' }, 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
|
||||
}
|
||||
})
|
||||
|
||||
test('Falsche Feldtypen im Körper -> 400 mit Meldung, nichts bei Stripe', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
await erwarte('/rechnungen', { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: 'ws1' }, 400, 'Die Arbeitsblätter müssen als Liste von Kennungen übergeben werden.')
|
||||
await erwarte('/rechnungen', { customerId: 'k1', art: 'aufwand', projektId: 'p1', zeilentexte: ['x'] }, 400, 'Die Zeilentexte müssen ein Objekt mit Texten sein.')
|
||||
await erwarte('/rechnungen', { customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: 'viel' }, 400, 'Die Angaben zur Anzahlung müssen ein Objekt sein.')
|
||||
await erwarte('/rechnungen', { customerId: 'k1', items: 'x' }, 400, 'Eine Rechnung braucht 1 bis 20 Positionen.')
|
||||
await erwarte('/rechnungen', { customerId: 'k1', art: {}, items: POSTEN }, 400, 'Unbekannte Rechnungsart.')
|
||||
assert.equal(schreibaufrufe().length, 0)
|
||||
})
|
||||
|
||||
test('Rechte: ohne Anmeldung 401, Mitarbeiter ohne Admin-Label 403; GET /rechnungen trägt die neuen Felder', async () => {
|
||||
for (const pfad of ['/rechnungen', '/rechnungen/vorschau']) {
|
||||
assert.equal((await post(`${base}${pfad}`, { customerId: 'k1', items: POSTEN })).status, 401)
|
||||
assert.equal((await post(`${base}${pfad}`, { customerId: 'k1', items: POSTEN }, staff)).status, 403)
|
||||
}
|
||||
assert.equal(st.invoices.size, 0)
|
||||
await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN, memo: 'frei' }, admin)
|
||||
await post(`${base}/rechnungen`, { customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN }, admin)
|
||||
const liste = (await rawGet(`${base}/rechnungen`, admin)).body.rechnungen
|
||||
const felder = (r) => [r.art, r.projektId, r.projektName, r.auftragId, r.auftragNummer]
|
||||
assert.deepEqual(liste.map(felder).sort(), [['festbetrag', 'p1', 'Website anelia-wolf.com', '', ''], ['frei', '', '', '', '']])
|
||||
})
|
||||
|
||||
test('POST /rechnungen: Anzahlung ohne daysUntilDue nimmt das Zahlungsziel der AB, mit Angabe diese; freie Rechnung bleibt bei 14', async () => {
|
||||
const ab = await legeAuftragAn({ zahlungszielTage: 30 })
|
||||
const tage = () => st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/invoices').at(-1).body.days_until_due
|
||||
assert.equal((await post(`${base}/rechnungen`, { customerId: 'k1', art: 'anzahlung', auftragId: ab.id, projektId: 'p1', anzahlung: { prozent: 20 } }, admin)).status, 200)
|
||||
assert.equal(tage(), '30')
|
||||
assert.equal((await post(`${base}/rechnungen`, { customerId: 'k1', art: 'anzahlung', auftragId: ab.id, projektId: 'p1', anzahlung: { prozent: 10 }, daysUntilDue: 7 }, admin)).status, 200)
|
||||
assert.equal(tage(), '7')
|
||||
assert.equal((await post(`${base}/rechnungen`, { customerId: 'k1', items: POSTEN }, admin)).status, 200)
|
||||
assert.equal(tage(), '14')
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
// test/auftraegeSchalter.test.js
|
||||
import { test, beforeEach, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import { config } from '../server/config.js'
|
||||
import { auftraegeAktiv } from '../server/services/auftraege/schalter.js'
|
||||
|
||||
const ENV = ['AUFTRAEGE_ENABLED', 'AUFTRAEGE_PILOT_EMAILS', 'AUFTRAEGE_UPLOAD_DIR', 'APPWRITE_COLLECTION_AUFTRAEGE', 'APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN']
|
||||
|
||||
let original
|
||||
beforeEach(() => {
|
||||
original = { ...config.auftraege }
|
||||
})
|
||||
afterEach(() => {
|
||||
Object.assign(config.auftraege, original)
|
||||
})
|
||||
|
||||
/** Frische config.js-Instanz mit genau diesen Umgebungsvariablen (alle anderen entfernt). */
|
||||
let freshCounter = 0
|
||||
async function freshConfig(env = {}) {
|
||||
const saved = Object.fromEntries(ENV.map((key) => [key, process.env[key]]))
|
||||
for (const key of ENV) delete process.env[key]
|
||||
Object.assign(process.env, env)
|
||||
try {
|
||||
freshCounter += 1
|
||||
return (await import(`../server/config.js?auftraege-test=${freshCounter}`)).config
|
||||
} finally {
|
||||
for (const key of ENV) {
|
||||
if (saved[key] === undefined) delete process.env[key]
|
||||
else process.env[key] = saved[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('Vorgabe: aus, keine Pilotadressen, Ablage unter upload-data/auftraege, Collection-IDs wie die Namen', async () => {
|
||||
const fresh = await freshConfig()
|
||||
assert.deepEqual(fresh.auftraege, { mode: 'off', pilotEmails: [], uploadDir: path.join(process.cwd(), 'upload-data', 'auftraege') })
|
||||
assert.equal(fresh.collections.auftraege, 'auftraege')
|
||||
assert.equal(fresh.collections.projektRechnungen, 'projektRechnungen')
|
||||
})
|
||||
|
||||
test('AUFTRAEGE_ENABLED: leer und Unbekanntes sind off, " ON " ist on, " Pilot " ist pilot mit bereinigter Liste', async () => {
|
||||
for (const value of ['', ' ', 'banane', 'true', '1']) {
|
||||
assert.equal((await freshConfig({ AUFTRAEGE_ENABLED: value })).auftraege.mode, 'off', JSON.stringify(value))
|
||||
}
|
||||
assert.equal((await freshConfig({ AUFTRAEGE_ENABLED: ' ON ' })).auftraege.mode, 'on')
|
||||
const fresh = await freshConfig({ AUFTRAEGE_ENABLED: ' Pilot ', AUFTRAEGE_PILOT_EMAILS: ' Kenso.Gri@Gmail.com, ,justin@webklar.com ' })
|
||||
assert.equal(fresh.auftraege.mode, 'pilot')
|
||||
assert.deepEqual(fresh.auftraege.pilotEmails, ['kenso.gri@gmail.com', 'justin@webklar.com'])
|
||||
})
|
||||
|
||||
test('Ablage und Collection-IDs lassen sich per Umgebung ändern', async () => {
|
||||
const fresh = await freshConfig({
|
||||
AUFTRAEGE_UPLOAD_DIR: '/tmp/ab-test',
|
||||
APPWRITE_COLLECTION_AUFTRAEGE: 'abX',
|
||||
APPWRITE_COLLECTION_PROJEKT_RECHNUNGEN: 'prX',
|
||||
})
|
||||
assert.equal(fresh.auftraege.uploadDir, '/tmp/ab-test')
|
||||
assert.deepEqual([fresh.collections.auftraege, fresh.collections.projektRechnungen], ['abX', 'prX'])
|
||||
})
|
||||
|
||||
test('off sperrt alle, auch mit Stern in der Liste; ein unbekannter Modus gilt als off', () => {
|
||||
config.auftraege.mode = 'off'
|
||||
config.auftraege.pilotEmails = ['*', 'kenso.gri@gmail.com']
|
||||
assert.equal(auftraegeAktiv({ email: 'kenso.gri@gmail.com' }), false)
|
||||
assert.equal(auftraegeAktiv({ email: 'jemand@example.com' }), false)
|
||||
for (const value of [undefined, null, '', 'banane', 'PILOT']) {
|
||||
config.auftraege.mode = value
|
||||
assert.equal(auftraegeAktiv({ email: 'kenso.gri@gmail.com' }), false, String(value))
|
||||
}
|
||||
})
|
||||
|
||||
test('pilot: nur Adressen der Liste, ohne Beachtung von Groß- und Kleinschreibung und Leerraum', () => {
|
||||
config.auftraege.mode = 'pilot'
|
||||
config.auftraege.pilotEmails = ['a@b.de']
|
||||
assert.equal(auftraegeAktiv({ email: 'A@b.de' }), true)
|
||||
assert.equal(auftraegeAktiv({ email: ' a@b.de ' }), true)
|
||||
assert.equal(auftraegeAktiv({ email: 'x@y.de' }), false)
|
||||
assert.equal(auftraegeAktiv({ email: 'a@b.de.evil.de' }), false)
|
||||
for (const kunde of [undefined, null, {}, { email: '' }, { email: ' ' }]) assert.equal(auftraegeAktiv(kunde), false)
|
||||
})
|
||||
|
||||
test('pilot mit Stern lässt alle durch, auch ohne Adresse; eine leere Liste niemanden', () => {
|
||||
config.auftraege.mode = 'pilot'
|
||||
config.auftraege.pilotEmails = ['*']
|
||||
assert.equal(auftraegeAktiv({ email: 'x@y.de' }), true)
|
||||
assert.equal(auftraegeAktiv(null), true)
|
||||
config.auftraege.pilotEmails = []
|
||||
assert.equal(auftraegeAktiv({ email: 'a@b.de' }), false)
|
||||
})
|
||||
|
||||
test('on gilt für alle; ein Kundendokument zählt wie { email }', () => {
|
||||
config.auftraege.mode = 'on'
|
||||
config.auftraege.pilotEmails = []
|
||||
assert.equal(auftraegeAktiv({ email: 'x@y.de' }), true)
|
||||
assert.equal(auftraegeAktiv(undefined), true)
|
||||
config.auftraege.mode = 'pilot'
|
||||
config.auftraege.pilotEmails = ['k@example.invalid']
|
||||
assert.equal(auftraegeAktiv({ $id: 'c1', email: 'k@example.invalid', name: 'Kunde Eins' }), true)
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
// test/auftraegeSchema.test.js
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { AUFTRAEGE_COLLECTIONS, AUFTRAEGE_DATETIMES, AUFTRAEGE_ZUSATZ_ATTRIBUTE } from '../server/services/auftraege/schema.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
|
||||
// Spalten aus Übersicht V1 in Kurzschrift: sN = Text mit Größe N, i = ganze Zahl, b = Wahrheitswert, d = Datum und Zeit
|
||||
const SPALTEN = {
|
||||
auftraege: `nummer:s20 jahr:i laufnummer:i status:s20 kundenId:s64 kundeJson:s2000 projektId:s64 titel:s200
|
||||
zeitquelleJson:s1000 art:s20 positionenJson:s20000 stundensatzCents:i schaetzungMinuten:i summeCents:i
|
||||
zahlungsplanJson:s1000 zahlungszielTage:i auftragVom:s10 auftragWeg:s20 startAm:s10 fertigBis:s10
|
||||
texteFassung:s20 agbFassung:s20 pdfDatei:s300 pdfSha256:s64 gesendetAm:d gesendetAn:s255 mailStatus:s20
|
||||
mailFehler:s500 tokenHash:s64 tokenGueltigBis:d bestaetigtAm:d bestaetigtUeber:s10 bestaetigtPdfSha256:s64
|
||||
fruehbeginn:b fruehbeginnFassung:s20 ersetztId:s64 ersetztDurchId:s64 storniertAm:d widerrufenAm:d
|
||||
erstelltVon:s255 erstelltAm:d geaendertAm:d`,
|
||||
projektRechnungen: `invoiceId:s64 kundenId:s64 projektId:s64 auftragId:s64 art:s20 betragCents:i
|
||||
arbeitsblattIdsJson:s20000 rechnungsnummer:s64 aktiv:b erstelltVon:s255 erstelltAm:d`,
|
||||
}
|
||||
function attribut(kurz) {
|
||||
const [key, art] = kurz.split(':')
|
||||
if (art === 'i') return { key, type: 'integer', required: false }
|
||||
if (art === 'b') return { key, type: 'boolean', required: false }
|
||||
if (art === 'd') return { key, type: 'datetime', required: false }
|
||||
return { key, type: 'string', size: Number(art.slice(1)), required: false }
|
||||
}
|
||||
const erwartet = (name) => SPALTEN[name].trim().split(/\s+/).map(attribut)
|
||||
|
||||
test('zwei Collections mit den Spalten aus Übersicht V1', () => {
|
||||
assert.deepEqual(Object.keys(AUFTRAEGE_COLLECTIONS), ['auftraege', 'projektRechnungen'])
|
||||
assert.deepEqual(AUFTRAEGE_COLLECTIONS.auftraege.attributes, erwartet('auftraege'))
|
||||
assert.deepEqual(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes, erwartet('projektRechnungen'))
|
||||
assert.equal(AUFTRAEGE_COLLECTIONS.auftraege.attributes.length, 42)
|
||||
assert.equal(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes.length, 11)
|
||||
})
|
||||
|
||||
test('Indizes und Zusatzfelder wie in Übersicht V1', () => {
|
||||
assert.deepEqual(AUFTRAEGE_COLLECTIONS.auftraege.indexes, [
|
||||
{ key: 'idx_kunde', type: 'key', attributes: ['kundenId'] },
|
||||
{ key: 'idx_projekt', type: 'key', attributes: ['projektId'] },
|
||||
{ key: 'idx_jahr', type: 'key', attributes: ['jahr'] },
|
||||
{ key: 'idx_token', type: 'key', attributes: ['tokenHash'] },
|
||||
{ key: 'idx_nummer', type: 'key', attributes: ['nummer'] },
|
||||
])
|
||||
assert.deepEqual(AUFTRAEGE_COLLECTIONS.projektRechnungen.indexes, [
|
||||
{ key: 'idx_kunde', type: 'key', attributes: ['kundenId'] },
|
||||
{ key: 'idx_projekt', type: 'key', attributes: ['projektId'] },
|
||||
{ key: 'idx_auftrag', type: 'key', attributes: ['auftragId'] },
|
||||
])
|
||||
assert.deepEqual(AUFTRAEGE_ZUSATZ_ATTRIBUTE, {
|
||||
customers: [{ key: 'kundenart', type: 'string', size: 20, required: false }],
|
||||
financeSettings: [{ key: 'steuernummer', type: 'string', size: 40, required: false }],
|
||||
})
|
||||
})
|
||||
|
||||
test('AUFTRAEGE_DATETIMES nennt je Collection genau die Datumsfelder', () => {
|
||||
assert.deepEqual(AUFTRAEGE_DATETIMES, {
|
||||
auftraege: ['gesendetAm', 'tokenGueltigBis', 'bestaetigtAm', 'storniertAm', 'widerrufenAm', 'erstelltAm', 'geaendertAm'],
|
||||
projektRechnungen: ['erstelltAm'],
|
||||
})
|
||||
})
|
||||
|
||||
test('schemaKeys kennt die neuen Collections und die Steuernummer; Bestehendes bleibt', () => {
|
||||
assert.deepEqual(schemaKeys('auftraege'), AUFTRAEGE_COLLECTIONS.auftraege.attributes.map((a) => a.key))
|
||||
assert.deepEqual(schemaKeys('projektRechnungen'), AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes.map((a) => a.key))
|
||||
const einstellungen = schemaKeys('financeSettings')
|
||||
for (const key of ['steuernummer', 'bankDetailsText', 'taxReservePercent']) assert.ok(einstellungen.includes(key), key)
|
||||
assert.ok(!schemaKeys('financeFixedCosts').includes('steuernummer'))
|
||||
assert.ok(schemaKeys('portalFeatures').includes('unlockedBy') && schemaKeys('termineConfig').includes('einstellung'))
|
||||
assert.throws(() => schemaKeys('gibtEsNicht'), /fehlt im Setup-Skript/)
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
starteUmgebung, KUNDE_ID, VON, MELDUNG, festpreisEingabe, kundeDoc, baueDeps, sha256, abgelehnt,
|
||||
ergebnis, tor, warte, wartebis, dateienIn,
|
||||
} from './helpers/auftraegeAb.js'
|
||||
|
||||
const umg = await starteUmgebung()
|
||||
const { aw, config, uploadDir } = umg
|
||||
after(() => umg.schliesse())
|
||||
beforeEach(umg.zuruecksetzen)
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { getAuftrag, listAuftraege, updateAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { lesePdf } = await import('../server/services/auftraege/ablage.js')
|
||||
const { kundeSnapshot } = await import('../server/services/auftraege/auftragSicht.js')
|
||||
const { TEXTE_FASSUNG } = await import('../server/services/auftraege/texte.js')
|
||||
const { AGB_FASSUNG, TOKEN_GUELTIG_TAGE } = await import('../server/services/auftraege/konstanten.js')
|
||||
const { AuftragError } = await import('../server/services/auftraege/fehler.js')
|
||||
const { legeEntwurfAn, aendereEntwurf, loescheEntwurf, sendeAuftrag } = await import('../server/services/auftraege/auftraege.js')
|
||||
|
||||
const JETZT = new Date('2026-10-06T10:00:00Z') // Berlin: 12:00
|
||||
const neuerEntwurf = (patch) => legeEntwurfAn(festpreisEingabe(patch), { von: VON })
|
||||
const sende = (id, deps, extra = {}) => sendeAuftrag(id, { von: VON, deps, jetzt: JETZT, ...extra })
|
||||
|
||||
const VOR_DER_NUMMER = [
|
||||
['Interessent', { customerStatus: 'lead' }, 400, MELDUNG.keinFesterKunde],
|
||||
['verlorener Kunde (Schreibweise egal)', { customerStatus: ' Lost ' }, 400, MELDUNG.keinFesterKunde],
|
||||
['Kundenart fehlt', { kundenart: '' }, 400, MELDUNG.keineKundenart],
|
||||
['Kundenart unbekannt', { kundenart: 'firma' }, 400, MELDUNG.keineKundenart],
|
||||
['E-Mail fehlt', { email: '' }, 400, MELDUNG.keineMail],
|
||||
['E-Mail nur Leerzeichen', { email: ' ' }, 400, MELDUNG.keineMail],
|
||||
['Straße fehlt', { street: '' }, 409, MELDUNG.adresse],
|
||||
['PLZ nur Leerzeichen', { postalCode: ' ' }, 409, MELDUNG.adresse],
|
||||
['Ort fehlt', { city: '' }, 409, MELDUNG.adresse],
|
||||
['Interessent hat Vorrang vor allem anderen', { customerStatus: 'lead', kundenart: '', email: '', street: '' }, 400, MELDUNG.keinFesterKunde],
|
||||
['Kundenart kommt vor E-Mail und Anschrift', { kundenart: '', email: '', street: '' }, 400, MELDUNG.keineKundenart],
|
||||
['E-Mail kommt vor der Anschrift', { email: '', street: '' }, 400, MELDUNG.keineMail],
|
||||
]
|
||||
|
||||
test('Senden: unvollständige Kundendaten werden vor der Nummer abgelehnt – nichts wird erzeugt, gespeichert oder verbraucht', async () => {
|
||||
const entwurf = await neuerEntwurf()
|
||||
const { deps, calls } = baueDeps()
|
||||
for (const [name, patch, status, meldung] of VOR_DER_NUMMER) {
|
||||
aw.seed(config.collections.customers, kundeDoc(patch))
|
||||
await abgelehnt(sende(entwurf.id, deps), status, meldung, name)
|
||||
}
|
||||
assert.deepEqual([calls.pdf.length, calls.token.length, calls.mail.length], [0, 0, 0])
|
||||
assert.deepEqual(dateienIn(uploadDir), [])
|
||||
const unveraendert = await getAuftrag(entwurf.id)
|
||||
assert.deepEqual([unveraendert.status, unveraendert.nummer, unveraendert.jahr], ['entwurf', '', null])
|
||||
aw.seed(config.collections.customers, kundeDoc()) // der Kunde ist wieder vollständig: die erste echte Nummer ist 0001
|
||||
assert.equal((await sende(entwurf.id, deps)).auftrag.nummer, 'AB-2026-0001')
|
||||
})
|
||||
|
||||
test('Senden: der Entwurf wird vor der Nummer noch einmal geprüft (beschädigte Daten ergeben die Feldmeldung)', async () => {
|
||||
const entwurf = await neuerEntwurf()
|
||||
await updateAuftrag(entwurf.id, { positionen: [] })
|
||||
await abgelehnt(sende(entwurf.id, baueDeps().deps), 400, MELDUNG.positionen)
|
||||
})
|
||||
|
||||
test('Senden: Nummer, eingefrorener Kunde, PDF mit Prüfsumme, Token und Mail', async () => {
|
||||
aw.seed(config.collections.financeSettings, { $id: 'main', steuernummer: '12/345/67890', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' })
|
||||
const entwurf = await neuerEntwurf()
|
||||
const { deps, calls } = baueDeps()
|
||||
const { auftrag, mailFehler } = await sende(entwurf.id, deps)
|
||||
assert.equal(mailFehler, false)
|
||||
assert.deepEqual([auftrag.id, auftrag.status, auftrag.nummer, auftrag.jahr, auftrag.laufnummer], [entwurf.id, 'versendet', 'AB-2026-0001', 2026, 1])
|
||||
assert.equal(new Date(auftrag.gesendetAm).getTime(), JETZT.getTime())
|
||||
assert.equal(auftrag.gesendetAn, 'anna@example.invalid')
|
||||
assert.deepEqual(auftrag.kunde, kundeSnapshot(kundeDoc()))
|
||||
assert.equal(auftrag.kunde.kundenart, 'unternehmen')
|
||||
assert.deepEqual([auftrag.texteFassung, auftrag.agbFassung], [TEXTE_FASSUNG, AGB_FASSUNG])
|
||||
assert.deepEqual([auftrag.mailStatus, auftrag.mailFehler], ['gesendet', ''])
|
||||
// PDF: einmal mit der Nummer und dem Kunden-Schnappschuss erzeugt, abgelegt, Prüfsumme gespeichert
|
||||
const pdf = Buffer.from('%PDF-1.4 AB-2026-0001')
|
||||
assert.equal(calls.pdf.length, 1)
|
||||
assert.equal(calls.pdf[0].auftrag.nummer, 'AB-2026-0001')
|
||||
assert.deepEqual(calls.pdf[0].auftrag.kunde, kundeSnapshot(kundeDoc()))
|
||||
assert.deepEqual(calls.pdf[0].optionen, { settings: { steuernummer: '12/345/67890', bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00' } })
|
||||
assert.equal(auftrag.pdfDatei, `${KUNDE_ID}/${entwurf.id}.pdf`)
|
||||
assert.equal(auftrag.pdfSha256, sha256(pdf))
|
||||
assert.deepEqual(await lesePdf(auftrag.pdfDatei), pdf)
|
||||
// Token: im Dokument nur der Hash, 60 Tage gültig ab dem Versand
|
||||
assert.equal(calls.token.length, 1)
|
||||
assert.equal(auftrag.tokenHash, sha256(calls.token[0]))
|
||||
assert.equal(new Date(auftrag.tokenGueltigBis).getTime(), JETZT.getTime() + TOKEN_GUELTIG_TAGE * 86_400_000)
|
||||
// Mail: einmal, mit dem gespeicherten Dokument, dem PDF und dem Klartext-Token
|
||||
assert.equal(calls.mail.length, 1)
|
||||
assert.equal(calls.mail[0].auftrag.nummer, 'AB-2026-0001')
|
||||
assert.deepEqual(calls.mail[0].pdf, pdf)
|
||||
assert.deepEqual([calls.mail[0].token, calls.mail[0].erinnerung], [calls.token[0], false])
|
||||
assert.deepEqual(await getAuftrag(entwurf.id), auftrag)
|
||||
})
|
||||
|
||||
test('Senden: Nummern zählen fortlaufend, im neuen Berliner Jahr beginnt wieder 0001', async () => {
|
||||
const { deps } = baueDeps()
|
||||
const nummern = []
|
||||
for (let i = 0; i < 2; i += 1) nummern.push((await sende((await neuerEntwurf()).id, deps)).auftrag.nummer)
|
||||
const neujahr = await sende((await neuerEntwurf()).id, deps, { jetzt: new Date('2026-12-31T23:30:00Z') })
|
||||
assert.deepEqual(nummern, ['AB-2026-0001', 'AB-2026-0002'])
|
||||
assert.deepEqual([neujahr.auftrag.nummer, neujahr.auftrag.jahr, neujahr.auftrag.laufnummer], ['AB-2027-0001', 2027, 1])
|
||||
assert.equal(new Date(neujahr.auftrag.gesendetAm).toISOString(), '2026-12-31T23:30:00.000Z')
|
||||
})
|
||||
|
||||
test('Senden: nur Entwürfe (409), unbekannte Aufträge (404) und Kunden (404)', async () => {
|
||||
const { deps } = baueDeps()
|
||||
const entwurf = await neuerEntwurf()
|
||||
await sende(entwurf.id, deps)
|
||||
await abgelehnt(sende(entwurf.id, deps), 409, MELDUNG.nurEntwurfSenden)
|
||||
await abgelehnt(sende('a_gibtsnicht', deps), 404, MELDUNG.auftragFehlt)
|
||||
const zweiter = await neuerEntwurf()
|
||||
await deleteDocument(config.collections.customers, KUNDE_ID)
|
||||
await abgelehnt(sende(zweiter.id, deps), 404, MELDUNG.kundeFehlt)
|
||||
})
|
||||
|
||||
test('Senden: scheitert die PDF-Erzeugung (Fehler, leer oder unvollständiger Entwurf), bleibt alles wie vorher und die Nummer ist nicht verbraucht', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const entwurf = await neuerEntwurf()
|
||||
const faelle = [
|
||||
[baueDeps({ pdfFehler: new Error('pdfkit kaputt') }), 500, MELDUNG.pdf],
|
||||
[baueDeps({ pdf: Buffer.alloc(0) }), 500, MELDUNG.pdf],
|
||||
[baueDeps({ pdfFehler: new AuftragError(409, 'Entwurf unvollständig: Anschrift fehlt.') }), 409, 'Entwurf unvollständig: Anschrift fehlt.'],
|
||||
]
|
||||
for (const [fehlerhaft, status, meldung] of faelle) {
|
||||
await abgelehnt(sende(entwurf.id, fehlerhaft.deps), status, meldung)
|
||||
assert.deepEqual([fehlerhaft.calls.token.length, fehlerhaft.calls.mail.length], [0, 0])
|
||||
}
|
||||
assert.deepEqual(dateienIn(uploadDir), [])
|
||||
const danach = await getAuftrag(entwurf.id)
|
||||
assert.deepEqual([danach.status, danach.nummer, danach.jahr], ['entwurf', '', null])
|
||||
assert.ok(!danach.pdfDatei && !danach.tokenHash)
|
||||
assert.equal((await sende(entwurf.id, baueDeps().deps)).auftrag.nummer, 'AB-2026-0001')
|
||||
})
|
||||
|
||||
test('Senden: eine schon vorhandene Zieldatei wird nie überschrieben – Fehler, Entwurf und Datei bleiben', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const entwurf = await neuerEntwurf()
|
||||
const ziel = path.join(uploadDir, KUNDE_ID, `${entwurf.id}.pdf`)
|
||||
fs.mkdirSync(path.dirname(ziel), { recursive: true })
|
||||
fs.writeFileSync(ziel, 'Rest eines Absturzes')
|
||||
const { deps, calls } = baueDeps()
|
||||
await abgelehnt(sende(entwurf.id, deps), 500, MELDUNG.pdf)
|
||||
assert.equal(fs.readFileSync(ziel, 'utf8'), 'Rest eines Absturzes')
|
||||
assert.equal(calls.mail.length, 0)
|
||||
assert.equal((await getAuftrag(entwurf.id)).status, 'entwurf')
|
||||
})
|
||||
|
||||
test('Senden: scheitert das Speichern der Auftragsbestätigung, wird die eben abgelegte Datei entfernt und das Senden lässt sich wiederholen', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const entwurf = await neuerEntwurf()
|
||||
const { deps, calls } = baueDeps()
|
||||
aw.failNext({ collection: config.collections.auftraege, method: 'PATCH', status: 500 })
|
||||
await assert.rejects(sende(entwurf.id, deps), (err) => err.status === 500)
|
||||
assert.equal(calls.mail.length, 0)
|
||||
assert.deepEqual(dateienIn(uploadDir), [])
|
||||
assert.equal((await getAuftrag(entwurf.id)).status, 'entwurf')
|
||||
const { auftrag } = await sende(entwurf.id, deps)
|
||||
assert.equal(auftrag.nummer, 'AB-2026-0001')
|
||||
assert.deepEqual(dateienIn(uploadDir), [`${KUNDE_ID}/${entwurf.id}.pdf`])
|
||||
})
|
||||
|
||||
test('Senden: scheitert nur die Mail, bleibt die AB versendet (mailStatus fehler) und der Fehlertext verrät nichts Internes', async () => {
|
||||
mock.method(console, 'error', () => {})
|
||||
const [e1, e2] = [await neuerEntwurf(), await neuerEntwurf()]
|
||||
const mitCode = baueDeps({ mailFehler: Object.assign(new Error('535 Login smtp.example.invalid'), { code: 'EAUTH' }) })
|
||||
const r1 = await sende(e1.id, mitCode.deps)
|
||||
assert.equal(r1.mailFehler, true)
|
||||
assert.deepEqual(
|
||||
[r1.auftrag.status, r1.auftrag.nummer, r1.auftrag.mailStatus, r1.auftrag.mailFehler],
|
||||
['versendet', 'AB-2026-0001', 'fehler', 'E-Mail konnte nicht gesendet werden (EAUTH).'],
|
||||
)
|
||||
assert.ok(r1.auftrag.pdfDatei && r1.auftrag.tokenHash)
|
||||
assert.deepEqual(await getAuftrag(e1.id), r1.auftrag)
|
||||
const ohneCode = baueDeps({ mailFehler: new Error('Verbindung zu mail.example.invalid abgebrochen') })
|
||||
const r2 = await sende(e2.id, ohneCode.deps)
|
||||
assert.deepEqual([r2.auftrag.nummer, r2.auftrag.mailStatus, r2.auftrag.mailFehler], ['AB-2026-0002', 'fehler', 'E-Mail konnte nicht gesendet werden.'])
|
||||
})
|
||||
|
||||
test('Senden: zwei Entwürfe gleichzeitig bekommen verschiedene Nummern', async () => {
|
||||
const [a, b] = [await neuerEntwurf(), await neuerEntwurf()]
|
||||
const { deps } = baueDeps({ pdfWarten: warte(30) })
|
||||
const ergebnisse = await Promise.all([sende(a.id, deps), sende(b.id, deps)])
|
||||
assert.deepEqual(ergebnisse.map((e) => e.auftrag.nummer).sort(), ['AB-2026-0001', 'AB-2026-0002'])
|
||||
assert.equal(new Set(ergebnisse.map((e) => e.auftrag.pdfDatei)).size, 2)
|
||||
})
|
||||
|
||||
test('Senden: dieselbe AB zweimal gleichzeitig (Doppelklick) – einmal gesendet, das zweite Mal 409, genau eine Nummer und eine Mail', async () => {
|
||||
const entwurf = await neuerEntwurf()
|
||||
const { deps, calls } = baueDeps({ pdfWarten: warte(30) })
|
||||
const ergebnisse = await Promise.allSettled([sende(entwurf.id, deps), sende(entwurf.id, deps)])
|
||||
assert.deepEqual(ergebnisse.map((r) => r.status).sort(), ['fulfilled', 'rejected'])
|
||||
const abgewiesen = ergebnisse.find((r) => r.status === 'rejected').reason
|
||||
assert.deepEqual([abgewiesen.status, abgewiesen.message], [409, MELDUNG.nurEntwurfSenden])
|
||||
assert.deepEqual([calls.pdf.length, calls.mail.length], [1, 1])
|
||||
assert.deepEqual((await listAuftraege({ jahr: 2026 })).map((a) => a.nummer), ['AB-2026-0001'])
|
||||
})
|
||||
|
||||
test('Senden hält die Sperre: Ändern und Löschen währenddessen warten und scheitern danach mit 409', async () => {
|
||||
const entwurf = await neuerEntwurf()
|
||||
const pdfTor = tor()
|
||||
const { deps, calls } = baueDeps({ pdfWarten: pdfTor.promise })
|
||||
const senden = ergebnis(sende(entwurf.id, deps))
|
||||
await wartebis(() => calls.pdf.length === 1) // das Senden hält die Sperre und wartet auf das PDF
|
||||
const aendern = ergebnis(aendereEntwurf(entwurf.id, { titel: 'Heimlich geändert' }))
|
||||
const loeschen = ergebnis(loescheEntwurf(entwurf.id))
|
||||
await warte(30) // beide sind unterwegs, dürfen aber noch nichts geschrieben haben
|
||||
assert.equal((await getAuftrag(entwurf.id)).titel, 'Website Beispiel')
|
||||
pdfTor.freigeben()
|
||||
const [s, a, l] = await Promise.all([senden, aendern, loeschen])
|
||||
assert.equal(s.wert.auftrag.status, 'versendet')
|
||||
assert.deepEqual([a.fehler?.status, a.fehler?.message], [409, MELDUNG.nurEntwurfAendern])
|
||||
assert.deepEqual([l.fehler?.status, l.fehler?.message], [409, MELDUNG.nurEntwurfLoeschen])
|
||||
const danach = await getAuftrag(entwurf.id)
|
||||
assert.deepEqual([danach.titel, danach.status], ['Website Beispiel', 'versendet'])
|
||||
})
|
||||
|
||||
test('Senden: während die Mail unterwegs ist, ist die Sperre schon frei (die nächste AB bekommt ihre Nummer)', async () => {
|
||||
const [a, b] = [await neuerEntwurf(), await neuerEntwurf()]
|
||||
const mailTor = tor()
|
||||
const langsam = baueDeps({ mailWarten: mailTor.promise })
|
||||
const ersteMail = ergebnis(sende(a.id, langsam.deps))
|
||||
await wartebis(() => langsam.calls.mail.length === 1)
|
||||
const zweite = await sende(b.id, baueDeps().deps) // läuft durch, obwohl die Mail der ersten noch hängt
|
||||
assert.equal(zweite.auftrag.nummer, 'AB-2026-0002')
|
||||
mailTor.freigeben()
|
||||
assert.equal((await ersteMail).wert.auftrag.nummer, 'AB-2026-0001')
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
// test/auftraegeSetup.test.js
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { config } from '../server/config.js'
|
||||
import { AUFTRAEGE_COLLECTIONS } from '../server/services/auftraege/schema.js'
|
||||
import { main } from '../scripts/auftraege-setup.mjs'
|
||||
|
||||
// Geprüft wird nur mit dem Client mit Zustand unten (Muster: test/zeitenSetup.test.js), nie gegen Appwrite.
|
||||
const NAMES = ['auftraege', 'projektRechnungen']
|
||||
const ids = NAMES.map((name) => config.collections[name])
|
||||
const customersId = config.collections.customers
|
||||
const settingsId = config.collections.financeSettings
|
||||
const keysOf = (name) => AUFTRAEGE_COLLECTIONS[name].attributes.map((a) => a.key)
|
||||
const quiet = () => {}
|
||||
const leer = () => ({ collections: [], attrs: [], indexes: [] })
|
||||
|
||||
/** Client mit Zustand: Attribute gibt es nur in vorhandenen Collections, Indizes nur auf vorhandenen Attributen. */
|
||||
function fakeClient(initial = {}) {
|
||||
const start = {
|
||||
[customersId]: { attrs: ['name', 'email'] },
|
||||
[settingsId]: { attrs: ['minBufferCents', 'bankDetailsText'] },
|
||||
...initial,
|
||||
}
|
||||
const state = new Map(
|
||||
Object.entries(start).map(([id, c]) => [id, { attrs: new Map((c.attrs || []).map((key) => [key, null])), indexes: new Set(c.indexes || []) }]),
|
||||
)
|
||||
const calls = leer()
|
||||
const need = (id) => {
|
||||
if (!state.has(id)) throw Object.assign(new Error(`Collection ${id} fehlt`), { status: 404 })
|
||||
return state.get(id)
|
||||
}
|
||||
return {
|
||||
state,
|
||||
calls,
|
||||
async collectionExists(id) {
|
||||
return state.has(id)
|
||||
},
|
||||
async ensureCollection(id, name) {
|
||||
if (state.has(id)) return false
|
||||
state.set(id, { attrs: new Map(), indexes: new Set() })
|
||||
calls.collections.push({ id, name })
|
||||
return true
|
||||
},
|
||||
async listAttributes(id) {
|
||||
return [...need(id).attrs.keys()].map((key) => ({ key }))
|
||||
},
|
||||
async createAttribute(id, attr) {
|
||||
const collection = need(id)
|
||||
if (collection.attrs.has(attr.key)) throw new Error(`Attribut ${id}.${attr.key} existiert schon`)
|
||||
collection.attrs.set(attr.key, attr)
|
||||
calls.attrs.push(`${id}.${attr.key}`)
|
||||
},
|
||||
async waitForAttribute(id, key) {
|
||||
assert.ok(need(id).attrs.has(key), `wartet auf nie angelegtes Attribut ${id}.${key}`)
|
||||
},
|
||||
async listIndexes(id) {
|
||||
return [...need(id).indexes].map((key) => ({ key }))
|
||||
},
|
||||
async createIndex(id, index) {
|
||||
const collection = need(id)
|
||||
for (const key of index.attributes) {
|
||||
if (!collection.attrs.has(key)) throw new Error(`Index ${index.key}: Attribut ${key} fehlt in ${id}`)
|
||||
}
|
||||
collection.indexes.add(index.key)
|
||||
calls.indexes.push(`${id}.${index.key}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const expectedAttrs = [
|
||||
...NAMES.flatMap((name) => keysOf(name).map((key) => `${config.collections[name]}.${key}`)),
|
||||
`${customersId}.kundenart`,
|
||||
`${settingsId}.steuernummer`,
|
||||
]
|
||||
const expectedIndexes = NAMES.flatMap((name) => AUFTRAEGE_COLLECTIONS[name].indexes.map((i) => `${config.collections[name]}.${i.key}`))
|
||||
|
||||
test('main legt auf leerer Datenbank 2 Collections mit allen Attributen und Indizes an und ergänzt customers und financeSettings', async () => {
|
||||
const client = fakeClient()
|
||||
const result = await main({ client, log: quiet })
|
||||
assert.deepEqual(client.calls.collections, ids.map((id) => ({ id, name: id })))
|
||||
assert.deepEqual([...client.calls.attrs].sort(), [...expectedAttrs].sort())
|
||||
assert.deepEqual([...client.calls.indexes].sort(), [...expectedIndexes].sort())
|
||||
assert.equal(expectedAttrs.length, 55)
|
||||
assert.equal(expectedIndexes.length, 8)
|
||||
assert.equal(result.missing.length, 55)
|
||||
assert.deepEqual(result.missingCollections, ids)
|
||||
assert.equal(result.missingIndexes.length, 8)
|
||||
// customers und financeSettings gibt es schon: sie werden nie angelegt, bekommen nur ihr neues Feld
|
||||
assert.ok(!client.calls.collections.some((c) => [customersId, settingsId].includes(c.id)))
|
||||
assert.deepEqual(client.state.get(customersId).attrs.get('kundenart'), { key: 'kundenart', type: 'string', size: 20, required: false })
|
||||
assert.deepEqual(client.state.get(settingsId).attrs.get('steuernummer'), { key: 'steuernummer', type: 'string', size: 40, required: false })
|
||||
})
|
||||
|
||||
test('main: zweiter Lauf legt nichts mehr an', async () => {
|
||||
const client = fakeClient()
|
||||
await main({ client, log: quiet })
|
||||
client.calls.collections.length = client.calls.attrs.length = client.calls.indexes.length = 0
|
||||
const result = await main({ client, log: quiet })
|
||||
assert.deepEqual(client.calls, leer())
|
||||
assert.deepEqual(result, { missing: [], missingCollections: [], missingIndexes: [] })
|
||||
})
|
||||
|
||||
test('main legt nur Fehlendes an, auch in einer schon vorhandenen Collection', async () => {
|
||||
const pr = config.collections.projektRechnungen
|
||||
const client = fakeClient({
|
||||
[pr]: { attrs: ['invoiceId', 'kundenId'], indexes: ['idx_kunde'] },
|
||||
[customersId]: { attrs: ['name', 'kundenart'] },
|
||||
})
|
||||
await main({ client, log: quiet })
|
||||
assert.deepEqual(client.calls.collections, [{ id: config.collections.auftraege, name: config.collections.auftraege }])
|
||||
assert.deepEqual(
|
||||
client.calls.attrs.filter((k) => k.startsWith(`${pr}.`)),
|
||||
keysOf('projektRechnungen').filter((k) => !['invoiceId', 'kundenId'].includes(k)).map((k) => `${pr}.${k}`),
|
||||
)
|
||||
assert.deepEqual(client.calls.indexes.filter((k) => k.startsWith(`${pr}.`)), [`${pr}.idx_projekt`, `${pr}.idx_auftrag`])
|
||||
assert.ok(!client.calls.attrs.includes(`${customersId}.kundenart`))
|
||||
assert.ok(client.calls.attrs.includes(`${settingsId}.steuernummer`))
|
||||
})
|
||||
|
||||
test('--check meldet Fehlendes und schreibt nichts', async () => {
|
||||
const client = fakeClient()
|
||||
const lines = []
|
||||
const result = await main({ check: true, client, log: (line) => lines.push(line) })
|
||||
assert.deepEqual(client.calls, leer())
|
||||
assert.equal(client.state.size, 2, 'nur customers und financeSettings, nichts angelegt')
|
||||
assert.deepEqual([...client.state.get(settingsId).attrs.keys()], ['minBufferCents', 'bankDetailsText'])
|
||||
assert.deepEqual(result.missingCollections, ids)
|
||||
assert.equal(result.missing.length, 55)
|
||||
assert.ok(lines.some((l) => l.includes(config.collections.auftraege) && l.includes('FEHLT')), lines.join('\n'))
|
||||
assert.ok(lines.some((l) => l.includes(`${settingsId}.steuernummer FEHLT`)), lines.join('\n'))
|
||||
assert.ok(lines.some((l) => l.includes(`Index ${config.collections.auftraege}.idx_nummer FEHLT`)), lines.join('\n'))
|
||||
})
|
||||
|
||||
test('--check bei vollständigem Stand: nichts fehlt; ein einzelnes fehlendes Feld wird genau genannt', async () => {
|
||||
const client = fakeClient()
|
||||
await main({ client, log: quiet })
|
||||
assert.deepEqual(await main({ check: true, client, log: quiet }), { missing: [], missingCollections: [], missingIndexes: [] })
|
||||
client.state.get(customersId).attrs.delete('kundenart')
|
||||
const result = await main({ check: true, client, log: quiet })
|
||||
assert.deepEqual(result.missing.map((a) => `${a.collection}.${a.key}`), [`${customersId}.kundenart`])
|
||||
assert.deepEqual(result.missingCollections, [])
|
||||
})
|
||||
|
||||
test('fehlt customers oder financeSettings, bricht main mit klarer Meldung ab, ohne etwas anzulegen', async () => {
|
||||
for (const [fehlt, hinweis] of [[settingsId, /setup-finanzen/], [customersId, /Ticketsystem/]]) {
|
||||
const client = fakeClient()
|
||||
client.state.delete(fehlt)
|
||||
await assert.rejects(
|
||||
() => main({ client, log: quiet }),
|
||||
(err) => err.message.includes(fehlt) && err.message.includes('fehlt') && hinweis.test(err.message),
|
||||
fehlt,
|
||||
)
|
||||
assert.deepEqual(client.calls, leer())
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,288 @@
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js'
|
||||
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') },
|
||||
datetimes: AUFTRAEGE_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { seedRechnungsDaten, legeAuftragAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js')
|
||||
const {
|
||||
mapInvoice, createProjectInvoice, rechnungsVorschau, sendInvoiceNow, voidInvoice, deleteDraft, invalidateFinanceCache, resetInvoiceCreateGuardForTests,
|
||||
} = await import('../server/services/finance/stripeFinance.js')
|
||||
const { getProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js')
|
||||
const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { sperreAuftrag } = await import('../server/services/auftraege/konstanten.js')
|
||||
const { withLocks } = await import('../server/services/keyedLock.js')
|
||||
const { berlinDate, formatDe } = await import('../server/services/finance/dates.js')
|
||||
|
||||
after(() => Promise.all([aw.close(), st.close()]))
|
||||
seedRechnungsDaten(aw)
|
||||
config.auftraege.mode = 'on'
|
||||
beforeEach(async () => {
|
||||
mock.restoreAll()
|
||||
await leereRechnungsDaten(aw, st)
|
||||
invalidateFinanceCache()
|
||||
resetInvoiceCreateGuardForTests()
|
||||
})
|
||||
|
||||
const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt
|
||||
const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }]
|
||||
const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path))
|
||||
const writes = () => st.calls.filter((c) => c.method !== 'GET')
|
||||
const wirft = (promise, status, message) =>
|
||||
assert.rejects(promise, (err) => {
|
||||
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
|
||||
assert.equal(err.message, message)
|
||||
return true
|
||||
})
|
||||
|
||||
test('mapInvoice: Projektfelder aus den Metadaten, art mit Rückfall abo oder frei', () => {
|
||||
const roh = (over = {}) => ({ id: 'in_1', object: 'invoice', status: 'open', customer: 'cus_1', created: 1_790_000_000, total: 1000, metadata: {}, lines: { object: 'list', data: [] }, ...over })
|
||||
const felder = (r) => [r.projektId, r.projektName, r.art, r.auftragId, r.auftragNummer]
|
||||
const heute = { today: '2026-10-06' }
|
||||
assert.deepEqual(felder(mapInvoice(roh(), heute)), ['', '', 'frei', '', ''])
|
||||
assert.deepEqual(felder(mapInvoice(roh({ subscription: 'sub_1' }), heute)), ['', '', 'abo', '', ''])
|
||||
const metadata = { projektId: 'p1', projektName: 'Website Wolf', art: 'schluss', auftragId: 'a1', auftragNummer: 'AB-2026-0002' }
|
||||
assert.deepEqual(felder(mapInvoice(roh({ metadata }), heute)), ['p1', 'Website Wolf', 'schluss', 'a1', 'AB-2026-0002'])
|
||||
for (const art of ['quatsch', 'abo', '']) assert.equal(mapInvoice(roh({ metadata: { art } }), heute).art, 'frei', `art "${art}"`)
|
||||
})
|
||||
|
||||
test('createProjectInvoice frei: A4 für jede Rechnung, Art in den Metadaten, keine Zusatzfelder, kein Projektdatensatz', async () => {
|
||||
const rechnung = await createProjectInvoice({ customerId: 'k1', items: POSTEN, createdBy: 'kenso@webklar.com' })
|
||||
const [create] = callsTo('POST', '/v1/invoices')
|
||||
assert.equal(create.body['rendering[pdf][page_size]'], 'a4')
|
||||
assert.equal(create.body['metadata[art]'], 'frei')
|
||||
assert.equal(create.body['metadata[webklarCustomerId]'], 'k1')
|
||||
assert.ok(!Object.keys(create.body).some((k) => k.startsWith('custom_fields')))
|
||||
assert.ok(!Object.keys(create.body).some((k) => /^metadata\[(projektId|projektName|auftragId|auftragNummer)\]$/.test(k)))
|
||||
assert.deepEqual([rechnung.art, rechnung.status, rechnung.totalCents, rechnung.projektId], ['frei', 'entwurf', 1200, ''])
|
||||
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
|
||||
})
|
||||
|
||||
test('createProjectInvoice festbetrag: Zusatzfeld Projekt, Metadaten und ein Projektdatensatz mit der Rechnungs-ID', async () => {
|
||||
const items = [{ description: 'Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }]
|
||||
const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items, createdBy: 'kenso@webklar.com' })
|
||||
const [create] = callsTo('POST', '/v1/invoices')
|
||||
assert.equal(create.body['custom_fields[0][name]'], 'Projekt')
|
||||
assert.equal(create.body['custom_fields[0][value]'], 'Website anelia-wolf.com')
|
||||
assert.equal(create.body['custom_fields[1][name]'], undefined)
|
||||
assert.equal(create.body['rendering[pdf][page_size]'], 'a4')
|
||||
assert.deepEqual([create.body['metadata[art]'], create.body['metadata[projektId]'], create.body['metadata[projektName]']], ['festbetrag', 'p1', 'Website anelia-wolf.com'])
|
||||
assert.deepEqual([rechnung.art, rechnung.projektId, rechnung.projektName, rechnung.totalCents], ['festbetrag', 'p1', 'Website anelia-wolf.com', 50000])
|
||||
const doc = await getProjektRechnung(rechnung.id)
|
||||
assert.deepEqual(
|
||||
[doc.invoiceId, doc.kundenId, doc.projektId, doc.auftragId, doc.art, doc.betragCents, doc.rechnungsnummer, doc.aktiv, doc.erstelltVon],
|
||||
[rechnung.id, 'k1', 'p1', '', 'festbetrag', 50000, '', true, 'kenso@webklar.com']
|
||||
)
|
||||
})
|
||||
|
||||
test('createProjectInvoice aufwand: eine Zeile je Tag bei Stripe, Blätter im Projektdatensatz, Zeitraum als Zusatzfeld', async () => {
|
||||
const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2', 'ws3'], createdBy: 'k' })
|
||||
const items = callsTo('POST', '/v1/invoiceitems')
|
||||
assert.deepEqual(items.map((c) => [c.body.quantity, c.body.unit_amount_decimal, c.body.amount]), [['1', '22500', undefined], ['1', '10000', undefined]])
|
||||
assert.ok(items[0].body.description.startsWith('06.10.2026: '))
|
||||
const [create] = callsTo('POST', '/v1/invoices')
|
||||
assert.deepEqual([0, 1].map((i) => create.body[`custom_fields[${i}][name]`]), ['Projekt', 'Leistungszeitraum'])
|
||||
assert.equal(create.body['custom_fields[1][value]'], '06.10.–07.10.2026')
|
||||
assert.equal(rechnung.totalCents, 32500)
|
||||
assert.deepEqual((await getProjektRechnung(rechnung.id)).arbeitsblattIds, ['ws1', 'ws2', 'ws3'])
|
||||
})
|
||||
|
||||
test('createProjectInvoice schluss: positive Zeilen mit Menge und Einzelpreis, die Minuszeile nur mit amount (ohne quantity)', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const anzahlung = await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true, createdBy: 'k' })
|
||||
assert.deepEqual([anzahlung.totalCents, anzahlung.art, anzahlung.auftragNummer, anzahlung.status], [40000, 'anzahlung', 'AB-2026-0002', 'offen'])
|
||||
st.calls.length = 0
|
||||
const schluss = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, createdBy: 'k' })
|
||||
const [create] = callsTo('POST', '/v1/invoices')
|
||||
assert.deepEqual(
|
||||
[0, 1].map((i) => [create.body[`custom_fields[${i}][name]`], create.body[`custom_fields[${i}][value]`]]),
|
||||
[['Projekt', 'Website anelia-wolf.com'], ['Auftrag', 'AB-2026-0002 vom 06.10.2026']]
|
||||
)
|
||||
const items = callsTo('POST', '/v1/invoiceitems')
|
||||
const kunde = items[0].body.customer
|
||||
assert.deepEqual(items.map((c) => c.body), [
|
||||
{ customer: kunde, invoice: schluss.id, description: 'Website', quantity: '1', unit_amount_decimal: '60000', currency: 'eur' },
|
||||
{ customer: kunde, invoice: schluss.id, description: 'Texte', quantity: '2', unit_amount_decimal: '10000', currency: 'eur' },
|
||||
{ customer: kunde, invoice: schluss.id, description: `abzüglich Anzahlung, Rechnung ${anzahlung.number} vom ${formatDe(berlinDate())}`, amount: '-40000', currency: 'eur' },
|
||||
])
|
||||
assert.deepEqual([schluss.totalCents, schluss.art, schluss.status], [40000, 'schluss', 'entwurf'])
|
||||
})
|
||||
|
||||
test('createProjectInvoice: scheitert der Projektdatensatz, wird der Entwurf bei Stripe gelöscht und nichts bleibt zurück', async () => {
|
||||
const eingabe = { customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, createdBy: 'k' }
|
||||
aw.failNext({ collection: config.collections.projektRechnungen, method: 'POST', status: 500 })
|
||||
await assert.rejects(createProjectInvoice(eingabe))
|
||||
assert.equal(st.invoices.size, 0)
|
||||
assert.equal(callsTo('DELETE', /^\/v1\/invoices\/in_/).length, 1)
|
||||
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
|
||||
// der Schutz vor doppeltem Anlegen ist wieder frei
|
||||
assert.equal((await createProjectInvoice(eingabe)).status, 'entwurf')
|
||||
})
|
||||
|
||||
test('createProjectInvoice: der Fingerabdruck unterscheidet Art, Projekt, Auftrag und Anzahlung, gleicher Inhalt bleibt 30 Sekunden gesperrt', async () => {
|
||||
const basis = { customerId: 'k1', items: POSTEN }
|
||||
const GESPERRT = 'Diese Rechnung wird gerade schon erstellt.'
|
||||
await createProjectInvoice(basis)
|
||||
await createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p1' })
|
||||
await createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p3' })
|
||||
await wirft(createProjectInvoice({ ...basis, art: 'festbetrag', projektId: 'p3' }), 409, GESPERRT)
|
||||
const ab = await legeAuftragAn()
|
||||
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 50 } })
|
||||
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 } })
|
||||
await wirft(createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, anzahlung: { prozent: 25 } }), 409, GESPERRT)
|
||||
assert.equal(st.invoices.size, 5)
|
||||
})
|
||||
|
||||
test('rechnungsVorschau: Positionen, Summe, Zeitraum und Zusatzfelder ohne Stripe-Schreibzugriff und ohne Projektdatensatz', async () => {
|
||||
const v = await rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1' })
|
||||
assert.deepEqual(Object.keys(v).sort(), ['customFields', 'leistungszeitraum', 'positionen', 'summeCents'])
|
||||
assert.equal(v.summeCents, 32500)
|
||||
assert.deepEqual(v.positionen.map((p) => p.schluessel), ['2026-10-06', '2026-10-07'])
|
||||
assert.equal(v.leistungszeitraum, '06.10.–07.10.2026')
|
||||
assert.deepEqual(v.customFields.map((f) => f.name), ['Projekt', 'Leistungszeitraum'])
|
||||
assert.equal(writes().length, 0)
|
||||
assert.equal(aw.docs(config.collections.projektRechnungen).size, 0)
|
||||
})
|
||||
|
||||
test('rechnungsVorschau: dieselben Prüfungen wie das Anlegen; Anzahlung ohne Angaben zeigt den Standardwert', async () => {
|
||||
await createProjectInvoice({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] })
|
||||
await wirft(rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws3'] }), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
|
||||
await wirft(rechnungsVorschau({ customerId: 'k1', art: 'aufwand', projektId: 'p2' }), 404, 'Projekt nicht gefunden.')
|
||||
const ab = await legeAuftragAn()
|
||||
const v = await rechnungsVorschau({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id })
|
||||
assert.deepEqual([v.summeCents, v.positionen[0].schluessel], [40000, 'anzahlung'])
|
||||
assert.equal(aw.docs(config.collections.projektRechnungen).size, 1, 'nur der Entwurf von oben')
|
||||
})
|
||||
|
||||
test('Aufwand: zwei gleichzeitige Rechnungen mit denselben Blättern -> genau eine entsteht, die andere bekommt 409', async () => {
|
||||
st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 })
|
||||
const eingabe = (memo) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1', 'ws2'], memo, createdBy: 'k' })
|
||||
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe('Admin A')), createProjectInvoice(eingabe('Admin B'))])
|
||||
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
|
||||
assert.equal(ergebnisse[1].reason.status, 409)
|
||||
assert.equal(ergebnisse[1].reason.message, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
|
||||
assert.equal(st.invoices.size, 1)
|
||||
assert.equal(callsTo('POST', '/v1/invoices').length, 1)
|
||||
assert.equal(aw.docs(config.collections.projektRechnungen).size, 1)
|
||||
})
|
||||
|
||||
test('Aufwand: eine nur teilweise überschneidende Auswahl wird abgewiesen, die übrigen Blätter bleiben frei', async () => {
|
||||
const eingabe = (ids, memo) => ({ customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ids, memo })
|
||||
await createProjectInvoice(eingabe(['ws1', 'ws2'], 'A'))
|
||||
await wirft(createProjectInvoice(eingabe(['ws2', 'ws3'], 'B')), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
|
||||
const rest = await createProjectInvoice(eingabe(['ws3'], 'C'))
|
||||
assert.equal(rest.totalCents, 10000)
|
||||
assert.equal(st.invoices.size, 2)
|
||||
})
|
||||
|
||||
test('Doppelklick (gleicher Inhalt gleichzeitig): der Schutz vor doppeltem Anlegen greift, es entsteht eine Rechnung', async () => {
|
||||
const eingabe = { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'] }
|
||||
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe), createProjectInvoice(eingabe)])
|
||||
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
|
||||
assert.equal(ergebnisse[1].reason.message, 'Diese Rechnung wird gerade schon erstellt.')
|
||||
assert.equal(st.invoices.size, 1)
|
||||
})
|
||||
|
||||
test('Schluss: zwei gleichzeitige Schlussrechnungen zur selben AB -> eine entsteht, die andere stößt auf den Entwurf (409)', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
st.delayNext({ method: 'POST', path: '/v1/invoices', ms: 150 })
|
||||
const eingabe = (memo) => ({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo })
|
||||
const ergebnisse = await Promise.allSettled([createProjectInvoice(eingabe('A')), createProjectInvoice(eingabe('B'))])
|
||||
assert.deepEqual(ergebnisse.map((e) => e.status), ['fulfilled', 'rejected'])
|
||||
assert.equal(ergebnisse[1].reason.status, 409)
|
||||
assert.equal(ergebnisse[1].reason.message, `Erst den Entwurf über 800,00${NBSP}€ senden oder löschen.`)
|
||||
assert.equal(st.invoices.size, 1)
|
||||
})
|
||||
|
||||
test('Finalisieren trägt die Rechnungsnummer in den Projektdatensatz ein (senden und sendInvoiceNow); freie Rechnungen haben keinen Datensatz', async () => {
|
||||
const gesendet = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, senden: true })
|
||||
assert.match(gesendet.number, /^WK-\d{4}$/)
|
||||
assert.equal((await getProjektRechnung(gesendet.id)).rechnungsnummer, gesendet.number)
|
||||
const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, memo: 'später' })
|
||||
assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, '')
|
||||
const nachher = await sendInvoiceNow(entwurf.id)
|
||||
assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, nachher.number)
|
||||
const frei = await createProjectInvoice({ customerId: 'k1', items: POSTEN, memo: 'frei', senden: true })
|
||||
assert.equal(await getProjektRechnung(frei.id), null)
|
||||
})
|
||||
|
||||
test('Schlussrechnung: nach dem Finalisieren steht die AB auf abgerechnet; Anzahlung und Entwurf ändern den Status nicht', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true })
|
||||
assert.equal((await getAuftrag(ab.id)).status, 'versendet')
|
||||
const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
|
||||
assert.equal((await getAuftrag(ab.id)).status, 'versendet')
|
||||
const gesendet = await sendInvoiceNow(entwurf.id)
|
||||
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
|
||||
assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, gesendet.number)
|
||||
await wirft(createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo: 'noch eine' }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.')
|
||||
})
|
||||
|
||||
test('Storno der Schlussrechnung: Datensatz inaktiv, AB zurück auf bestaetigt (mit bestaetigtAm) bzw. versendet; Storno einer Anzahlung ändert die AB nie', async () => {
|
||||
for (const [patch, erwartet] of [[{}, 'versendet'], [{ status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z' }, 'bestaetigt']]) {
|
||||
await leereRechnungsDaten(aw, st)
|
||||
resetInvoiceCreateGuardForTests()
|
||||
const ab = await legeAuftragAn({}, patch)
|
||||
const anzahlung = await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true })
|
||||
await voidInvoice(anzahlung.id)
|
||||
assert.equal((await getProjektRechnung(anzahlung.id)).aktiv, false)
|
||||
assert.equal((await getAuftrag(ab.id)).status, patch.status ?? 'versendet')
|
||||
const schluss = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, senden: true })
|
||||
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
|
||||
await voidInvoice(schluss.id)
|
||||
assert.equal((await getProjektRechnung(schluss.id)).aktiv, false)
|
||||
assert.equal((await getAuftrag(ab.id)).status, erwartet)
|
||||
// die stornierten Rechnungen zählen nicht: eine neue Schlussrechnung geht über die volle Summe
|
||||
assert.equal((await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo: 'neu' })).totalCents, 80000)
|
||||
}
|
||||
})
|
||||
|
||||
test('Entwurf löschen und Storno geben die Arbeitsblätter frei (Datensatz inaktiv)', async () => {
|
||||
const args = { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'], createdBy: 'k' }
|
||||
const entwurf = await createProjectInvoice(args)
|
||||
await wirft(createProjectInvoice({ ...args, memo: 'nochmal' }), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).')
|
||||
await deleteDraft(entwurf.id)
|
||||
assert.equal((await getProjektRechnung(entwurf.id)).aktiv, false)
|
||||
const offen = await createProjectInvoice({ ...args, memo: 'nochmal', senden: true })
|
||||
assert.equal(offen.status, 'offen')
|
||||
await wirft(createProjectInvoice({ ...args, memo: 'drittes Mal' }), 409, `Arbeitsblatt bereits abgerechnet (Rechnung ${offen.number}).`)
|
||||
await voidInvoice(offen.id)
|
||||
assert.equal((await getProjektRechnung(offen.id)).aktiv, false)
|
||||
assert.equal((await createProjectInvoice({ ...args, memo: 'viertes Mal' })).status, 'entwurf')
|
||||
})
|
||||
|
||||
test('Scheitert das Buchen nach dem Finalisieren, wird die Rechnung trotzdem gesendet (nur Protokoll); erneutes Senden trägt die Nummer nach', async () => {
|
||||
const log = mock.method(console, 'error', () => {})
|
||||
aw.failNext({ collection: config.collections.projektRechnungen, method: 'PATCH', status: 500 })
|
||||
const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, senden: true })
|
||||
assert.equal(rechnung.status, 'offen')
|
||||
assert.equal(callsTo('POST', /\/send$/).length, 1)
|
||||
assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('Rechnungsnummer buchen')))
|
||||
assert.equal((await getProjektRechnung(rechnung.id)).rechnungsnummer, '')
|
||||
const erneut = await sendInvoiceNow(rechnung.id)
|
||||
assert.equal(erneut.status, 'offen')
|
||||
assert.equal((await getProjektRechnung(rechnung.id)).rechnungsnummer, rechnung.number)
|
||||
})
|
||||
|
||||
test('Die AB ändert L2 nur unter der AB-Sperre: eine laufende Statusänderung eines anderen Strangs wird nicht überholt', async () => {
|
||||
const ab = await legeAuftragAn()
|
||||
const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id })
|
||||
let freigeben
|
||||
const gehalten = withLocks(sperreAuftrag(ab.id), () => new Promise((resolve) => { freigeben = resolve }))
|
||||
const senden = sendInvoiceNow(entwurf.id)
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
assert.equal((await getAuftrag(ab.id)).status, 'versendet', 'L2 wartet auf die Sperre der AB')
|
||||
freigeben()
|
||||
await Promise.all([gehalten, senden])
|
||||
assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet')
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
// test/auftraegeTextBausteine.test.js
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import * as T from '../server/services/auftraege/texte.js'
|
||||
|
||||
// Geprüft werden Form und eingesetzte Werte, nie ganze Sätze: die Wortlaute ändert L7.
|
||||
const alleTexte = (wert) =>
|
||||
typeof wert === 'string'
|
||||
? [wert]
|
||||
: Array.isArray(wert)
|
||||
? wert.flatMap(alleTexte)
|
||||
: wert && typeof wert === 'object'
|
||||
? Object.values(wert).flatMap(alleTexte)
|
||||
: []
|
||||
|
||||
test('Exportnamen und feste Angaben nach Übersicht V7', () => {
|
||||
assert.match(T.TEXTE_FASSUNG, /^\d{4}-\d{2}-\d{2}$/)
|
||||
assert.match(T.FRUEHBEGINN_FASSUNG, /^\d{4}-\d{2}-\d{2}$/)
|
||||
assert.deepEqual(T.ABSENDER, {
|
||||
name: 'WEBklar GbR', strasse: 'Am Schwimmbad 10', plzOrt: '67722 Winnweiler', telefon: '0176 23726355',
|
||||
email: 'support@webklar.com', web: 'webklar.com', vertretenDurch: 'Kenso Grimm und Justin Klein',
|
||||
})
|
||||
assert.equal(T.AB_TEXTE.titel, 'Auftragsbestätigung')
|
||||
assert.equal(T.AB_TEXTE.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
|
||||
assert.equal(T.BESTAETIGUNG_TEXT, 'Ich bestätige den Auftrag zu diesen Bedingungen.')
|
||||
assert.deepEqual(Object.keys(T.AB_TEXTE).sort(), ['agb', 'aufwand', 'einleitung', 'ersetzt', 'gruss', 'kleinunternehmer', 'mitwirkung', 'termine', 'titel', 'zahlungsplan'])
|
||||
assert.deepEqual(Object.keys(T.AB_TEXTE.zahlungsplan).sort(), ['anzahlung', 'frei', 'monatlich', 'nach_abnahme'])
|
||||
for (const name of ['mitwirkung', 'gruss']) assert.ok(typeof T.AB_TEXTE[name] === 'string' && T.AB_TEXTE[name].length > 5, name)
|
||||
assert.ok(typeof T.FRUEHBEGINN_TEXT === 'string' && T.FRUEHBEGINN_TEXT.length > 50 && /Widerruf/.test(T.FRUEHBEGINN_TEXT))
|
||||
})
|
||||
|
||||
test('Einleitung, AGB-Satz, Ersetzt-Hinweis und Termine setzen ihre Werte ein', () => {
|
||||
assert.match(T.AB_TEXTE.einleitung({ datum: '05.10.2026', weg: 'telefonisch' }), /05\.10\.2026.*telefonisch/)
|
||||
assert.doesNotMatch(T.AB_TEXTE.einleitung({ datum: '05.10.2026', weg: '' }), /\(\s*\)/, 'ohne Weg keine leere Klammer')
|
||||
assert.ok(T.AB_TEXTE.agb({ fassung: '30.09.2026' }).includes('30.09.2026'))
|
||||
const ersetzt = T.AB_TEXTE.ersetzt({ nummer: 'AB-2026-0001', datum: '01.10.2026' })
|
||||
assert.ok(ersetzt.includes('AB-2026-0001') && ersetzt.includes('01.10.2026'))
|
||||
assert.equal(T.AB_TEXTE.termine({ start: null, fertig: null }), null)
|
||||
assert.equal(T.AB_TEXTE.termine({}), null)
|
||||
assert.ok(T.AB_TEXTE.termine({ start: '01.11.2026', fertig: null }).includes('01.11.2026'))
|
||||
const beide = T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026' })
|
||||
assert.ok(beide.includes('01.11.2026') && beide.includes('15.12.2026'))
|
||||
assert.ok(!T.AB_TEXTE.termine({ start: null, fertig: '15.12.2026' }).includes('null'))
|
||||
})
|
||||
|
||||
test('Termine: für Privatpersonen kommt der Satz zum Beginn nach Ablauf der Widerrufsfrist dazu, auch ohne Termine', () => {
|
||||
const ohne = T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026' })
|
||||
assert.equal(T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026', privat: false }), ohne)
|
||||
assert.doesNotMatch(ohne, /Widerrufsfrist/)
|
||||
const privat = T.AB_TEXTE.termine({ start: '01.11.2026', fertig: '15.12.2026', privat: true })
|
||||
assert.ok(privat.startsWith(ohne) && privat.length > ohne.length && /Widerrufsfrist/.test(privat), privat)
|
||||
const nurSatz = T.AB_TEXTE.termine({ start: null, fertig: null, privat: true })
|
||||
assert.ok(typeof nurSatz === 'string' && /Widerrufsfrist/.test(nurSatz) && !nurSatz.includes('null'), String(nurSatz))
|
||||
assert.equal(T.AB_TEXTE.termine({ privat: true }), nurSatz)
|
||||
})
|
||||
|
||||
test('Aufwandsregel und Zahlungsplan: Werte, Schätzung nur wenn gegeben, Ziel 0 heißt sofort', () => {
|
||||
const mit = T.AB_TEXTE.aufwand({ satz: '100,00 €', schaetzung: '10' })
|
||||
assert.ok(mit.includes('100,00 €') && /\b15\b/.test(mit) && /\b9\b/.test(mit) && /\b10\b/.test(mit), mit)
|
||||
const ohne = T.AB_TEXTE.aufwand({ satz: '100,00 €', schaetzung: null })
|
||||
assert.ok(!/\b10\b/.test(ohne) && ohne.length < mit.length, ohne)
|
||||
assert.match(T.AB_TEXTE.aufwand({ satz: '100,00 €', schaetzung: '1' }), /\b1 Stunde(?!n)/)
|
||||
|
||||
const z = T.AB_TEXTE.zahlungsplan
|
||||
const anzahlung = z.anzahlung({ prozent: 50, anzahlung: '400,00 €', rest: '400,00 €', ziel: 14 })
|
||||
assert.ok(/50\s?%/.test(anzahlung) && anzahlung.split('400,00 €').length === 3 && /\b14\b/.test(anzahlung), anzahlung)
|
||||
for (const art of ['nach_abnahme', 'monatlich']) {
|
||||
assert.ok(/\b14\b/.test(z[art]({ ziel: 14 })), art)
|
||||
assert.ok(!/\b0 Tag/.test(z[art]({ ziel: 0 })), `${art}: Ziel 0`)
|
||||
}
|
||||
const frei = z.frei({ text: 'Zahlung in zwei Raten.', ziel: 14 })
|
||||
assert.ok(frei.includes('Zahlung in zwei Raten') && !frei.includes('Raten..') && /\b14\b/.test(frei), frei)
|
||||
})
|
||||
|
||||
test('Frühbeginn-Erklärung, Widerrufsbelehrung und Formular haben die Form aus V7 und nennen keine Funktionen-Einrichtung mehr', () => {
|
||||
assert.equal(T.WIDERRUFSBELEHRUNG_PROJEKT.titel, 'Widerrufsbelehrung')
|
||||
assert.ok(T.WIDERRUFSBELEHRUNG_PROJEKT.abschnitte.length >= 2)
|
||||
for (const abschnitt of T.WIDERRUFSBELEHRUNG_PROJEKT.abschnitte) {
|
||||
assert.ok(typeof abschnitt.ueberschrift === 'string' && abschnitt.ueberschrift.length > 0)
|
||||
assert.ok(Array.isArray(abschnitt.absaetze) && abschnitt.absaetze.length > 0)
|
||||
for (const absatz of abschnitt.absaetze) assert.ok(typeof absatz === 'string' && absatz.trim() !== '')
|
||||
}
|
||||
assert.equal(T.WIDERRUFSFORMULAR.titel, 'Muster-Widerrufsformular')
|
||||
assert.ok(typeof T.WIDERRUFSFORMULAR.einleitung === 'string' && T.WIDERRUFSFORMULAR.einleitung.length > 0)
|
||||
assert.ok(Array.isArray(T.WIDERRUFSFORMULAR.zeilen) && T.WIDERRUFSFORMULAR.zeilen.length >= 6)
|
||||
const text = alleTexte([T.WIDERRUFSBELEHRUNG_PROJEKT, T.WIDERRUFSFORMULAR, T.FRUEHBEGINN_TEXT]).join('\n')
|
||||
assert.doesNotMatch(text, /Einrichtung|Terminplaner|Wartungs-Abo|Funktionen/)
|
||||
assert.match(text, /vierzehn Tage/)
|
||||
assert.match(text, /Projektauftrag/)
|
||||
assert.match(text, /widerrufen\.html/)
|
||||
assert.match(text, /support@webklar\.com/)
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { hashToken, neuerToken } from '../server/services/auftraege/token.js'
|
||||
|
||||
const TAG_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
test('neuerToken: 32 Zufallsbytes als base64url (43 Zeichen), Hash = SHA-256 in Hex, gültig 60 Tage ab jetzt', () => {
|
||||
const jetzt = new Date('2026-10-06T10:00:00.000Z')
|
||||
const { token, hash, gueltigBis } = neuerToken(jetzt)
|
||||
assert.match(token, /^[A-Za-z0-9_-]{43}$/)
|
||||
assert.equal(hash, createHash('sha256').update(token).digest('hex'))
|
||||
assert.ok(gueltigBis instanceof Date)
|
||||
assert.equal(gueltigBis.toISOString(), '2026-12-05T10:00:00.000Z')
|
||||
assert.equal(jetzt.toISOString(), '2026-10-06T10:00:00.000Z', 'das übergebene Datum bleibt unverändert')
|
||||
})
|
||||
|
||||
test('neuerToken: jedes Token ist neu; ohne Argument zählt die aktuelle Zeit; ein ungültiger Zeitpunkt wirft', () => {
|
||||
const vorher = Date.now()
|
||||
const a = neuerToken()
|
||||
const b = neuerToken()
|
||||
const nachher = Date.now()
|
||||
assert.notEqual(a.token, b.token)
|
||||
assert.notEqual(a.hash, b.hash)
|
||||
assert.ok(a.gueltigBis.getTime() >= vorher + 60 * TAG_MS && a.gueltigBis.getTime() <= nachher + 60 * TAG_MS)
|
||||
assert.throws(() => neuerToken(new Date('kein Datum')), TypeError)
|
||||
assert.throws(() => neuerToken('2026-10-06'), TypeError)
|
||||
})
|
||||
|
||||
test('hashToken: gleicher Hash wie neuerToken, null bei falschem Format', () => {
|
||||
const { token, hash } = neuerToken()
|
||||
assert.equal(hashToken(token), hash)
|
||||
const falsch = [undefined, null, 42, {}, [], '', 'kurz', `${token}x`, token.slice(1), ` ${token.slice(0, 42)}`, `${token.slice(0, 42)}+`, `${token.slice(0, 42)}=`, `../${token.slice(3)}`, `${token.slice(0, 42)}\n`]
|
||||
for (const wert of falsch) assert.equal(hashToken(wert), null, JSON.stringify(wert))
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { hashToken } from '../server/services/auftraege/token.js'
|
||||
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, SHA, TOKEN, TOKEN_NEU, UNGUELTIG, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
|
||||
|
||||
// Öffentliche Link-Routen /api/auftrag/:token (V5): ansehen, PDF, bestätigen; Ratenbegrenzung, Schalter, Ablauf, Status
|
||||
process.env.AUFTRAEGE_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'auftraege-token-'))
|
||||
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 { startTestApp, send } = await import('./support/testApp.js')
|
||||
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { speicherePdf } = await import('../server/services/auftraege/ablage.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { default: tokenRoutes, resetAuftragRoutesForTests, setzeUhrFuerTests } = await import('../server/routes/auftrag.js')
|
||||
const app = await startTestApp({ '/api/auftrag': tokenRoutes })
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([app.close(), aw.close()])
|
||||
})
|
||||
|
||||
const PDF = '%PDF-1.4 Test-AB'
|
||||
await speicherePdf('c1', 'ab1', Buffer.from(PDF))
|
||||
const JETZT = new Date('2026-10-10T09:00:00.000Z')
|
||||
const GRUND = { ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.', storniert: 'Diese Auftragsbestätigung wurde storniert.', widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.' }
|
||||
const ZU_VIELE = { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' }
|
||||
|
||||
let sink
|
||||
const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) })
|
||||
beforeEach(() => {
|
||||
resetAuftragRoutesForTests()
|
||||
setzeUhrFuerTests(() => JETZT)
|
||||
config.auftraege.mode = 'on'
|
||||
config.auftraege.pilotEmails = []
|
||||
sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
seed()
|
||||
})
|
||||
const get = (token = TOKEN, rest = '', opts = {}) => send('GET', `/api/auftrag/${token}${rest}`, opts)
|
||||
const post = (body, token = TOKEN, opts = {}) => send('POST', `/api/auftrag/${token}/bestaetigen`, { ...opts, body })
|
||||
const ip = (adresse) => ({ headers: { 'x-forwarded-for': adresse } })
|
||||
|
||||
test('GET /api/auftrag/:token: öffentliche Ansicht der AB ohne interne Felder, nie zwischengespeichert', async () => {
|
||||
const res = await get()
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(Object.keys(res.body), ['auftrag'])
|
||||
const a = res.body.auftrag
|
||||
assert.deepEqual([a.nummer, a.status, a.titel, a.summeCents, a.kundenart], ['AB-2026-0001', 'versendet', 'Website für Musterbäckerei', 80000, 'privat'])
|
||||
assert.deepEqual(a.positionen, [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 80000, summeCents: 80000 }])
|
||||
assert.match(a.agbUrl, /^\/rechtliches\/agb-.+\.pdf$/)
|
||||
for (const intern of ['id', 'kundenId', 'kunde', 'tokenHash', 'tokenGueltigBis', 'pdfDatei', 'pdfSha256', 'bestaetigtPdfSha256', 'gesendetAn', 'zeitquelle', 'erstelltVon']) assert.ok(!(intern in a), `${intern} darf nicht öffentlich sein`)
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store')
|
||||
assert.equal(res.headers.get('x-robots-tag'), 'noindex')
|
||||
})
|
||||
|
||||
test('ungültige, unbekannte und abgelaufene Links: 404 mit immer derselben Meldung; am letzten Moment gilt der Link noch', async () => {
|
||||
for (const token of [TOKEN_NEU, 'kurz', `${TOKEN}x`, `${'T'.repeat(42)}%2B`, `${'T'.repeat(40)}%2e%2e%2f`]) {
|
||||
for (const res of [await get(token), await get(token, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }], token)
|
||||
}
|
||||
seed({ tokenGueltigBis: new Date(JETZT.getTime() - 1).toISOString() })
|
||||
for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }])
|
||||
seed({ tokenGueltigBis: JETZT.toISOString() })
|
||||
assert.equal((await get()).status, 200)
|
||||
})
|
||||
|
||||
test('Erinnerung mit neuem Link: der alte Link gilt nicht mehr (404), der neue schon', async () => {
|
||||
seed({ tokenHash: hashToken(TOKEN_NEU) })
|
||||
assert.deepEqual([(await get(TOKEN)).status, (await get(TOKEN_NEU)).status], [404, 200])
|
||||
})
|
||||
|
||||
test('Schalter: bei aus 404 wie bei unbekanntem Link; im Pilot nur für Adressen der Liste', async () => {
|
||||
config.auftraege.mode = 'off'
|
||||
for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [404, { error: UNGUELTIG }])
|
||||
config.auftraege.mode = 'pilot'
|
||||
config.auftraege.pilotEmails = ['andere@example.invalid']
|
||||
assert.equal((await get()).status, 404)
|
||||
config.auftraege.pilotEmails = ['k@example.invalid']
|
||||
assert.equal((await get()).status, 200)
|
||||
})
|
||||
|
||||
test('ersetzte, stornierte und widerrufene AB: 410 mit dem Grund – für Ansicht und PDF, auch bei abgelaufenem Link; Entwurf 404', async () => {
|
||||
for (const [status, meldung] of Object.entries(GRUND)) {
|
||||
seed({ status, tokenGueltigBis: '2026-01-01T00:00:00.000Z' })
|
||||
for (const res of [await get(), await get(TOKEN, '/pdf')]) assert.deepEqual([res.status, res.body], [410, { error: meldung }], status)
|
||||
}
|
||||
seed({ status: 'entwurf' })
|
||||
assert.equal((await get()).status, 404)
|
||||
})
|
||||
|
||||
test('GET …/pdf liefert die gespeicherte Datei unverändert und inline; fehlt die Datei, 404', async () => {
|
||||
const res = await get(TOKEN, '/pdf')
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal(res.headers.get('content-type'), 'application/pdf')
|
||||
assert.equal(res.headers.get('content-disposition'), 'inline; filename="AB-2026-0001.pdf"')
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store')
|
||||
assert.equal(res.body, PDF)
|
||||
seed({ pdfDatei: 'c1/fehlt.pdf' })
|
||||
const fehlt = await get(TOKEN, '/pdf')
|
||||
assert.deepEqual([fehlt.status, fehlt.body], [404, { error: 'PDF nicht gefunden.' }])
|
||||
})
|
||||
|
||||
test('Ratenbegrenzung: 120 Aufrufe (GET) je Stunde und IP, danach 429; eine andere IP ist nicht betroffen', async () => {
|
||||
for (let i = 1; i <= 120; i += 1) assert.equal((await get(TOKEN, '', ip('203.0.113.9'))).status, 200, `Anfrage ${i}`)
|
||||
const zuViele = await get(TOKEN, '', ip('203.0.113.9'))
|
||||
assert.deepEqual([zuViele.status, zuViele.body], [429, ZU_VIELE])
|
||||
assert.equal((await get(TOKEN, '', ip('203.0.113.10'))).status, 200)
|
||||
})
|
||||
|
||||
const HAEKCHEN = { error: 'Bitte bestätigen Sie den Auftrag mit dem Häkchen.' }
|
||||
|
||||
test('POST …/bestaetigen ohne bestaetigt: true → 400, nichts geändert, keine Mail', async () => {
|
||||
for (const body of [{}, { bestaetigt: false }, { bestaetigt: 'true' }, { bestaetigt: 1 }, { fruehbeginn: true }]) {
|
||||
const res = await post(body)
|
||||
assert.deepEqual([res.status, res.body], [400, HAEKCHEN], JSON.stringify(body))
|
||||
}
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.deepEqual([doc.status, doc.bestaetigtAm], ['versendet', null])
|
||||
assert.equal(sink.sent.length, 0)
|
||||
// die Prüfung des Links geht dem Häkchen vor: bei einem unbekannten Link 404, nie 400
|
||||
const unbekannt = await post({}, TOKEN_NEU)
|
||||
assert.deepEqual([unbekannt.status, unbekannt.body], [404, { error: UNGUELTIG }])
|
||||
})
|
||||
|
||||
test('POST mit unbekanntem, falschem, altem oder abgelaufenem Link, bei ersetzter AB oder abgeschaltetem Schalter: 404 bzw. 410, nichts ändert sich', async () => {
|
||||
const pruefe = async (erwartet, token = TOKEN) => {
|
||||
const res = await post({ bestaetigt: true }, token)
|
||||
assert.deepEqual([res.status, res.body], erwartet)
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.deepEqual([doc.status, doc.bestaetigtAm, sink.sent.length], ['versendet', null, 0])
|
||||
}
|
||||
await pruefe([404, { error: UNGUELTIG }], TOKEN_NEU)
|
||||
await pruefe([404, { error: UNGUELTIG }], 'kurz')
|
||||
seed({ tokenHash: hashToken(TOKEN_NEU) }) // wie nach „Erinnerung senden“: nur der neue Link gilt
|
||||
await pruefe([404, { error: UNGUELTIG }], TOKEN)
|
||||
seed({ tokenGueltigBis: new Date(JETZT.getTime() - 1).toISOString() })
|
||||
await pruefe([404, { error: UNGUELTIG }])
|
||||
seed({ status: 'ersetzt' })
|
||||
const ersetzt = await post({ bestaetigt: true })
|
||||
assert.deepEqual([ersetzt.status, ersetzt.body], [410, { error: GRUND.ersetzt }])
|
||||
assert.deepEqual([(await getAuftrag('ab1')).bestaetigtAm, sink.sent.length], [null, 0])
|
||||
seed()
|
||||
config.auftraege.mode = 'off'
|
||||
await pruefe([404, { error: UNGUELTIG }])
|
||||
})
|
||||
|
||||
test('POST bestätigt über den Link: Protokoll am Dokument, öffentliche Antwort, K32 und T22; keine IP-Adresse gespeichert', async () => {
|
||||
const res = await post({ bestaetigt: true, fruehbeginn: true }, TOKEN, ip('203.0.113.77'))
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual([res.body.auftrag.status, res.body.auftrag.fruehbeginn, new Date(res.body.auftrag.bestaetigtAm).getTime()], ['bestaetigt', true, JETZT.getTime()])
|
||||
assert.ok(!('tokenHash' in res.body.auftrag) && !('pdfSha256' in res.body.auftrag))
|
||||
const doc = await getAuftrag('ab1')
|
||||
assert.deepEqual([doc.bestaetigtUeber, doc.bestaetigtPdfSha256, doc.fruehbeginn], ['link', SHA, true])
|
||||
assert.deepEqual(sink.sent.map((m) => m.subject), ['Ihre Bestätigung zu AB-2026-0001 ist eingegangen', '[Aufträge] Auftrag AB-2026-0001 bestätigt: Erika Muster'])
|
||||
assert.ok(!JSON.stringify([...aw.docs('auftraege').values()]).includes('203.0.113.77'), 'keine IP-Adresse im Dokument')
|
||||
})
|
||||
|
||||
test('fruehbeginn zählt nur bei Privatpersonen und nur als true', async () => {
|
||||
for (const [kundenart, eingabe, erwartet] of [['privat', 'ja', false], ['privat', 1, false], ['privat', true, true], ['unternehmen', true, false]]) {
|
||||
seed({ kunde: kunde(kundenart) })
|
||||
const res = await post({ bestaetigt: true, fruehbeginn: eingabe })
|
||||
assert.equal(res.status, 200, `${kundenart} ${eingabe}`)
|
||||
assert.equal((await getAuftrag('ab1')).fruehbeginn, erwartet, `${kundenart} ${eingabe}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('zweite und gleichzeitige Bestätigungen: 200, die erste zählt, genau zwei Mails', async () => {
|
||||
const erste = await post({ bestaetigt: true, fruehbeginn: true })
|
||||
setzeUhrFuerTests(() => new Date(JETZT.getTime() + 3_600_000))
|
||||
const zweite = await post({ bestaetigt: true, fruehbeginn: false })
|
||||
assert.equal(zweite.status, 200)
|
||||
assert.deepEqual(zweite.body, erste.body)
|
||||
assert.equal(sink.sent.length, 2)
|
||||
seed()
|
||||
sink.sent.length = 0
|
||||
const [a, b] = await Promise.all([post({ bestaetigt: true }), post({ bestaetigt: true })])
|
||||
assert.deepEqual([a.status, b.status], [200, 200])
|
||||
assert.equal(a.body.auftrag.bestaetigtAm, b.body.auftrag.bestaetigtAm)
|
||||
assert.equal(sink.sent.length, 2)
|
||||
})
|
||||
|
||||
test('abgerechnete AB ohne frühere Bestätigung: 200, der Status bleibt abgerechnet', async () => {
|
||||
seed({ status: 'abgerechnet' })
|
||||
const res = await post({ bestaetigt: true })
|
||||
assert.deepEqual([res.status, res.body.auftrag.status], [200, 'abgerechnet'])
|
||||
assert.notEqual((await getAuftrag('ab1')).bestaetigtAm, null)
|
||||
})
|
||||
|
||||
test('Mailfehler: die Bestätigung gilt trotzdem (200), der Fehler steht nur im Protokoll', async () => {
|
||||
setMailTransportForTests({ async sendMail() { throw new Error('SMTP 451') } })
|
||||
const log = mock.method(console, 'error', () => {})
|
||||
const res = await post({ bestaetigt: true })
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'bestaetigt')
|
||||
assert.ok(log.mock.callCount() > 0)
|
||||
mock.restoreAll()
|
||||
})
|
||||
|
||||
test('Ratenbegrenzung: höchstens 10 POST je Stunde und IP, danach 429; GET und andere IP sind nicht betroffen', async () => {
|
||||
const kopf = ip('203.0.113.20')
|
||||
for (let i = 1; i <= 10; i += 1) assert.equal((await post({}, TOKEN, kopf)).status, 400, `Anfrage ${i}`)
|
||||
const zuViele = await post({ bestaetigt: true }, TOKEN, kopf)
|
||||
assert.deepEqual([zuViele.status, zuViele.body], [429, ZU_VIELE])
|
||||
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
|
||||
assert.equal((await get(TOKEN, '', kopf)).status, 200)
|
||||
assert.equal((await post({ bestaetigt: true }, TOKEN, ip('203.0.113.21'))).status, 200)
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { config } from '../server/config.js'
|
||||
import { loadTemplate, renderMail } from '../server/services/mailTemplates.js'
|
||||
import { euroDe } from '../server/services/finance/format.js'
|
||||
|
||||
// K31, K32, T22 (Aufträge und Rechnungen, Strang L5) und der Block projekt in K04 und T09 (Task 11). Die allgemeinen
|
||||
// Vorlagenregeln (Kopf, Platzhalter, Blöcke, Logo, Text ohne Entities, Team-Fuß) prüft test/mailTemplates.test.js.
|
||||
const eur = (cent) => euroDe(cent).replace(/\u{a0}/gu, ' ') // die Textfassung macht geschützte Leerzeichen zu normalen
|
||||
const DU_FORM = /(^|[^\p{L}])(du|dein\p{L}*|dir|dich)(?![\p{L}-])/iu
|
||||
const SIE_FORM = /(^|[^\p{L}])(Sie|Ihr\p{L}*|Ihnen)(?![\p{L}-])/u
|
||||
const alle = (id) => Object.fromEntries(loadTemplate(id).blocks.map((name) => [name, true]))
|
||||
const pruefeZeilen = (mail, erwartet) => {
|
||||
const zeilen = mail.text.split('\n')
|
||||
for (const zeile of erwartet) assert.ok(zeilen.includes(zeile), `Zeile fehlt: ${zeile}\n${mail.text}`)
|
||||
}
|
||||
|
||||
const K31 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', datum: '06.10.2026', betrag: eur(80000), link: 'https://project.webklar.com/auftrag.html#t=TOKEN', gueltig_bis: '05.12.2026' }
|
||||
const K32 = { name: 'Erika Muster', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', fruehester_beginn: '21.10.2026', fruehbeginn_text: 'Ich verlange ausdrücklich den Beginn vor Ablauf der Widerrufsfrist.' }
|
||||
const T22 = { kunde: 'Muster GmbH', nummer: 'AB-2026-0001', titel: 'Website für Musterbäckerei', zeitpunkt: '07.10.2026, 10:30', ueber: 'Bestätigungslink', woms_link: 'https://ticket.webklar.com/customers/c1?tab=invoices', fruehester_beginn: '21.10.2026' }
|
||||
|
||||
test('K31, K32, T22: Datei, Betreff, Platzhalter und Blöcke wie im Vertrag V6 (K31 mit dem Gegenblock kundenmail)', () => {
|
||||
const erwartet = {
|
||||
K31: { file: 'K31 Auftragsbestaetigung.html', subject: 'Ihre Auftragsbestätigung {{nummer}} – {{titel}}', placeholders: ['name', 'nummer', 'titel', 'datum', 'betrag', 'link', 'gueltig_bis'], blocks: ['erinnerung', 'privat', 'festpreis', 'aufwand', 'teamkopie', 'kundenmail'] },
|
||||
K32: { file: 'K32 Auftrag bestaetigt.html', subject: 'Ihre Bestätigung zu {{nummer}} ist eingegangen', placeholders: ['name', 'nummer', 'titel', 'zeitpunkt', 'fruehester_beginn', 'fruehbeginn_text'], blocks: ['fruehbeginn', 'ohne_fruehbeginn'] },
|
||||
T22: { file: 'T22 Auftrag bestaetigt.html', subject: '[Aufträge] Auftrag {{nummer}} bestätigt: {{kunde}}', placeholders: ['kunde', 'nummer', 'titel', 'zeitpunkt', 'ueber', 'woms_link', 'fruehester_beginn'], blocks: ['privat_fruehbeginn', 'privat_ohne_fruehbeginn'] },
|
||||
}
|
||||
for (const [id, soll] of Object.entries(erwartet)) {
|
||||
const vorlage = loadTemplate(id)
|
||||
assert.equal(vorlage.file, soll.file, id)
|
||||
assert.equal(vorlage.subject, soll.subject, id)
|
||||
assert.deepEqual(vorlage.placeholders, soll.placeholders, id)
|
||||
assert.deepEqual(vorlage.blocks, soll.blocks, id)
|
||||
}
|
||||
})
|
||||
|
||||
test('K31 Festpreis an eine Privatperson: Angaben, Knopf mit Link im Fragment, Anhänge, Widerrufshinweis; keine Erinnerung, kein Teamhinweis', () => {
|
||||
const mail = renderMail('K31', K31, { festpreis: true, privat: true, kundenmail: true })
|
||||
assert.deepEqual(mail.problems, [])
|
||||
assert.equal(mail.subject, 'Ihre Auftragsbestätigung AB-2026-0001 – Website für Musterbäckerei')
|
||||
pruefeZeilen(mail, [
|
||||
'Guten Tag Erika Muster,',
|
||||
'Auftrag: AB-2026-0001 vom 06.10.2026',
|
||||
'Projekt: Website für Musterbäckerei',
|
||||
'Gesamtpreis: 800,00 € (Festpreis, gemäß § 19 UStG ohne Umsatzsteuer)',
|
||||
'Bestätigung möglich bis: 05.12.2026',
|
||||
'Auftrag bestätigen: https://project.webklar.com/auftrag.html#t=TOKEN',
|
||||
'Viele Grüße',
|
||||
'Ihr WEBklar-Team',
|
||||
])
|
||||
assert.ok(mail.text.includes('Die Auftragsbestätigung (AB-2026-0001.pdf) und unsere Allgemeinen Geschäftsbedingungen hängen als PDF an dieser E-Mail.'))
|
||||
assert.ok(mail.text.includes('Sie sind Verbraucher: Ihr Widerrufsrecht und das Muster-Widerrufsformular finden Sie in der angehängten Auftragsbestätigung;'))
|
||||
assert.ok(mail.text.includes(`Widerrufsformular auf unserer Website (${config.publicBaseUrl}/widerrufen.html)`))
|
||||
assert.doesNotMatch(mail.text, /Erinnerung|Vergütung:|Kopie für das Team/)
|
||||
})
|
||||
|
||||
test('K31 Aufwand an ein Unternehmen als Erinnerung: Stundensatz, Hinweis auf den früheren Link, kein Widerrufshinweis', () => {
|
||||
const mail = renderMail('K31', { ...K31, betrag: `${eur(10000)} pro Stunde` }, { aufwand: true, erinnerung: true, kundenmail: true })
|
||||
assert.deepEqual(mail.problems, [])
|
||||
pruefeZeilen(mail, ['Vergütung: 100,00 € pro Stunde (nach Aufwand in Schritten von 15 Minuten, gemäß § 19 UStG ohne Umsatzsteuer)', 'Auftrag bestätigen: https://project.webklar.com/auftrag.html#t=TOKEN'])
|
||||
assert.ok(mail.text.includes('Wir haben Ihnen diese Auftragsbestätigung am 06.10.2026 schon einmal geschickt und noch keine Bestätigung von Ihnen erhalten. Der Link in der früheren E-Mail gilt nicht mehr.'))
|
||||
assert.doesNotMatch(mail.text, /Gesamtpreis|Widerruf|Verbraucher/)
|
||||
})
|
||||
|
||||
test('K31 als Kopie für das Team: Hinweis statt Knopf, kein Link, keine Anhänge, kein Widerrufshinweis; die Erinnerung bleibt erkennbar', () => {
|
||||
const mail = renderMail('K31', { ...K31, link: '' }, { aufwand: true, erinnerung: true, teamkopie: true })
|
||||
assert.deepEqual(mail.problems, [])
|
||||
pruefeZeilen(mail, ['Kopie für das Team – der Bestätigungslink steht nur in der Mail an den Kunden.', 'Auftrag: AB-2026-0001 vom 06.10.2026', 'Bestätigung möglich bis: 05.12.2026'])
|
||||
assert.ok(mail.text.includes('Der Link in der früheren E-Mail gilt nicht mehr.'))
|
||||
assert.doesNotMatch(mail.text, /Auftrag bestätigen|auftrag\.html|hängen als PDF|Anhänge|Verbraucher/)
|
||||
assert.doesNotMatch(mail.html, /auftrag\.html|#t=|class="btn"/)
|
||||
})
|
||||
|
||||
test('K32: Eingangsbestätigung mit Datum und Uhrzeit; Frühbeginn ja (wörtliche Erklärung), nein und Unternehmen', () => {
|
||||
const ja = renderMail('K32', K32, { fruehbeginn: true })
|
||||
assert.deepEqual(ja.problems, [])
|
||||
assert.equal(ja.subject, 'Ihre Bestätigung zu AB-2026-0001 ist eingegangen')
|
||||
pruefeZeilen(ja, ['Guten Tag Erika Muster,', 'Auftrag: AB-2026-0001', 'Projekt: Website für Musterbäckerei', 'Bestätigt am: 07.10.2026, 10:30 Uhr', 'Ihre Erklärung zum früheren Beginn', K32.fruehbeginn_text, 'Viele Grüße', 'Ihr WEBklar-Team'])
|
||||
assert.ok(ja.text.includes('wir haben Ihre Bestätigung zu AB-2026-0001 am 07.10.2026, 10:30 Uhr erhalten.'))
|
||||
assert.doesNotMatch(ja.text, /21\.10\.2026|nicht verlangt|Widerrufsrecht bleibt/)
|
||||
const nein = renderMail('K32', K32, { ohne_fruehbeginn: true })
|
||||
assert.ok(nein.text.includes('Sie haben nicht verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Wir beginnen daher frühestens am 21.10.2026 mit der Arbeit.'))
|
||||
assert.doesNotMatch(nein.text, /Erklärung zum früheren Beginn/)
|
||||
assert.doesNotMatch(renderMail('K32', K32, {}).text, /Beginn der Arbeiten|21\.10\.2026|Widerruf|Erklärung/)
|
||||
})
|
||||
|
||||
test('T22: Betreff mit Präfix, Angaben, Knopf ins Ticketsystem; Hinweise nur bei Privatpersonen', () => {
|
||||
const ja = renderMail('T22', T22, { privat_fruehbeginn: true })
|
||||
assert.deepEqual(ja.problems, [])
|
||||
assert.equal(ja.subject, '[Aufträge] Auftrag AB-2026-0001 bestätigt: Muster GmbH')
|
||||
pruefeZeilen(ja, ['Kunde: Muster GmbH', 'Auftrag: AB-2026-0001', 'Projekt: Website für Musterbäckerei', 'Bestätigt am: 07.10.2026, 10:30 Uhr', 'Weg: Bestätigungslink', 'Im Ticketsystem öffnen: https://ticket.webklar.com/customers/c1?tab=invoices'])
|
||||
assert.ok(ja.text.includes('Der Kunde hat ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist beginnen. Die Arbeit darf sofort starten. Bei einem Widerruf ist Wertersatz zu prüfen (§ 357a Abs. 2 BGB).'))
|
||||
assert.doesNotMatch(ja.text, /keinen früheren Beginn|21\.10\.2026/)
|
||||
const nein = renderMail('T22', T22, { privat_ohne_fruehbeginn: true })
|
||||
assert.ok(nein.text.includes('Der Kunde hat keinen früheren Beginn verlangt. Arbeitsbeginn frühestens am 21.10.2026, dem Tag nach Ablauf der Widerrufsfrist.'))
|
||||
assert.doesNotMatch(renderMail('T22', T22, {}).text, /Privatperson|Wertersatz|frühestens/)
|
||||
})
|
||||
|
||||
test('K31 und K32 stehen in der Sie-Form; Werte werden HTML-sicher eingesetzt', () => {
|
||||
for (const [id, werte] of [['K31', K31], ['K32', K32]]) {
|
||||
const mail = renderMail(id, werte, alle(id))
|
||||
assert.doesNotMatch(mail.text, DU_FORM, `${id}: Du-Form\n${mail.text}`)
|
||||
assert.match(mail.text, SIE_FORM, `${id}: spricht den Kunden mit Sie an`)
|
||||
}
|
||||
const mail = renderMail('K31', { ...K31, titel: 'Café <b>&"' }, { festpreis: true })
|
||||
assert.match(mail.html, /Café <b>&"/)
|
||||
assert.doesNotMatch(mail.html, /<b>&/)
|
||||
})
|
||||
|
||||
const K04 = { name: 'Erika Muster', produkt: 'Projektauftrag', bestellung: 'Projektauftrag (Auftragsbestätigung AB-2026-0001)', eingang_zeitpunkt: '07.10.2026, 10:30', email: 'k@example.invalid', kundennummer: 'KD-9' }
|
||||
const T09 = { email: 'k@example.invalid', name: 'Erika Muster', produkt: 'Projektauftrag', preis: '', kundennummer: 'KD-9', bestellung: 'Projektauftrag (Auftragsbestätigung AB-2026-0001)', stripe_abo_id: '', abo_beginn: '–', fristende: '20.10.2026, 24 Uhr', rechtzeitig: 'RECHTZEITIG', ereignisse: 'Auftragsbestätigung auf „widerrufen“ gesetzt', pruefhinweis: '' }
|
||||
|
||||
test('K04 und T09: Block projekt ist deklariert (K04 mit projekt_unternehmen), die Platzhalter bleiben, die anderen Blöcke auch', () => {
|
||||
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen'])
|
||||
assert.deepEqual(loadTemplate('K04').placeholders, ['name', 'produkt', 'bestellung', 'eingang_zeitpunkt', 'email', 'kundennummer'])
|
||||
assert.deepEqual(loadTemplate('T09').blocks.slice(-2), ['bereits_widerrufen', 'projekt'])
|
||||
assert.deepEqual(loadTemplate('T09').placeholders, ['email', 'name', 'produkt', 'preis', 'kundennummer', 'bestellung', 'stripe_abo_id', 'abo_beginn', 'fristende', 'rechtzeitig', 'ereignisse', 'pruefhinweis'])
|
||||
})
|
||||
|
||||
test('K04 Block projekt: Vertragsart, „wir prüfen“; bei Unternehmen zusätzlich der Hinweis ohne gesetzliches Widerrufsrecht; keine Zusagen', () => {
|
||||
const privat = renderMail('K04', K04, { kundennummer: true, projekt: true })
|
||||
assert.deepEqual(privat.problems, [])
|
||||
assert.equal(privat.subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Projektauftrag')
|
||||
pruefeZeilen(privat, [
|
||||
'wir haben Ihren Widerruf der Bestellung „Projektauftrag (Auftragsbestätigung AB-2026-0001)“ am 07.10.2026, 10:30 Uhr erhalten.',
|
||||
'Bestellung: Projektauftrag (Auftragsbestätigung AB-2026-0001)',
|
||||
'Kundennummer: KD-9',
|
||||
'Wie es weitergeht',
|
||||
'Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.',
|
||||
])
|
||||
assert.doesNotMatch(privat.text, /Unternehmen|gesetzliches Widerrufsrecht|Abbuchungen|erstatt|Wertersatz|Widerrufsfrist läuft/)
|
||||
const firma = renderMail('K04', K04, { projekt: true, projekt_unternehmen: true })
|
||||
assert.deepEqual(firma.problems, [])
|
||||
pruefeZeilen(firma, ['Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen. Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.'])
|
||||
// der Zusatz gehört zum Block projekt: allein erscheint er nie, und ohne projekt gibt es keinen Kasten
|
||||
assert.doesNotMatch(renderMail('K04', K04, { projekt_unternehmen: true }).text, /Unternehmen|Wie es weitergeht/)
|
||||
assert.doesNotMatch(renderMail('K04', K04, {}).text, /Wie es weitergeht|Projektauftrags/)
|
||||
})
|
||||
|
||||
test('T09 Block projekt: Ergebnis, Widerrufsfrist und Eingang als eigene Zeilen, Hinweis auf die Handarbeit; ohne den Block nichts davon', () => {
|
||||
const mail = renderMail('T09', T09, { kundennummer: true, projekt: true })
|
||||
assert.deepEqual(mail.problems, [])
|
||||
assert.equal(mail.subject, '[Abo] WIDERRUF eingegangen: k@example.invalid')
|
||||
pruefeZeilen(mail, [
|
||||
'Bestellung: Projektauftrag (Auftragsbestätigung AB-2026-0001)',
|
||||
'Abo: Projektauftrag',
|
||||
'Kundennummer: KD-9',
|
||||
'Zu tun',
|
||||
'Ergebnis: Auftragsbestätigung auf „widerrufen“ gesetzt',
|
||||
'Widerrufsfrist bis: 20.10.2026, 24 Uhr',
|
||||
'Eingang: RECHTZEITIG',
|
||||
])
|
||||
assert.ok(mail.text.includes('Der Kunde hat „wir prüfen“ bekommen. Erstattung, Wertersatz und das Ende der Arbeit sind Handarbeit (Ticketsystem: Kunde → Aufträge und Rechnungen).'))
|
||||
assert.doesNotMatch(mail.text, /KEIN aktives Abo|Stripe-Abo|Abo in Stripe/)
|
||||
assert.doesNotMatch(renderMail('T09', T09, { kundennummer: true }).text, /Ergebnis:|Eingang:|Handarbeit|Zu tun/)
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import { test, after, beforeEach, afterEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { AUFTRAEGE_DATETIMES, AUFTRAEGE_SCHEMA, TOKEN, abDokument, kunde } from './auftraegeBestaetigungHilfe.js'
|
||||
|
||||
// Widerruf eines Projektauftrags über /api/abo/public/withdraw (order = 'projekt', abNummer optional); K04 und T09 mit dem Block projekt
|
||||
process.env.AUFTRAEGE_ENABLED = 'on'
|
||||
const aw = await startAppwriteMock({ schema: AUFTRAEGE_SCHEMA, datetimes: AUFTRAEGE_DATETIMES, defaultLimit: 25 })
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, post, send } = await import('./support/testApp.js')
|
||||
const { auftragToDoc, getAuftrag } = await import('../server/services/auftraege/auftragStore.js')
|
||||
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { default: aboRoutes, resetPublicRateLimit } = await import('../server/routes/abo.js')
|
||||
const { default: tokenRoutes, resetAuftragRoutesForTests } = await import('../server/routes/auftrag.js')
|
||||
const app = await startTestApp({ '/api/abo': aboRoutes, '/api/auftrag': tokenRoutes })
|
||||
const sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([app.close(), aw.close(), st.close()])
|
||||
})
|
||||
|
||||
const vor = (tage) => new Date(Date.now() - tage * 86_400_000).toISOString()
|
||||
const tag = (zeit) => new Date(zeit).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
const seed = (over = {}) => aw.seed('auftraege', { $id: 'ab1', ...auftragToDoc(abDokument(over)) })
|
||||
let protokoll
|
||||
beforeEach(() => {
|
||||
resetPublicRateLimit()
|
||||
resetAuftragRoutesForTests()
|
||||
sink.sent.length = 0
|
||||
config.auftraege.mode = 'on'
|
||||
protokoll = mock.method(console, 'error', () => {})
|
||||
})
|
||||
afterEach(() => mock.restoreAll())
|
||||
const widerruf = (body = {}) => post('/api/abo/public/withdraw', { name: 'Erika Muster', email: 'k@example.invalid', order: 'projekt', abNummer: 'AB-2026-0001', ...body })
|
||||
const k04 = () => sink.sent.find((mail) => mail.subject.startsWith('Eingangsbestätigung Ihres Widerrufs'))
|
||||
const t09 = () => sink.sent.find((mail) => mail.subject.startsWith('[Abo] WIDERRUF eingegangen'))
|
||||
const zeile = (mail, anfang) => mail.text.split('\n').find((z) => z.startsWith(anfang))
|
||||
const LABEL = 'Projektauftrag (Auftragsbestätigung AB-2026-0001)'
|
||||
const OHNE_NUMMER = 'Projektauftrag (Auftragsbestätigung)'
|
||||
const UNTERNEHMEN_HINWEIS = 'Für Verträge mit Unternehmen besteht kein gesetzliches Widerrufsrecht.'
|
||||
const KEINE_NUMMER = 'ungeprüft: keine gültige AB-Nummer angegeben, nichts geändert, bitte von Hand zuordnen'
|
||||
const pruefeT09 = (ergebnis, frist, eingang) => {
|
||||
assert.equal(zeile(t09(), 'Ergebnis: '), `Ergebnis: ${ergebnis}`)
|
||||
assert.equal(zeile(t09(), 'Widerrufsfrist bis: '), `Widerrufsfrist bis: ${frist}`)
|
||||
assert.equal(zeile(t09(), 'Eingang: '), `Eingang: ${eingang}`)
|
||||
}
|
||||
|
||||
test('Privatperson innerhalb der Frist: AB widerrufen, Antwort wie immer, K04 und T09 mit Block projekt; kein Stripe', async () => {
|
||||
const gesendetAm = vor(3)
|
||||
seed({ gesendetAm })
|
||||
const stripeVorher = st.calls.length
|
||||
const res = await widerruf({ customerNumber: 'KD-9' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt'])
|
||||
assert.equal(st.calls.length, stripeVorher, 'kein Stripe-Aufruf')
|
||||
const ab = await getAuftrag('ab1')
|
||||
assert.equal(ab.status, 'widerrufen')
|
||||
assert.equal(new Date(ab.widerrufenAm).getTime(), new Date(res.body.receivedAt).getTime())
|
||||
// K04 an die Privatperson: Vertragsart, „wir prüfen“, keine Zusagen, kein Hinweis für Unternehmen
|
||||
assert.equal(k04().subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Projektauftrag')
|
||||
assert.deepEqual(k04().to.map((a) => a.address), ['k@example.invalid'])
|
||||
assert.ok(k04().text.includes(`Widerruf der Bestellung „${LABEL}“ am `), k04().text)
|
||||
assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${LABEL}`)
|
||||
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.'))
|
||||
assert.ok(k04().text.includes('Kundennummer: KD-9'))
|
||||
assert.doesNotMatch(k04().text, /Unternehmen|Abbuchungen|Wir prüfen, ob für diese Bestellung/)
|
||||
// T09 ans Team: Vertragsart, Ergebnis, Frist, Eingang
|
||||
assert.equal(t09().subject, '[Abo] WIDERRUF eingegangen: k@example.invalid')
|
||||
assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`)
|
||||
assert.equal(zeile(t09(), 'Abo: '), 'Abo: Projektauftrag')
|
||||
pruefeT09('Auftragsbestätigung auf „widerrufen“ gesetzt', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'RECHTZEITIG')
|
||||
assert.doesNotMatch(t09().text, /KEIN aktives Abo|Stripe-Abo/)
|
||||
})
|
||||
|
||||
test('bestätigte AB mit verlangtem Frühbeginn: wird widerrufen, T09 mahnt die Prüfung des Wertersatzes an', async () => {
|
||||
seed({ gesendetAm: vor(5), status: 'bestaetigt', bestaetigtAm: vor(4), bestaetigtUeber: 'link', fruehbeginn: true })
|
||||
assert.equal((await widerruf()).status, 200)
|
||||
const ab = await getAuftrag('ab1')
|
||||
assert.equal(ab.status, 'widerrufen')
|
||||
assert.notEqual(ab.bestaetigtAm, null)
|
||||
assert.equal(zeile(t09(), 'Ergebnis: '), 'Ergebnis: Auftragsbestätigung auf „widerrufen“ gesetzt; Frühbeginn war verlangt: Wertersatz prüfen (§ 357a Abs. 2 BGB)')
|
||||
})
|
||||
|
||||
test('Unternehmen: kein gesetzliches Widerrufsrecht – AB bleibt unverändert, K04 weist darauf hin, T09 sagt es dem Team', async () => {
|
||||
seed({ gesendetAm: vor(3), kunde: kunde('unternehmen') })
|
||||
assert.equal((await widerruf()).status, 200)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'versendet')
|
||||
assert.ok(k04().text.includes(`Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen. ${UNTERNEHMEN_HINWEIS}`), k04().text)
|
||||
pruefeT09('Unternehmen: kein gesetzliches Widerrufsrecht, nichts geändert', 'entfällt (Unternehmen)', 'ungeprüft')
|
||||
})
|
||||
|
||||
test('nach Fristende: nichts geändert, T09 nennt das Fristende und bittet um Prüfung; K04 ohne Unternehmen-Hinweis', async () => {
|
||||
const gesendetAm = vor(20)
|
||||
seed({ gesendetAm })
|
||||
assert.equal((await widerruf()).status, 200)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'versendet')
|
||||
pruefeT09('nichts geändert', `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, 'NACH Fristende, vor einer Ablehnung bitte prüfen')
|
||||
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags'))
|
||||
assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS))
|
||||
})
|
||||
|
||||
test('ohne, mit ungültiger oder falscher Nummer und mit fremder E-Mail-Adresse: dieselbe Antwort, nichts geändert, K04 und T09 gehen trotzdem raus (ungeprüft)', async () => {
|
||||
const faelle = [
|
||||
[{ abNummer: 'AB-2026-0002' }, 'Projektauftrag (Auftragsbestätigung AB-2026-0002)', 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0002“ und dieser E-Mail-Adresse gefunden, nichts geändert'],
|
||||
[{ email: 'fremd@example.invalid' }, LABEL, 'ungeprüft: keine Auftragsbestätigung zu Nummer „AB-2026-0001“ und dieser E-Mail-Adresse gefunden, nichts geändert'],
|
||||
[{ abNummer: 'AB-2026-1' }, OHNE_NUMMER, KEINE_NUMMER],
|
||||
[{ abNummer: '' }, OHNE_NUMMER, KEINE_NUMMER],
|
||||
[{ abNummer: undefined }, OHNE_NUMMER, KEINE_NUMMER],
|
||||
[{ abNummer: 'irgendwas' }, OHNE_NUMMER, KEINE_NUMMER],
|
||||
]
|
||||
seed({ gesendetAm: vor(3) })
|
||||
for (const [body, label, ergebnis] of faelle) {
|
||||
sink.sent.length = 0
|
||||
resetPublicRateLimit()
|
||||
const res = await widerruf(body)
|
||||
const was = JSON.stringify(body)
|
||||
assert.equal(res.status, 200, was)
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['ok', 'receivedAt'], was)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'versendet', was)
|
||||
assert.equal(zeile(k04(), 'Bestellung: '), `Bestellung: ${label}`, was)
|
||||
assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${label}`, was)
|
||||
pruefeT09(ergebnis, 'keine Frist gefunden', 'ungeprüft')
|
||||
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags und melden uns umgehend bei Ihnen.'), was)
|
||||
assert.ok(!k04().text.includes(UNTERNEHMEN_HINWEIS), was)
|
||||
}
|
||||
})
|
||||
|
||||
test('Groß-/Kleinschreibung und Leerzeichen bei Nummer und E-Mail-Adresse spielen keine Rolle', async () => {
|
||||
seed({ gesendetAm: vor(3) })
|
||||
assert.equal((await widerruf({ abNummer: ' ab-2026-0001 ', email: 'K@Example.INVALID' })).status, 200)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
|
||||
assert.equal(zeile(t09(), 'Bestellung: '), `Bestellung: ${LABEL}`)
|
||||
})
|
||||
|
||||
test('bereits widerrufen, abgerechnet, ersetzt, storniert: nichts geändert, T09 sagt warum', async () => {
|
||||
const faelle = [
|
||||
['widerrufen', 'war schon widerrufen, nichts geändert', 'bereits widerrufen'],
|
||||
['abgerechnet', 'Status „abgerechnet“, nichts geändert, bitte prüfen', 'ungeprüft'],
|
||||
['ersetzt', 'Status „ersetzt“, nichts geändert, bitte prüfen', 'ungeprüft'],
|
||||
['storniert', 'Status „storniert“, nichts geändert, bitte prüfen', 'ungeprüft'],
|
||||
]
|
||||
for (const [status, ergebnis, eingang] of faelle) {
|
||||
const gesendetAm = vor(3)
|
||||
seed({ gesendetAm, status, widerrufenAm: status === 'widerrufen' ? vor(1) : null })
|
||||
sink.sent.length = 0
|
||||
resetPublicRateLimit()
|
||||
assert.equal((await widerruf()).status, 200, status)
|
||||
pruefeT09(ergebnis, `${tag(withdrawalDeadline(gesendetAm).endsAt)}, 24 Uhr`, eingang)
|
||||
assert.equal((await getAuftrag('ab1')).status, status, status)
|
||||
}
|
||||
})
|
||||
|
||||
test('nach dem Widerruf bestätigt der Link nichts mehr: 410 mit Grund', async () => {
|
||||
seed({ gesendetAm: vor(3) })
|
||||
assert.equal((await widerruf()).status, 200)
|
||||
const lesen = await send('GET', `/api/auftrag/${TOKEN}`)
|
||||
assert.deepEqual([lesen.status, lesen.body], [410, { error: 'Diese Auftragsbestätigung wurde widerrufen.' }])
|
||||
assert.equal((await send('POST', `/api/auftrag/${TOKEN}/bestaetigen`, { body: { bestaetigt: true } })).status, 410)
|
||||
assert.equal((await getAuftrag('ab1')).bestaetigtAm, null)
|
||||
})
|
||||
|
||||
test('Schalter aus: der Widerruf wird trotzdem verarbeitet (Verbraucherrecht), der Link antwortet wie bei unbekannter AB', async () => {
|
||||
seed({ gesendetAm: vor(3) })
|
||||
config.auftraege.mode = 'off'
|
||||
assert.equal((await widerruf()).status, 200)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'widerrufen')
|
||||
assert.equal((await send('GET', `/api/auftrag/${TOKEN}`)).status, 404)
|
||||
})
|
||||
|
||||
test('Datenbankfehler bei der Prüfung: Antwort 200, nichts geändert, T09 meldet die fehlgeschlagene Prüfung, K04 geht raus', async () => {
|
||||
seed({ gesendetAm: vor(3) })
|
||||
aw.failNext({ collection: 'auftraege', method: 'GET', status: 503 })
|
||||
assert.equal((await widerruf()).status, 200)
|
||||
assert.equal((await getAuftrag('ab1')).status, 'versendet')
|
||||
pruefeT09('ungeprüft: Prüfung fehlgeschlagen (Datenbank), bitte von Hand prüfen', 'keine Frist gefunden', 'ungeprüft')
|
||||
assert.ok(k04().text.includes('Wir prüfen Ihren Widerruf des Projektauftrags'))
|
||||
assert.ok(protokoll.mock.callCount() > 0)
|
||||
})
|
||||
@@ -184,6 +184,7 @@ test('Liste: Felder einer bezahlten Abo-Rechnung (Betraege in Euro, Link zur Rec
|
||||
overdue: false,
|
||||
portalUrl: 'https://invoice.stripe.com/i/in_c1_abo',
|
||||
hasPdf: true,
|
||||
projekt: '',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const storedSettings = () => aw.docs(SETTINGS()).get('main')
|
||||
const storedCost = (id) => aw.docs(COSTS()).get(id)
|
||||
const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path))
|
||||
|
||||
const DEFAULTS = { minBufferCents: 0, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: '' }
|
||||
const DEFAULTS = { minBufferCents: 0, taxReservePercent: 30, previousYearRevenueCents: null, estPrepaymentCents: 0, bankDetailsText: '', steuernummer: '' }
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
|
||||
async function reset() {
|
||||
@@ -124,7 +124,7 @@ test('GET /einstellungen: gespeicherte Werte ueberschreiben die Standardwerte, f
|
||||
assert.deepEqual((await getSettings()).einstellungen, { ...DEFAULTS, previousYearRevenueCents: 0, bankDetailsText: 'IBAN DE00' })
|
||||
})
|
||||
|
||||
test('GET /einstellungen: nur die fuenf Felder, nie Appwrite-Metafelder', async () => {
|
||||
test('GET /einstellungen: nur die sechs Felder, nie Appwrite-Metafelder', async () => {
|
||||
aw.seed(SETTINGS(), { $id: 'main', taxReservePercent: 25, secret: 'intern' })
|
||||
const { einstellungen } = await getSettings()
|
||||
assert.deepEqual(Object.keys(einstellungen).sort(), Object.keys(DEFAULTS).sort())
|
||||
@@ -184,6 +184,28 @@ test('PATCH: der Bankverbindungstext wird ohne Rand-Leerraum gespeichert', async
|
||||
assert.equal(storedSettings().bankDetailsText, 'IBAN DE00 0000')
|
||||
})
|
||||
|
||||
test('PATCH steuernummer: Vorgabe leer, getrimmt gespeichert; Buchstaben, Ziffern, Leerzeichen, / - und . bis 40 Zeichen, sonst 400', async () => {
|
||||
assert.equal((await getSettings()).einstellungen.steuernummer, '')
|
||||
const getrimmt = await patchSettings({ steuernummer: ' 12/345/67890 ' })
|
||||
assert.equal(getrimmt.status, 200, JSON.stringify(getrimmt.body))
|
||||
assert.equal(getrimmt.body.einstellungen.steuernummer, '12/345/67890')
|
||||
assert.equal(storedSettings().steuernummer, '12/345/67890')
|
||||
assert.equal((await getSettings()).einstellungen.steuernummer, '12/345/67890')
|
||||
for (const wert of ['DE123456789', 'de 123.456.789', '12 345 67890', '12-345-678', 'A'.repeat(40), ` ${'1'.repeat(40)} `, ' ', '']) {
|
||||
const res = await patchSettings({ steuernummer: wert })
|
||||
assert.equal(res.status, 200, JSON.stringify(wert))
|
||||
assert.equal(res.body.einstellungen.steuernummer, wert.trim())
|
||||
assert.equal(storedSettings().steuernummer, wert.trim())
|
||||
}
|
||||
assert.equal((await patchSettings({ steuernummer: 'DE123456789' })).status, 200)
|
||||
for (const wert of ['<script>', '1'.repeat(41), 'A'.repeat(41), '123_456', '12;34', 'Müller 12', '12\n34', '12\t34', null, 123, ['1'], true]) {
|
||||
const res = await patchSettings({ steuernummer: wert })
|
||||
assert.equal(res.status, 400, JSON.stringify(wert))
|
||||
assert.equal(res.body.error, 'Steuernummer ungültig.')
|
||||
}
|
||||
assert.equal(storedSettings().steuernummer, 'DE123456789', 'ungültige Werte ändern nichts')
|
||||
})
|
||||
|
||||
test('PATCH { previousYearRevenueCents: null } loescht den Wert wieder', async () => {
|
||||
assert.equal((await patchSettings({ previousYearRevenueCents: 4500000 })).body.einstellungen.previousYearRevenueCents, 4500000)
|
||||
assert.equal(storedSettings().previousYearRevenueCents, 4500000)
|
||||
@@ -582,6 +604,18 @@ test('Rechnung: ohne Einstellungsdokument nur Kleinunternehmer-Hinweis und Verwe
|
||||
assert.equal(aw.docs(SETTINGS()).size, 0)
|
||||
})
|
||||
|
||||
test('Rechnung: mit gesetzter Steuernummer steht sie als Zeile direkt nach dem § 19-Satz im Rechnungsfuss, ohne Wert bleibt der Fuss unveraendert', async () => {
|
||||
assert.equal((await patchSettings({ steuernummer: '123/456/78901', bankDetailsText: 'IBAN DE00 0000' })).status, 200)
|
||||
await createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'kenso@example.invalid' })
|
||||
assert.equal(footerOfLastDraft(), `${config.stripe.invoiceFooter}\nSteuernummer: 123/456/78901\nIBAN DE00 0000\n${PAYMENT_NOTE}`)
|
||||
|
||||
// geloescht: wieder wie vorher
|
||||
assert.equal((await patchSettings({ steuernummer: '', bankDetailsText: '' })).status, 200)
|
||||
resetInvoiceCreateGuardForTests()
|
||||
await createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'kenso@example.invalid' })
|
||||
assert.equal(footerOfLastDraft(), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
|
||||
})
|
||||
|
||||
test('Rechnung: sind die Einstellungen nicht lesbar, scheitert sie vor jedem Stripe-Aufruf', async () => {
|
||||
aw.failNext({ collection: SETTINGS(), method: 'GET', status: 500 })
|
||||
await assert.rejects(createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'k' }))
|
||||
|
||||
@@ -114,6 +114,11 @@ test('mapInvoice: Felder, Betraege in Cent und Berliner Kalendertage', () => {
|
||||
pdfUrl: 'https://invoice.stripe.test/i/in_1/pdf',
|
||||
outOfBand: false,
|
||||
bankTransactionId: '',
|
||||
projektId: '',
|
||||
projektName: '',
|
||||
art: 'frei',
|
||||
auftragId: '',
|
||||
auftragNummer: '',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ const MOUNTS = [
|
||||
['/api/termine', termineRouter],
|
||||
['/api/shop', (await import('../server/routes/shop.js')).default],
|
||||
['/api/zeiten', zeitenRouter],
|
||||
['/api/auftraege', (await import('../server/routes/auftraegeKunde.js')).default],
|
||||
['/api/auftrag', (await import('../server/routes/auftrag.js')).default],
|
||||
]
|
||||
// Oeffentliche Schnittstelle (ohne Session) wie in server/index.js hinter dem Waechter; nicht in MOUNTS, weil sie keine Kunden-Route ist
|
||||
const oeffentlich = (await import('../server/routes/publicTermine.js')).default
|
||||
@@ -146,6 +148,19 @@ test('Behalten der Öffnungszeiten ist in der Kundenansicht gesperrt (Route gefu
|
||||
assert.equal(res.body.code, 'view_blocked')
|
||||
})
|
||||
|
||||
// Auftragsbestätigung bestätigen ist eine Erklärung im Namen des Kunden (Spec Abschnitt 2): Portal-Weg und Link-Weg stehen nicht auf der
|
||||
// Erlaubnisliste; der Wächter sperrt sie vor dem Router (auch der Link-Weg, wenn der Browser das Cookie einer Kundenansicht trägt).
|
||||
test('Auftragsbestätigung bestätigen ist in der Kundenansicht gesperrt (Portal-Weg und Link-Weg)', async () => {
|
||||
const routes = writeRoutes(MOUNTS)
|
||||
for (const p of ['/api/auftraege/x/bestaetigen', '/api/auftrag/x/bestaetigen']) {
|
||||
assert.ok(routes.some(({ method, path: r }) => method === 'POST' && r === p), `Route ${p} gefunden`)
|
||||
assert.ok(!allowed('POST', p), p)
|
||||
const res = await call('POST', p, { ...kv(), body: { bestaetigt: true, fruehbeginn: false } })
|
||||
assert.equal(res.status, 403, p)
|
||||
assert.equal(res.body.code, 'view_blocked', p)
|
||||
}
|
||||
})
|
||||
|
||||
test('Erlaubnisliste: genau News, Terminplaner, Webshop-Verwaltung, Öffnungszeiten, Analyse-Link, Ende, Logout und Login', () => {
|
||||
const ok = [
|
||||
['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'],
|
||||
|
||||
@@ -6,9 +6,9 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
|
||||
|
||||
const DIR = new URL('../server/mail-templates/', import.meta.url)
|
||||
// G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb;
|
||||
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
|
||||
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9); K31, K32 und T22 Auftragsbestätigung (Aufträge und Rechnungen, Strang L5; K31 und K32 in Sie-Form, T22 mit [Aufträge])
|
||||
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
|
||||
const EXPECTED = [...ids('G', 22), ...ids('K', 35), ...ids('T', 22)]
|
||||
const EXPECTED = [...ids('G', 22), ...ids('K', 37), ...ids('T', 23)]
|
||||
// GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten)
|
||||
const GUEST = ids('G', 8)
|
||||
const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15']
|
||||
@@ -27,7 +27,7 @@ for (const id of EXPECTED) {
|
||||
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
|
||||
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
|
||||
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen), [Zeit] (Push-Zeitmail), [Finanzen] (Bankzugang), [Terminplaner] (Betriebsmeldung) oder [Webshop] (neue Anfrage)
|
||||
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop|Buchungskalender)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner], [Webshop] oder [Buchungskalender] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
|
||||
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit|Finanzen|Terminplaner|Webshop|Aufträge|Buchungskalender)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner], [Webshop], [Aufträge] oder [Buchungskalender] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
|
||||
|
||||
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
|
||||
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
|
||||
@@ -446,7 +446,7 @@ test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Au
|
||||
for (const p of ['vertrag', 'pakete', 'pakete_neu']) assert.ok(loadTemplate('K03').placeholders.includes(p), `K03 ${p}`)
|
||||
for (const b of ['zusatzspeicher', 'pruefen', 'abo']) assert.ok(loadTemplate('K03').blocks.includes(b), `K03 ${b}`)
|
||||
assert.ok(loadTemplate('K04').placeholders.includes('bestellung'))
|
||||
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen'])
|
||||
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen'])
|
||||
for (const p of ['vertrag', 'pakete', 'fehler']) assert.ok(loadTemplate('T08').placeholders.includes(p), `T08 ${p}`)
|
||||
for (const b of ['zusatzspeicher', 'kein_speicher', 'wechsel_ausstehend', 'speicher_fehlgeschlagen', 'nachweis_fehlt']) assert.ok(loadTemplate('T08').blocks.includes(b), `T08 ${b}`)
|
||||
for (const p of ['bestellung', 'ereignisse']) assert.ok(loadTemplate('T09').placeholders.includes(p), `T09 ${p}`)
|
||||
@@ -613,13 +613,13 @@ test('K04: Folgen des Widerrufs je Bestellung (Abschluss, Wechsel, Zusatzspeiche
|
||||
})
|
||||
|
||||
// F9: Widerruf je Funktion - T09 kennt preis, die Blöcke bleiben unverändert; K04/T09 nennen Produkt und Folgen der Funktion
|
||||
test('F9: T09 deklariert preis, Blöcke von K04 und T09 unverändert', () => {
|
||||
test('F9: T09 deklariert preis, Blöcke von K04 und T09 wie festgelegt (dazu je der Block projekt aus Strang L5)', () => {
|
||||
assert.ok(loadTemplate('T09').placeholders.includes('preis'))
|
||||
assert.ok(loadTemplate('T09').placeholders.includes('produkt'))
|
||||
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen'])
|
||||
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen', 'projekt', 'projekt_unternehmen'])
|
||||
assert.deepEqual(loadTemplate('T09').blocks, [
|
||||
'kundennummer', 'abo_gefunden', 'auf_kuendigung_gesetzt', 'kein_abo_gefunden', 'kuendigung_fehlgeschlagen', 'abschluss', 'wechsel',
|
||||
'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt', 'wechsel_offen', 'abschluss_verspaetet', 'nicht_pruefbar', 'bereits_widerrufen',
|
||||
'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt', 'wechsel_offen', 'abschluss_verspaetet', 'nicht_pruefbar', 'bereits_widerrufen', 'projekt',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -715,7 +715,7 @@ test('T07: Hinweis auf nicht genutzte Arbeitszeit nur im Block wartung', () => {
|
||||
})
|
||||
|
||||
// Etappe 4: das Admin-Panel ist abgeschaltet, Team-Mails fuehren ins Ticketsystem
|
||||
const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21']
|
||||
const TEAM_FOOTER = ['T01', 'T02', 'T03', 'T04', 'T05', 'T06', 'T07', 'T08', 'T09', 'T10', 'T11', 'T12', 'T13', 'T14', 'T16', 'T19', 'T20', 'T21', 'T22']
|
||||
|
||||
test('keine Vorlage verlinkt admin.html oder nennt den Admin-Bereich', () => {
|
||||
for (const id of EXPECTED) {
|
||||
|
||||
@@ -1456,7 +1456,7 @@ test('widerrufen_html_radio: Radioknöpfe order', () => {
|
||||
assert.match(src, /name="order" value="shop"[^>]*data-text="Webshop \(Einrichtung\)"[^>]*> Webshop \(Einrichtung\)<\/label>/)
|
||||
assert.match(src, /name="order" value="zeiten"[^>]*data-text="Öffnungszeiten live"[^>]*> Öffnungszeiten live<\/label>/)
|
||||
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
|
||||
assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/)
|
||||
assert.match(src, /fields: \['order', 'abNummer', 'name', 'customerNumber', 'email'\]/)
|
||||
})
|
||||
|
||||
test('legal-form.js: Auswahl statt festem Vertrag, packages nur beim Zusatzspeicher', () => {
|
||||
|
||||
@@ -26,7 +26,10 @@ import http from 'node:http'
|
||||
* Wert loescht), .../finalize (nur draft -> open, vergibt die Nummer WK-0001, WK-0002, ... und die Faelligkeit),
|
||||
* .../send (nur open), .../void (nur open -> void), .../pay (nur open -> paid, auch paid_out_of_band; wie die echte API
|
||||
* liefert die Rechnung danach kein Feld paid_out_of_band, nur der Aufruf in calls zeigt es) und
|
||||
* DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400. Der Kunde einer neuen
|
||||
* DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400.
|
||||
* custom_fields (hoechstens 4, Name 1 bis 40, Wert 1 bis 140 Zeichen) und rendering[pdf][page_size] (a4, auto, letter) werden wie
|
||||
* bei Stripe geprueft, gespeichert (sonst null) und mit der Rechnung geliefert; Rechnungsposten mit amount (auch negativ)
|
||||
* tragen weder quantity noch unit_amount_decimal. Der Kunde einer neuen
|
||||
* Rechnung muss nicht in st.customers stehen (dann bleiben Name und E-Mail leer). Traegt ein Aufruf die Kopfzeile
|
||||
* Idempotency-Key, steht sie als idempotencyKey in calls; delayNext verzoegert eine Antwort (Zeitgrenzen testen).
|
||||
* Kunden: GET /v1/customers/search (Metadaten), POST /v1/customers (anlegen) und POST /v1/customers/:id (aendern:
|
||||
@@ -461,6 +464,22 @@ export async function startFakeStripe() {
|
||||
}
|
||||
const metadata = mergeMetadata({}, data.metadata)
|
||||
if (Array.isArray(metadata)) return metadata
|
||||
const customFields = data.custom_fields
|
||||
if (customFields !== undefined) {
|
||||
if (!Array.isArray(customFields) || customFields.length > 4) return invalid('custom_fields', 'You can specify up to 4 custom fields.')
|
||||
for (const [i, field] of customFields.entries()) {
|
||||
if (typeof field?.name !== 'string' || field.name.length < 1 || field.name.length > 40) {
|
||||
return invalid(`custom_fields[${i}][name]`, 'Custom field names must be between 1 and 40 characters.')
|
||||
}
|
||||
if (typeof field.value !== 'string' || field.value.length < 1 || field.value.length > 140) {
|
||||
return invalid(`custom_fields[${i}][value]`, 'Custom field values must be between 1 and 140 characters.')
|
||||
}
|
||||
}
|
||||
}
|
||||
const pageSize = data.rendering?.pdf?.page_size
|
||||
if (pageSize !== undefined && !['a4', 'auto', 'letter'].includes(pageSize)) {
|
||||
return invalid('rendering[pdf][page_size]', 'Invalid page_size: must be one of a4, auto, letter.')
|
||||
}
|
||||
const customer = customers.get(data.customer)
|
||||
const invoice = {
|
||||
id: nextId('in'),
|
||||
@@ -479,6 +498,8 @@ export async function startFakeStripe() {
|
||||
description: data.description ?? null,
|
||||
footer: data.footer ?? null,
|
||||
metadata,
|
||||
custom_fields: customFields ?? null,
|
||||
rendering: data.rendering ?? null,
|
||||
created: nowUnix(),
|
||||
total: 0,
|
||||
subtotal: 0,
|
||||
@@ -496,6 +517,9 @@ export async function startFakeStripe() {
|
||||
if (method === 'POST' && path === '/v1/invoiceitems') {
|
||||
const data = unflatten(body)
|
||||
if (!data.customer) return invalid('customer', 'Missing required param: customer.')
|
||||
if (data.amount !== undefined && (data.quantity !== undefined || data.unit_amount_decimal !== undefined)) {
|
||||
return invalid('amount', 'You may only specify one of these parameters: amount, or quantity with unit_amount_decimal.')
|
||||
}
|
||||
const quantity = data.quantity !== undefined ? Number(data.quantity) : 1
|
||||
if (!Number.isInteger(quantity) || quantity < 0) return invalid('quantity', 'Invalid non-negative integer: quantity.')
|
||||
let unit = null
|
||||
|
||||
+10
-3
@@ -3,6 +3,7 @@ import { TERMINE_COLLECTIONS } from '../../server/services/termine/schema.js'
|
||||
import { SHOP_COLLECTIONS } from '../../server/services/shop/schema.js'
|
||||
import { UNTERKUNFT_COLLECTIONS } from '../../server/services/unterkunft/schema.js'
|
||||
import { ZEITEN_COLLECTIONS, ZEITEN_FEATURE_ATTRIBUTES } from '../../server/services/zeiten/schema.js'
|
||||
import { AUFTRAEGE_COLLECTIONS, AUFTRAEGE_ZUSATZ_ATTRIBUTE } from '../../server/services/auftraege/schema.js'
|
||||
import { FEATURE_CONSENT_ATTRIBUTES, PORTAL_FEATURE_ATTRIBUTES } from '../../scripts/feature-setup.mjs'
|
||||
|
||||
// Setup-Skripte, die Collections in Appwrite anlegen (Blockformat: 'id', ... \n ],)
|
||||
@@ -18,9 +19,12 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
|
||||
* die Zeiten-Collections aus server/services/zeiten/schema.js (scripts/zeiten-setup.mjs);
|
||||
* portalFeatures bekommt zusaetzlich die Attribute aus scripts/feature-setup.mjs (PORTAL_FEATURE_ATTRIBUTES) und die
|
||||
* Zeiten-Felder aus server/services/zeiten/schema.js (ZEITEN_FEATURE_ATTRIBUTES, angelegt von scripts/zeiten-setup.mjs);
|
||||
* featureConsents (Zustimmungsnachweis) steht ganz dort (FEATURE_CONSENT_ATTRIBUTES).
|
||||
* featureConsents (Zustimmungsnachweis) steht ganz dort (FEATURE_CONSENT_ATTRIBUTES);
|
||||
* die Aufträge-Collections kommen aus server/services/auftraege/schema.js (scripts/auftraege-setup.mjs), und ihre
|
||||
* Zusatzfelder bestehender Collections (AUFTRAEGE_ZUSATZ_ATTRIBUTE, z. B. financeSettings.steuernummer) werden angehängt.
|
||||
*/
|
||||
export function schemaKeys(collection) {
|
||||
if (Object.hasOwn(AUFTRAEGE_COLLECTIONS, collection)) return AUFTRAEGE_COLLECTIONS[collection].attributes.map((a) => a.key)
|
||||
if (Object.hasOwn(TERMINE_COLLECTIONS, collection)) return TERMINE_COLLECTIONS[collection].attributes.map((a) => a.key)
|
||||
if (Object.hasOwn(SHOP_COLLECTIONS, collection)) return SHOP_COLLECTIONS[collection].attributes.map((a) => a.key)
|
||||
if (Object.hasOwn(UNTERKUNFT_COLLECTIONS, collection)) return UNTERKUNFT_COLLECTIONS[collection].attributes.map((a) => a.key)
|
||||
@@ -32,8 +36,11 @@ export function schemaKeys(collection) {
|
||||
if (start < 0) continue
|
||||
const block = src.slice(start, src.indexOf('\n ],', start))
|
||||
const keys = [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1])
|
||||
return collection === 'portalFeatures'
|
||||
? [...keys, ...PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key), ...ZEITEN_FEATURE_ATTRIBUTES.map((a) => a.key)]
|
||||
if (collection === 'portalFeatures') {
|
||||
return [...keys, ...PORTAL_FEATURE_ATTRIBUTES.map((a) => a.key), ...ZEITEN_FEATURE_ATTRIBUTES.map((a) => a.key)]
|
||||
}
|
||||
return Object.hasOwn(AUFTRAEGE_ZUSATZ_ATTRIBUTE, collection)
|
||||
? [...keys, ...AUFTRAEGE_ZUSATZ_ATTRIBUTE[collection].map((a) => a.key)]
|
||||
: keys
|
||||
}
|
||||
throw new Error(`Collection ${collection} fehlt im Setup-Skript`)
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
// Playwright-Prüfung der Kundenseiten zu Aufträgen (Strang L6): auftrag.html, Abschnitt „Aufträge“ im Dashboard, widerrufen.html.
|
||||
// Statischer Server für public/ + page.route-Nachbau der /api-Antworten; nichts verlässt den Rechner. Nicht Teil von npm test.
|
||||
// Aufruf: free -m; flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock nice node test/ui/auftraege-kundenportal.ui.mjs
|
||||
// Bildschirmfotos: $SHOTS (Standard: Scratchpad auftraege-l6/). Exit-Code 1, wenn eine Prüfung scheitert.
|
||||
import http from 'node:http'
|
||||
import { readFile, mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
|
||||
import { TOKEN, ab, privat, bestaetigt, eintrag } from '../auftraegeKundenportalDaten.js'
|
||||
|
||||
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
|
||||
const SHOTS = process.env.SHOTS || '/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-l6'
|
||||
await mkdir(SHOTS, { recursive: true })
|
||||
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json', '.woff2': 'font/woff2' }
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const datei = path.join(pub, new URL(req.url, 'http://x').pathname)
|
||||
if (!datei.startsWith(pub + path.sep)) throw new Error('außerhalb von public/')
|
||||
res.writeHead(200, { 'content-type': TYPES[path.extname(datei)] || 'application/octet-stream' })
|
||||
res.end(await readFile(datei))
|
||||
} catch {
|
||||
res.writeHead(404)
|
||||
res.end('nicht gefunden')
|
||||
}
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
|
||||
let failed = 0
|
||||
const check = (name, ok, detail = '') => {
|
||||
console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
|
||||
if (!ok) failed += 1
|
||||
}
|
||||
const norm = (text) => String(text).replace(/\s+/g, ' ').trim() // auch das geschützte Leerzeichen vor „€“
|
||||
const laufe = async (name, fn) => {
|
||||
try {
|
||||
await fn()
|
||||
} catch (err) {
|
||||
check(name, false, String(err.message).split('\n')[0])
|
||||
}
|
||||
}
|
||||
|
||||
let aufrufe = [] // alle /api-Anfragen der laufenden Prüfung
|
||||
async function mocke(page, handler) {
|
||||
aufrufe = []
|
||||
await page.unroute('**/api/**')
|
||||
await page.route('**/api/**', async (route) => {
|
||||
const req = route.request()
|
||||
const roh = req.postData()
|
||||
aufrufe.push({ pfad: new URL(req.url()).pathname, methode: req.method(), body: roh ? JSON.parse(roh) : undefined })
|
||||
const antwort = await handler(aufrufe.at(-1))
|
||||
if (antwort === 'abbrechen') return route.abort()
|
||||
const { status = 200, body = {} } = antwort || {}
|
||||
return route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
}
|
||||
|
||||
const GET_AB = `/api/auftrag/${TOKEN}`
|
||||
const POST_AB = `/api/auftrag/${TOKEN}/bestaetigen`
|
||||
const KUNDE = { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' }
|
||||
const RECHNUNGEN = [
|
||||
{ id: 'in_1', source: 'stripe', number: 'WEBKLAR-0009', date: '2026-10-06', dueDate: '2026-10-20', amount: 500, balance: 500, status: 'Offen', paid: false, cancelled: false, overdue: false, portalUrl: 'https://invoice.stripe.test/i/in_1', hasPdf: true, projekt: 'Website Anelia Wolf' },
|
||||
{ id: 'in_2', source: 'stripe', number: 'H96T-0004', date: '2026-10-01', dueDate: '', amount: 25, balance: 0, status: 'Bezahlt', paid: true, cancelled: false, overdue: false, portalUrl: '', hasPdf: true, projekt: '' },
|
||||
]
|
||||
const AUFTRAEGE = [
|
||||
eintrag({ id: 'ab3', nummer: 'AB-2026-0003' }),
|
||||
eintrag({ id: 'ab2', ...bestaetigt(privat()), nummer: 'AB-2026-0002', titel: 'Logo und Visitenkarten', kannBestaetigen: false }),
|
||||
eintrag({ id: 'ab1', nummer: 'AB-2026-0001', titel: 'Wartung Oktober', status: 'storniert', kannBestaetigen: false, art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.' }),
|
||||
]
|
||||
const dashboardApi = (me) => ({ pfad, methode }) => {
|
||||
if (pfad === '/api/auth/me') return { body: me }
|
||||
if (pfad === '/api/invoices') return { body: { invoices: RECHNUNGEN, incomplete: false } }
|
||||
// wie L5: in der Kundenansicht ist kannBestaetigen immer false
|
||||
if (pfad === '/api/auftraege') return { body: { aktiv: true, auftraege: AUFTRAEGE.map((a) => (me.viewAs ? { ...a, kannBestaetigen: false } : a)) } }
|
||||
if (methode === 'POST' && pfad === '/api/auftraege/ab3/bestaetigen') return { body: { auftrag: bestaetigt(ab({ nummer: 'AB-2026-0003' })) } }
|
||||
if (pfad === '/api/projects') return { body: { projects: [] } }
|
||||
if (pfad === '/api/features') return { body: { features: [] } }
|
||||
if (pfad === '/api/activity') return { body: { tickets: [] } }
|
||||
if (pfad === '/api/contact') return { body: { name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' } }
|
||||
if (pfad === '/api/chat/unread-count') return { body: { unread: 0 } }
|
||||
if (pfad === '/api/chat/messages') return { body: { messages: [], unread: 0 } }
|
||||
return { body: {} }
|
||||
}
|
||||
const postBody = () => JSON.stringify(aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body)
|
||||
|
||||
const browser = await chromium.launch().catch(() => chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome' }))
|
||||
try {
|
||||
for (const [label, viewport, mobil] of [['handy', { width: 390, height: 844 }, true], ['desktop', { width: 1440, height: 900 }, false]]) {
|
||||
const ctx = await browser.newContext({ viewport, deviceScaleFactor: mobil ? 2 : 1, isMobile: mobil, hasTouch: mobil, locale: 'de-DE', timezoneId: 'Europe/Berlin' })
|
||||
const page = await ctx.newPage()
|
||||
page.setDefaultTimeout(30000)
|
||||
const jsFehler = []
|
||||
page.on('pageerror', (e) => jsFehler.push(e.message))
|
||||
const foto = async (name) => {
|
||||
// Einblend-Animationen des Dashboards abwarten; das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile:
|
||||
// darum das Fenster kurz auf die Seitenhöhe ziehen (höchstens 3200 px)
|
||||
await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))))
|
||||
const fenster = page.viewportSize()
|
||||
const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3200))
|
||||
await page.setViewportSize({ width: fenster.width, height: hoehe })
|
||||
await page.screenshot({ path: `${SHOTS}/${name}-${label}.png`, fullPage: true })
|
||||
await page.setViewportSize(fenster)
|
||||
}
|
||||
const zustand = (z) => page.waitForSelector(`#ab-wurzel[data-zustand="${z}"]`)
|
||||
const ohneUeberlauf = (selektor) => page.evaluate((s) => { const el = document.querySelector(s); return el.scrollWidth <= el.clientWidth + 1 }, selektor)
|
||||
const oeffne = async (pfad, handler) => {
|
||||
await mocke(page, handler)
|
||||
await page.goto('about:blank') // frisch laden, auch wenn sich nur das Fragment unterscheidet
|
||||
await page.goto(`${base}${pfad}`)
|
||||
}
|
||||
const oeffneAuftrag = (handler, adresse = `#t=${TOKEN}`) => oeffne(`/auftrag.html${adresse}`, handler) // K31 verlinkt #t=
|
||||
const alsAb = (a, neu) => ({ pfad, methode }) => {
|
||||
if (methode === 'GET' && pfad === GET_AB) return { body: { auftrag: a } }
|
||||
if (methode === 'POST' && pfad === POST_AB) return { body: { auftrag: neu } }
|
||||
}
|
||||
|
||||
await laufe(`${label} unternehmen`, async () => {
|
||||
await oeffneAuftrag(alsAb(ab(), bestaetigt(ab())))
|
||||
await zustand('offen')
|
||||
check(`${label} offen_titel`, norm(await page.textContent('h1')) === 'Auftragsbestätigung AB-2026-0002')
|
||||
check(`${label} formular_sie_form`, norm(await page.textContent('legend')) === 'Ihre Bestätigung')
|
||||
check(`${label} paragraf_19_aus_der_api`, norm(await page.textContent('[data-hinweis]')) === 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
|
||||
check(`${label} offen_positionen_und_summe`, (await page.locator('[data-position]').count()) === 2 && norm(await page.innerText('[data-gesamt]')) === 'Gesamtpreis 800,00 €')
|
||||
check(`${label} offen_links`, (await page.getAttribute('[data-link="pdf"]', 'href')) === `/api/auftrag/${TOKEN}/pdf` && (await page.getAttribute('[data-link="agb"]', 'href')) === '/rechtliches/agb-2026-09-30.pdf')
|
||||
check(`${label} offen_kein_zweites_haekchen`, (await page.locator('[data-feld="fruehbeginn"]').count()) === 0)
|
||||
check(`${label} fusszeile`, (await page.locator('footer a').allInnerTexts()).join('|') === 'Datenschutz|Impressum')
|
||||
check(`${label} kein_seitlicher_ueberlauf`, await ohneUeberlauf('html'))
|
||||
const knopf = page.locator('[data-aktion="bestaetigen"]')
|
||||
check(`${label} knopf_gesperrt_ohne_haken_und_gross_genug`, (await knopf.isDisabled()) && (await knopf.boundingBox()).height >= 44)
|
||||
await foto('auftrag-offen-unternehmen')
|
||||
await page.check('[data-feld="bestaetigung"]')
|
||||
check(`${label} knopf_frei_mit_haken`, await knopf.isEnabled())
|
||||
await knopf.click()
|
||||
await zustand('bestaetigt')
|
||||
check(`${label} post_koerper`, postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false }))
|
||||
check(`${label} bestaetigt_zeit`, norm(await page.innerText('[data-bestaetigt]')) === 'Bestätigt am 07.10.2026, 10:15 Uhr')
|
||||
check(`${label} token_nicht_im_text`, !(await page.innerText('body')).includes(TOKEN))
|
||||
})
|
||||
|
||||
await laufe(`${label} rueckfall`, async () => {
|
||||
await oeffneAuftrag(alsAb(ab()), `?t=${TOKEN}`)
|
||||
await zustand('offen')
|
||||
check(`${label} rueckfall_t_in_der_suche`, aufrufe.some((a) => a.methode === 'GET' && a.pfad === GET_AB))
|
||||
})
|
||||
|
||||
await laufe(`${label} privat`, async () => {
|
||||
await oeffneAuftrag(alsAb(privat(), bestaetigt(privat())))
|
||||
await zustand('offen')
|
||||
const frueh = page.locator('[data-feld="fruehbeginn"]')
|
||||
check(`${label} privat_zweites_haekchen_frei_waehlbar`, (await frueh.isVisible()) && !(await frueh.isChecked()))
|
||||
check(`${label} privat_kein_seitlicher_ueberlauf`, await ohneUeberlauf('html'))
|
||||
await foto('auftrag-offen-privat')
|
||||
await page.check('[data-feld="bestaetigung"]')
|
||||
await page.click('[data-aktion="bestaetigen"]')
|
||||
await zustand('bestaetigt')
|
||||
check(`${label} privat_ohne_fruehbeginn`, postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false }))
|
||||
check(`${label} privat_fruehester_beginn`, norm(await page.innerText('[data-bestaetigt]')) === 'Bestätigt am 07.10.2026, 10:15 Uhr Wir beginnen frühestens am 21.10.2026.')
|
||||
await foto('auftrag-bestaetigt-privat')
|
||||
})
|
||||
|
||||
await laufe(`${label} aufwand`, async () => {
|
||||
await oeffneAuftrag(alsAb(ab({ art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten. Geschätzter Umfang: etwa 10 Stunden (unverbindlich).', zahlungsplanText: 'Monatlich nach Aufwand.' })))
|
||||
await zustand('offen')
|
||||
check(`${label} aufwand_text_statt_tabelle`, (await page.locator('[data-aufwand]').count()) === 1 && (await page.locator('table').count()) === 0)
|
||||
await foto('auftrag-aufwand')
|
||||
})
|
||||
|
||||
await laufe(`${label} fehlerzustaende`, async () => {
|
||||
await oeffneAuftrag(() => ({ status: 404, body: { error: 'Dieser Link ist ungültig oder abgelaufen.' } }))
|
||||
await zustand('ungueltig')
|
||||
check(`${label} ungueltig_kontaktweg`, (await page.locator('a[href="mailto:support@webklar.com"]').count()) === 1)
|
||||
await foto('auftrag-ungueltig')
|
||||
await oeffneAuftrag(() => ({ status: 410, body: { error: 'Diese Auftragsbestätigung wurde ersetzt.' } }))
|
||||
await zustand('beendet')
|
||||
check(`${label} beendet_titel`, norm(await page.textContent('h1')) === 'Diese Auftragsbestätigung wurde ersetzt.')
|
||||
await foto('auftrag-beendet')
|
||||
await oeffneAuftrag(() => ({ status: 429, body: {} }))
|
||||
await zustand('limit')
|
||||
let versuche = 0
|
||||
await oeffneAuftrag(({ pfad }) => {
|
||||
if (pfad !== GET_AB) return undefined
|
||||
versuche += 1
|
||||
return versuche === 1 ? { status: 500, body: { error: 'Interner Fehler.' } } : { body: { auftrag: ab() } }
|
||||
})
|
||||
await zustand('fehler')
|
||||
await foto('auftrag-fehler')
|
||||
await page.click('[data-aktion="erneut"]')
|
||||
await zustand('offen')
|
||||
await oeffneAuftrag(() => 'abbrechen')
|
||||
await zustand('fehler')
|
||||
check(`${label} netzfehler_ohne_browsertext`, !/Failed to fetch|NetworkError|fetch failed/i.test(await page.innerText('body')))
|
||||
await oeffne('/auftrag.html', () => ({}))
|
||||
await zustand('ungueltig')
|
||||
check(`${label} ohne_token_keine_anfrage`, aufrufe.length === 0)
|
||||
})
|
||||
|
||||
await laufe(`${label} portal`, async () => {
|
||||
await oeffne('/dashboard.html#rechnungen', dashboardApi({ authenticated: true, customer: KUNDE }))
|
||||
await page.waitForSelector('#orders-section:not(.hidden) .ab-karte')
|
||||
check(`${label} portal_reihenfolge`, JSON.stringify(await page.$$eval('.ab-karte', (k) => k.map((x) => x.getAttribute('data-auftrag')))) === JSON.stringify(['ab3', 'ab2', 'ab1']))
|
||||
check(`${label} portal_du_form`, norm(await page.textContent('#orders-section > p')) === 'Deine Auftragsbestätigungen. Offene Aufträge bestätigst du hier mit einem Klick.' && norm(await page.textContent('.ab-karte[data-auftrag="ab3"] legend')) === 'Deine Bestätigung')
|
||||
check(`${label} portal_projekt_unter_rechnungsnummer`, norm(await page.textContent('.inv-no .inv-project')) === 'Website Anelia Wolf')
|
||||
check(`${label} portal_offene_ab_aufgeklappt`, (await page.locator('.ab-karte[data-auftrag="ab3"] details').getAttribute('open')) !== null)
|
||||
check(`${label} portal_kein_seitlicher_ueberlauf`, await ohneUeberlauf('#orders-section'))
|
||||
await foto('portal-auftraege')
|
||||
await page.check('.ab-karte[data-auftrag="ab3"] [data-feld="bestaetigung"]')
|
||||
await page.click('.ab-karte[data-auftrag="ab3"] [data-aktion="bestaetigen"]')
|
||||
await page.waitForSelector('.ab-karte[data-auftrag="ab3"] [data-bestaetigt]')
|
||||
check(`${label} portal_post_und_status`, aufrufe.some((a) => a.methode === 'POST' && a.pfad === '/api/auftraege/ab3/bestaetigen') && postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false }) && norm(await page.textContent('.ab-karte[data-auftrag="ab3"] .ab-pille')) === 'Bestätigt')
|
||||
})
|
||||
|
||||
await laufe(`${label} ansichtsmodus`, async () => {
|
||||
const me = { authenticated: true, role: 'admin', viewAs: true, viewId: 'a'.repeat(32), viewExpiresAt: Date.now() + 3_600_000, admin: { name: 'Andrej', email: 'andrej@webklar.com', isAdmin: false }, customer: KUNDE }
|
||||
await oeffne('/dashboard.html#rechnungen', dashboardApi(me))
|
||||
await page.waitForSelector('#orders-section:not(.hidden) .ab-karte')
|
||||
const karte = '.ab-karte[data-auftrag="ab3"]'
|
||||
check(`${label} ansicht_gesperrt`, (await page.locator(`${karte} [data-aktion="bestaetigen"]`).isDisabled()) && (await page.locator(`${karte} [data-feld="bestaetigung"]`).isDisabled()))
|
||||
check(`${label} ansicht_hinweis`, norm(await page.textContent(`${karte} [data-gesperrt]`)) === 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.')
|
||||
await foto('portal-auftraege-ansichtsmodus')
|
||||
})
|
||||
|
||||
await laufe(`${label} widerruf`, async () => {
|
||||
const api = ({ pfad, methode }) => {
|
||||
if (pfad === '/api/auth/me') return { body: { authenticated: false } }
|
||||
if (methode === 'POST' && pfad === '/api/abo/public/withdraw') return { body: { receivedAt: '2026-10-07T08:15:00.000Z' } }
|
||||
}
|
||||
const oeffneWiderruf = async () => {
|
||||
await oeffne('/widerrufen.html', api)
|
||||
await page.waitForFunction(() => document.getElementById('ab-nummer-wrap')?.classList.contains('hidden'))
|
||||
}
|
||||
const absenden = async (nummer) => {
|
||||
await page.check('input[name="order"][value="projekt"]')
|
||||
await page.fill('#name', 'Berta Berg')
|
||||
await page.fill('#email', 'b@example.test')
|
||||
if (nummer !== undefined) await page.fill('#abNummer', nummer)
|
||||
await page.click('button[type="submit"]')
|
||||
}
|
||||
await oeffneWiderruf()
|
||||
check(`${label} widerruf_feld_zuerst_versteckt`, !(await page.locator('#ab-nummer-wrap').isVisible()))
|
||||
await absenden('AB-2026-01')
|
||||
check(`${label} widerruf_feld_nur_bei_projektauftrag`, await page.locator('#ab-nummer-wrap').isVisible())
|
||||
check(`${label} widerruf_unpassende_nummer_gestoppt`, aufrufe.every((a) => a.methode !== 'POST') && norm(await page.textContent('#form-error')).startsWith('Die Nummer sieht anders aus'))
|
||||
await foto('widerrufen-projekt')
|
||||
await page.fill('#abNummer', 'ab 2026 0001')
|
||||
await page.click('button[type="submit"]')
|
||||
await page.waitForSelector('#done:not(.hidden)')
|
||||
const mitNummer = aufrufe.find((a) => a.methode === 'POST')?.body
|
||||
check(`${label} widerruf_nummer_vereinheitlicht`, mitNummer?.order === 'projekt' && mitNummer.abNummer === 'AB-2026-0001', JSON.stringify(mitNummer))
|
||||
check(`${label} widerruf_bestaetigung_nennt_nummer`, norm(await page.textContent('#done-list')).includes('Nummer der AuftragsbestätigungAB-2026-0001'))
|
||||
await oeffneWiderruf()
|
||||
await absenden() // ohne Nummer: freiwillig
|
||||
await page.waitForSelector('#done:not(.hidden)')
|
||||
const ohneNummer = aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body
|
||||
check(`${label} widerruf_ohne_nummer_geht_durch`, ohneNummer?.order === 'projekt' && ohneNummer.abNummer === '', JSON.stringify(ohneNummer))
|
||||
await oeffneWiderruf()
|
||||
await page.fill('#name', 'Berta Berg')
|
||||
await page.fill('#email', 'b@example.test')
|
||||
await page.click('button[type="submit"]')
|
||||
await page.waitForSelector('#done:not(.hidden)')
|
||||
const andere = aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body
|
||||
check(`${label} widerruf_andere_art_ohne_nummer`, andere?.order === 'abschluss' && andere.abNummer === '', JSON.stringify(andere))
|
||||
})
|
||||
|
||||
check(`${label} keine_js_fehler`, jsFehler.length === 0, jsFehler.join(' | '))
|
||||
await ctx.close()
|
||||
}
|
||||
|
||||
// Dunkler Modus (Systemeinstellung): die Seite folgt den dunklen Farben der Mailvorlagen
|
||||
const dunkel = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'de-DE', colorScheme: 'dark' })
|
||||
const seite = await dunkel.newPage()
|
||||
await laufe('dunkel', async () => {
|
||||
await mocke(seite, ({ pfad }) => (pfad === GET_AB ? { body: { auftrag: privat() } } : undefined))
|
||||
await seite.goto(`${base}/auftrag.html#t=${TOKEN}`)
|
||||
await seite.waitForSelector('#ab-wurzel[data-zustand="offen"]')
|
||||
check('dunkel hintergrund', (await seite.evaluate(() => getComputedStyle(document.body).backgroundColor)) === 'rgb(22, 22, 21)')
|
||||
await seite.screenshot({ path: `${SHOTS}/auftrag-offen-privat-dunkel-handy.png`, fullPage: true })
|
||||
})
|
||||
await dunkel.close()
|
||||
} finally {
|
||||
await browser.close()
|
||||
server.close()
|
||||
}
|
||||
console.log(failed ? `${failed} Check(s) fehlgeschlagen` : 'alle Checks ok')
|
||||
process.exit(failed ? 1 : 0)
|
||||
Reference in new issue
Block a user