Merge auftraege/l4-ab-pdf: Auftragsbestätigung PDF (Strang L4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bd12892ed2
12 files changed
+1282
No files matched your search
@@ -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 })
|
||||
})
|
||||
Reference in new issue
Block a user