Buchungskalender: Strang mail zusammenführen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
97dd84fbb8
45 files changed
+3007
-21
No files matched your search
@@ -16,7 +16,7 @@ useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1'
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp } = await import('./support/testApp.js')
|
||||
const { legalPaths, legalPdfFiles, legalPdfAttachments, legalRouter } = await import('../server/services/legalDocs.js')
|
||||
const { render, parse, lauf, termineDokumente, shopDokumente, MarkdownFehler } = await import('../scripts/build-legal-html.mjs')
|
||||
const { render, parse, lauf, termineDokumente, shopDokumente, unterkunftDokumente, MarkdownFehler } = await import('../scripts/build-legal-html.mjs')
|
||||
|
||||
const V = config.termine.avvVersion
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
@@ -24,6 +24,8 @@ const lies = (rel) => readFileSync(join(ROOT, rel), 'utf8')
|
||||
const DOKUMENTE = termineDokumente(V)
|
||||
const S = config.shop.fassung
|
||||
const SHOP = shopDokumente(S)
|
||||
const U = config.unterkunft.fassung
|
||||
const UK = unterkunftDokumente(U)
|
||||
|
||||
const META = { fassung: '2026-10-05', titel: 'Prüfdokument', seitenkopf: 'Prüfdokument', nav: [{ href: '/dashboard.html', text: 'Portal' }] }
|
||||
const KOPF = '# Titel\n\n**Fassung 2026-10-05**\n\n'
|
||||
@@ -359,7 +361,7 @@ test('script_modus: build-legal-pdfs.sh kennt termine und datenschutz, lässt di
|
||||
// bash versteht das Skript, und ein unbekannter Modus liefert die Usage mit allen Modi
|
||||
const syntax = spawnSync('bash', ['-n', 'scripts/build-legal-pdfs.sh'], { cwd: ROOT, encoding: 'utf8' })
|
||||
assert.equal(syntax.status, 0, syntax.stderr)
|
||||
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|shop|datenschutz]'))
|
||||
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|shop|unterkunft|datenschutz]'))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- Webshop (Task J1)
|
||||
@@ -438,7 +440,7 @@ test('shop_lauf: --check meldet fehlende oder abweichende Seiten mit Exit 1, sch
|
||||
assert.equal(ruf('analyse'), 2)
|
||||
assert.equal(ruf('shop', '--schreiben'), 2)
|
||||
assert.equal(ruf(), 2)
|
||||
assert.match(zeilen[0], /Aufruf: node scripts\/build-legal-html\.mjs termine\|shop \[--check\]/)
|
||||
assert.match(zeilen[0], /Aufruf: node scripts\/build-legal-html\.mjs termine\|shop\|unterkunft \[--check\]/)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
@@ -480,7 +482,95 @@ test('shop_script_modus: build-legal-pdfs.sh kennt den Modus shop (Slugs der Reg
|
||||
assert.ok(shop.includes('sha256sum "$quelle"') && shop.includes('echo "$summe" > "$ziel.sha256"') && shop.includes('(unveraendert)'))
|
||||
assert.match(sh, /^shop\)\n {2}shop_pdfs\n {2};;$/m)
|
||||
assert.ok(sh.slice(sh.indexOf('\nall)'), sh.indexOf('\n*)')).includes(' shop_pdfs\n'), 'all druckt auch die Webshop-PDFs')
|
||||
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|shop|datenschutz]'))
|
||||
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|shop|unterkunft|datenschutz]'))
|
||||
const syntax = spawnSync('bash', ['-n', 'scripts/build-legal-pdfs.sh'], { cwd: ROOT, encoding: 'utf8' })
|
||||
assert.equal(syntax.status, 0, syntax.stderr)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- Buchungskalender (Task 21)
|
||||
|
||||
test('unterkunftDokumente: lb, avv und widerruf mit Quelle, Ziel und Navigation auf die Fassung des Buchungskalenders', () => {
|
||||
assert.deepEqual(UK.map((d) => d.doc), ['lb', 'avv', 'widerruf'])
|
||||
assert.deepEqual(
|
||||
UK.map((d) => [d.md, d.html]),
|
||||
[
|
||||
['docs/rechtliches/leistungsbeschreibung-unterkunft.md', `public/rechtliches/leistungsbeschreibung-unterkunft-${U}.html`],
|
||||
['docs/rechtliches/avv-unterkunft.md', `public/rechtliches/avv-unterkunft-${U}.html`],
|
||||
['docs/rechtliches/widerrufsbelehrung-funktionen.md', `public/rechtliches/widerrufsbelehrung-funktionen-${U}.html`],
|
||||
],
|
||||
)
|
||||
assert.deepEqual(Object.values(legalPaths('unterkunft', U)), UK.map((d) => d.html.replace(/^public/, '')))
|
||||
assert.deepEqual(unterkunftDokumente(), UK)
|
||||
// nur die Widerrufsbelehrung trägt eine fremde Fassung in der Quelle (die des Terminplaners)
|
||||
assert.deepEqual(UK.map((d) => d.stempel), [undefined, undefined, config.termine.avvVersion])
|
||||
})
|
||||
|
||||
test('unterkunft_html_gleich_generator: eingecheckte Seiten sind genau render(Markdown), Entwurfskennzeichen in LB und AVV, --check Exit 0', () => {
|
||||
for (const d of UK) {
|
||||
const md = d.stempel ? lies(d.md).replace(`**Fassung ${d.stempel}**`, `**Fassung ${U}**`) : lies(d.md)
|
||||
const html = render(md, d.meta)
|
||||
assert.equal(lies(d.html), html, `${d.html} weicht vom Generator ab (node scripts/build-legal-html.mjs unterkunft)`)
|
||||
assert.equal(render(md, d.meta), html, 'deterministisch')
|
||||
assert.ok(html.includes(`<strong>Fassung ${U}</strong>`))
|
||||
assert.ok(html.includes(`<meta name="webklar-fassung" content="${U}">`))
|
||||
assert.doesNotMatch(html.replaceAll(U, ''), /\b\d{4}-\d{2}-\d{2}\b/, `${d.html}: Datum außer der Fassung`)
|
||||
assert.doesNotMatch(html, /<!--|<script|<iframe|<img|<form|<link|\sstyle=|\ssrc=|\{\{|\}\}|undefined|NaN/, `${d.html}: verbotener Inhalt`)
|
||||
if (d.doc === 'lb' || d.doc === 'avv') assert.ok(html.includes('Entwurf – Prüfung durch Kanzlei offen'), `${d.doc}: Kennzeichnung fehlt`)
|
||||
else assert.ok(!html.includes('Entwurf'), 'die Widerrufsbelehrung ist kein Entwurf')
|
||||
}
|
||||
const lauf = spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', 'unterkunft', '--check'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
|
||||
assert.equal(lauf.status, 0, lauf.stdout + lauf.stderr)
|
||||
assert.equal([...lauf.stdout.matchAll(/^ok /gm)].length, 3)
|
||||
})
|
||||
|
||||
test('unterkunft_lauf: --check meldet fehlende Seiten mit Exit 1, schreibt drei Seiten; die Fassung der Quelle der Widerrufsbelehrung wird ersetzt', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'legalhtml-uk-'))
|
||||
try {
|
||||
mkdirSync(join(dir, 'docs/rechtliches'), { recursive: true })
|
||||
mkdirSync(join(dir, 'public/rechtliches'), { recursive: true })
|
||||
for (const d of UK) cpSync(join(ROOT, d.md), join(dir, d.md))
|
||||
const zeilen = []
|
||||
const ruf = (...args) => lauf(args, { root: dir, log: (z) => zeilen.push(z), fehler: (z) => zeilen.push(z) })
|
||||
assert.equal(ruf('unterkunft', '--check'), 1)
|
||||
assert.equal(zeilen.filter((z) => z.startsWith('ABWEICHT')).length, 3)
|
||||
assert.equal(existsSync(join(dir, UK[0].html)), false, '--check schreibt nichts')
|
||||
assert.equal(ruf('unterkunft'), 0)
|
||||
assert.equal(ruf('unterkunft', '--check'), 0)
|
||||
for (const d of UK) assert.equal(readFileSync(join(dir, d.html), 'utf8'), lies(d.html), 'dieselben Bytes wie die eingecheckte Seite')
|
||||
// Fassung der Quelle fehlt: laut abbrechen statt eine Seite mit falscher Fassung zu schreiben
|
||||
writeFileSync(join(dir, UK[2].md), lies(UK[2].md).replace(`**Fassung ${UK[2].stempel}**`, '**Fassung ohne**'))
|
||||
assert.throws(() => ruf('unterkunft'), MarkdownFehler)
|
||||
assert.equal(ruf('shop', '--schreiben'), 2)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('unterkunft_pdf_dateien: je Dokument ein PDF mit passender Prüfsumme der HTML-Seite', () => {
|
||||
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
|
||||
const dateien = legalPdfFiles('unterkunft', U)
|
||||
assert.deepEqual(Object.keys(dateien), ['lb', 'avv', 'widerruf'])
|
||||
for (const d of UK) {
|
||||
const f = dateien[d.doc]
|
||||
assert.equal(f.path, join(ROOT, d.html.replace(/\.html$/, '.pdf')))
|
||||
const bytes = readFileSync(f.path)
|
||||
assert.equal(bytes.subarray(0, 5).toString('latin1'), '%PDF-', `${d.doc}: kein PDF`)
|
||||
assert.ok(bytes.length > 20 * 1024, `${d.doc}: PDF mit ${bytes.length} Bytes ist zu klein`)
|
||||
assert.ok(bytes.subarray(-1024).toString('latin1').includes('%%EOF'), `${d.doc}: PDF ist abgeschnitten`)
|
||||
const summe = lies(d.html.replace(/\.html$/, '.pdf.sha256'))
|
||||
assert.match(summe, /^[0-9a-f]{64}\n$/)
|
||||
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 unterkunft)`)
|
||||
}
|
||||
})
|
||||
|
||||
test('unterkunft_script_modus: build-legal-pdfs.sh kennt den Modus unterkunft (Slugs der Registry, Fassung aus config.unterkunft.fassung), Usage nennt ihn', () => {
|
||||
const sh = lies('scripts/build-legal-pdfs.sh')
|
||||
assert.match(sh, /^unterkunft\)$/m)
|
||||
assert.ok(sh.includes('config.unterkunft.fassung') && sh.includes('UNTERKUNFT_VERSION'))
|
||||
const modus = sh.slice(sh.indexOf('\nunterkunft)'), sh.indexOf('\ndatenschutz)'))
|
||||
for (const d of UK) {
|
||||
const slug = /\/([a-z-]+)-\d{4}-\d{2}-\d{2}\.html$/.exec(d.html)[1]
|
||||
assert.ok(modus.includes(slug), `Modus unterkunft baut ${slug}`)
|
||||
}
|
||||
assert.ok(modus.includes('sha256sum "$quelle"') && modus.includes('echo "$summe" > "$ziel.sha256"') && modus.includes('(unveraendert)'))
|
||||
})
|
||||
@@ -346,6 +346,25 @@ test('featureLegalReady("shop") ist true: alle sechs Dateien der Fassung (Leistu
|
||||
}
|
||||
})
|
||||
|
||||
test('featureLegalReady("unterkunft") ist true: alle sechs Dateien der Fassung (Leistungsbeschreibung, AVV, Widerrufsbelehrung als HTML und PDF) sind da (Task 21)', async () => {
|
||||
const U = config.unterkunft.fassung
|
||||
const html = Object.values(legalPaths('unterkunft', U))
|
||||
const pdfs = Object.values(legalPdfFiles('unterkunft', U)).map((x) => x.path)
|
||||
assert.equal(html.length, 3)
|
||||
assert.equal(pdfs.length, 3)
|
||||
for (const p of html) assert.ok(existsSync(root(`public${p}`)), `${p} fehlt`)
|
||||
for (const p of pdfs) assert.ok(existsSync(p), `${p} fehlt`)
|
||||
assert.equal(await featureLegalReady('unterkunft'), true)
|
||||
assert.equal(await featureLegalReady('unterkunft', { fassung: '1999-01-01' }), false)
|
||||
const anhaenge = await featureLegalAttachments('unterkunft')
|
||||
assert.deepEqual(anhaenge.map((a) => a.filename), [
|
||||
`Leistungsbeschreibung-Buchungskalender-${U}.pdf`,
|
||||
`AVV-Buchungskalender-${U}.pdf`,
|
||||
`Widerrufsbelehrung-Muster-Widerrufsformular-${U}.pdf`,
|
||||
])
|
||||
for (const a of anhaenge) assert.equal(a.content.subarray(0, 5).toString('latin1'), '%PDF-')
|
||||
})
|
||||
|
||||
test('Register: leistungsbeschreibung, avv und widerruf des Webshops folgen config.shop.fassung (Drift-Wächter)', () => {
|
||||
const s = featureConfig('shop')
|
||||
const S = config.shop.fassung
|
||||
|
||||
@@ -8,7 +8,7 @@ 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)
|
||||
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
|
||||
const EXPECTED = [...ids('G', 15), ...ids('K', 30), ...ids('T', 21)]
|
||||
const EXPECTED = [...ids('G', 22), ...ids('K', 34), ...ids('T', 22)]
|
||||
// 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)\]/.test(tpl.subject) || id === 'T01' || id === 'T16' || id === 'T17', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz], [Zeit], [Finanzen], [Terminplaner] oder [Webshop] (T16 Funktion bestellt und T17 Repo zuordnen ohne Kennung)')
|
||||
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)')
|
||||
|
||||
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')
|
||||
@@ -1280,3 +1280,75 @@ test('K26: Betreff, Platzhalter, Erklärung nur im Block, Hinweis auf die 14 Tag
|
||||
assert.match(mit.text, /innerhalb von 14 Tagen/)
|
||||
assert.doesNotMatch(renderMail('K26', K26_WERTE, {}).text, /Ich widerrufe|Erklärung:/)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Buchungskalender (Task 20): G16–G22 an Gäste (Sie-Form, neutral), K31–K34 und T22 (Du-Form, WEBklar)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UK_GAST = ['G16', 'G17', 'G18', 'G19', 'G20', 'G21', 'G22']
|
||||
const UK_BASIS = ['gast_name', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'firma', 'farbe', 'anbieter_kontakt']
|
||||
const UK_ERWARTET = {
|
||||
G16: { p: [], b: [] },
|
||||
G17: { p: ['posten_text', 'gesamt', 'frist', 'buchungs_link', 'stornobedingungen', 'nachricht_vermieter'], b: ['nachricht'] },
|
||||
G18: { p: ['nachricht_vermieter'], b: ['nachricht'] },
|
||||
G19: { p: ['posten_text', 'gesamt', 'zahlart_text', 'rechnung_nummer', 'stornobedingungen'], b: ['online_unbezahlt'] },
|
||||
G20: { p: ['gesamt', 'rechnung_nummer'], b: [] },
|
||||
G21: { p: ['frist'], b: [] },
|
||||
G22: { p: ['rechnung_nummer', 'erstattung', 'nachricht_vermieter'], b: ['erstattung', 'nachricht'] },
|
||||
}
|
||||
|
||||
test('G16–G22: Platzhalter und Blöcke, Sie-Form, kein WEBklar-Logo, keine festen Adressen', () => {
|
||||
for (const id of UK_GAST) {
|
||||
const tpl = loadTemplate(id)
|
||||
assert.deepEqual([...tpl.placeholders].sort(), [...UK_BASIS, ...UK_ERWARTET[id].p].sort(), `${id}: Platzhalter`)
|
||||
assert.deepEqual([...tpl.blocks].sort(), [...UK_ERWARTET[id].b].sort(), `${id}: Blöcke`)
|
||||
assert.doesNotMatch(sourceOf(id), /<img|webklar-logo|project\.webklar\.com/i, `${id}: neutral`)
|
||||
for (const on of [true, false]) {
|
||||
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, on))
|
||||
assert.deepEqual(mail.problems, [], id)
|
||||
assert.doesNotMatch(mail.text, DU_FORM, `${id}: Sie-Form\n${mail.text}`)
|
||||
assert.match(mail.html, /background:Wert-farbe/, id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('G17: Preis, Frist, Stornobedingungen, Knopf und der Satz zur verbindlichen Buchung', () => {
|
||||
const mail = renderMail('G17', sampleValues(loadTemplate('G17')), { nachricht: true })
|
||||
for (const w of ['Wert-posten_text', 'Wert-gesamt', 'Wert-frist', 'Wert-stornobedingungen']) assert.ok(mail.text.includes(w), w)
|
||||
assert.match(mail.html, /href="Wert-buchungs_link"[^>]*>Jetzt verbindlich buchen/)
|
||||
assert.ok(mail.text.includes('Erst mit dem Klick auf „Zahlungspflichtig buchen“ auf der Buchungsseite ist Ihre Buchung verbindlich.'))
|
||||
})
|
||||
|
||||
test('G19: dauerhafter Datenträger mit allen Angaben, Hinweis kein Widerrufsrecht, Bezahl-Hinweis nur im Block', () => {
|
||||
const tpl = loadTemplate('G19')
|
||||
const aus = renderMail('G19', sampleValues(tpl), { online_unbezahlt: false })
|
||||
const an = renderMail('G19', sampleValues(tpl), { online_unbezahlt: true })
|
||||
for (const w of ['Wert-posten_text', 'Wert-gesamt', 'Wert-zahlart_text', 'Wert-stornobedingungen', 'Wert-anbieter_kontakt', 'Wert-rechnung_nummer']) assert.ok(aus.text.includes(w), w)
|
||||
assert.match(aus.text, /kein gesetzliches Widerrufsrecht/)
|
||||
assert.ok(!aus.text.includes('Bezahl-Link'))
|
||||
assert.ok(an.text.includes('Den Bezahl-Link finden Sie auf der Buchungsseite aus unserer vorigen E-Mail.'))
|
||||
})
|
||||
|
||||
test('G22: Erstattung „wird veranlasst“ nur im Block', () => {
|
||||
const tpl = loadTemplate('G22')
|
||||
assert.ok(renderMail('G22', { ...sampleValues(tpl), erstattung: '50,00 €' }, { erstattung: true }).text.includes('Eine Erstattung von 50,00 € wird veranlasst.'))
|
||||
assert.ok(!renderMail('G22', sampleValues(tpl), { erstattung: false }).text.includes('Erstattung von'))
|
||||
})
|
||||
|
||||
test('K31–K34, T22: Dateien, Platzhalter, Du-Form', () => {
|
||||
const erwartet = {
|
||||
K31: ['kunde_name', 'gast_name', 'gast_email', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'nachricht_gast', 'portal_url'],
|
||||
K32: ['kunde_name', 'gast_name', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'gesamt', 'zahlart_text', 'portal_url'],
|
||||
K33: ['kunde_name', 'unterkunft', 'portal_url'],
|
||||
K34: ['kunde_name', 'projekt', 'preis', 'ust_hinweis', 'begruendung', 'portal_url'],
|
||||
T22: ['kunde_name', 'email', 'projekt', 'nachricht', 'woms_url'],
|
||||
}
|
||||
for (const [id, p] of Object.entries(erwartet)) {
|
||||
const tpl = loadTemplate(id)
|
||||
assert.deepEqual([...tpl.placeholders].sort(), [...p].sort(), id)
|
||||
const mail = renderMail(id, sampleValues(tpl), allBlocks(tpl, true))
|
||||
assert.deepEqual(mail.problems, [], id)
|
||||
}
|
||||
assert.equal(loadTemplate('K32').file, 'K32 Neue Buchung.html')
|
||||
assert.equal(loadTemplate('T22').subject, '[Buchungskalender] Neue Anfrage: {{kunde_name}}')
|
||||
})
|
||||
@@ -15,5 +15,6 @@ export function useTestEnv({ appwriteUrl, stripeUrl }) {
|
||||
SMTP_HOST: '',
|
||||
SESSION_SECRET: 'test-session-secret-mit-mindestens-32-zeichen',
|
||||
ABO_PILOT_EMAILS: '*',
|
||||
UK_TESTPROTOKOLL: '1',
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
||||
// Produktion. Der Mailtransport ist ein Fake, der Sendeversuche zaehlt und gezielt Fehler wirft (nodemailer-Fehler
|
||||
// tragen `code` und SMTP-`responseCode`). Alle Zeiten sind feste Parameter.
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { termineOutbox: schemaKeys('termineOutbox') },
|
||||
schema: { termineOutbox: schemaKeys('termineOutbox'), ukRechnungen: schemaKeys('ukRechnungen') },
|
||||
datetimes: TERMINE_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
@@ -1290,3 +1290,39 @@ test('einreihen: optionaler Anhang-Verweis wird gespeichert (Senden unverändert
|
||||
const attribut = (await import('../server/services/termine/schema.js')).TERMINE_COLLECTIONS.termineOutbox.attributes.find((a) => a.key === 'anhang')
|
||||
assert.deepEqual(attribut, { key: 'anhang', type: 'string', size: 500, required: false })
|
||||
})
|
||||
|
||||
// Buchungskalender (Task 20): anhang {art:'ukRechnung', rechnungId} -> Rechnungs-PDF als Anhang
|
||||
const RE_INHALT = { aussteller: { firma: 'Haus' }, empfaenger: { name: 'Eva' }, unterkunft: { name: 'Wohnung' }, von: '2026-11-12', bis: '2026-11-14', naechte: 2, personen: 2, posten: [{ text: 'Übernachtung', menge: 2, einzelCent: 10000, ustProzent: 0 }], summen: { bruttoCent: 20000, nettoCent: 20000, ust: [] }, steuerart: 'kleinunternehmer' }
|
||||
const seedRechnung = (extra = {}) => aw.seed('ukRechnungen', { $id: 'r1', featureId: 'f_feature01', anfrageId: 'a1', art: 'rechnung', nummer: 'RE-2026-0001', datum: '2026-11-01', bruttoCent: 20000, inhalt: JSON.stringify(RE_INHALT), ...extra })
|
||||
|
||||
test('Anhang ukRechnung: gesendete Mail hat Rechnung-<nr>.pdf (beginnt mit %PDF); Stornorechnung heißt Stornorechnung-<nr>.pdf', async () => {
|
||||
seedRechnung()
|
||||
seedRechnung({ $id: 'r2', art: 'storno', nummer: 'RE-2026-0002' })
|
||||
await einreihen(eintrag({ bookingId: 'b_re', anhang: JSON.stringify({ art: 'ukRechnung', rechnungId: 'r1' }) }), JETZT)
|
||||
await einreihen(eintrag({ bookingId: 'b_st', anhang: JSON.stringify({ art: 'ukRechnung', rechnungId: 'r2' }) }), JETZT)
|
||||
await einreihen(eintrag({ bookingId: 'b_ohne' }), JETZT)
|
||||
await warteAufVersand()
|
||||
const namen = tr.senke.sent.map((m) => (m.attachments ?? []).map((a) => a.filename))
|
||||
assert.deepEqual(namen.map((n) => n.join()).sort(), ['', 'Rechnung-RE-2026-0001.pdf', 'Stornorechnung-RE-2026-0002.pdf'])
|
||||
const mit = tr.senke.sent.find((m) => m.attachments?.length)
|
||||
assert.equal(Buffer.from(mit.attachments[0].content, 'base64').subarray(0, 4).toString(), '%PDF')
|
||||
assert.equal(mit.attachments[0].contentType, 'application/pdf')
|
||||
})
|
||||
|
||||
test('Anhang nicht erzeugbar: Eintrag bleibt wartet (Wiederholung), nach 3 Fehlversuchen geht die Mail ohne Anhang raus', async () => {
|
||||
const doc = await einreihen(eintrag({ bookingId: 'b_fehlt', art: 'uk-G19', anhang: JSON.stringify({ art: 'ukRechnung', rechnungId: 'gibtsnicht' }) }), JETZT)
|
||||
await warteAufVersand()
|
||||
let d = await lies(doc.$id)
|
||||
assert.equal(d.status, 'wartet')
|
||||
assert.equal(d.versuche, 1)
|
||||
assert.equal(tr.aufrufe, 0)
|
||||
let jetzt = JETZT
|
||||
for (const warte of [2 * MIN, 6 * MIN, 31 * MIN]) {
|
||||
jetzt = plus(warte, jetzt)
|
||||
await sendeEintrag(doc.$id, jetzt)
|
||||
}
|
||||
d = await lies(doc.$id)
|
||||
assert.equal(d.status, 'gesendet')
|
||||
assert.equal(tr.senke.sent.length, 1)
|
||||
assert.equal(tr.senke.sent[0].attachments?.length ?? 0, 0)
|
||||
})
|
||||
@@ -256,7 +256,7 @@ test('Storno: Erstattung scheitert -> storniert, eine Stornorechnung, 502; Wiede
|
||||
assert.equal(a.erstattetCent, 0)
|
||||
assert.equal(aw.docs('ukRechnungen').size, 2)
|
||||
assert.deepEqual(arten(), ['G22'])
|
||||
assert.equal(mails._aufrufe[0].extra.grund, 'g')
|
||||
assert.equal(mails._aufrufe[0].grund, 'g')
|
||||
const r = await ab.storniere(sctx, id, { grund: 'g', erstattungCent: 20000, now: NOW })
|
||||
assert.equal(r.anfrage.erstattetCent, 20000)
|
||||
assert.equal(aw.docs('ukRechnungen').size, 2)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
||||
|
||||
// Buchungskalender Task 20: Mails an Gaeste (ueber die Warteschlange), Vermieter und Team
|
||||
const NAMEN = ['customers', 'ukUnterkuenfte', 'ukPreise', 'ukAnfragen', 'ukRechnungen', 'ukEinstellungen', 'termineOutbox']
|
||||
const aw = await startAppwriteMock({
|
||||
schema: Object.fromEntries(NAMEN.map((n) => [n, n === 'customers' ? ['name', 'email'] : schemaKeys(n)])),
|
||||
datetimes: { ...TERMINE_DATETIMES, ukAnfragen: ['fristAt', 'freigegebenAt', 'gebuchtAt', 'abgelehntAt', 'abgelaufenAt', 'storniertAt', 'bezahltAt'] },
|
||||
defaultLimit: 25,
|
||||
})
|
||||
after(() => aw.close())
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { warteAufVersand, resetOutboxForTests } = await import('../server/services/termine/outbox.js')
|
||||
const mails = await import('../server/services/unterkunft/mails.js')
|
||||
const sp = await import('../server/services/unterkunft/speicher.js')
|
||||
after(() => setMailTransportForTests(null))
|
||||
|
||||
const EIN = { firma: 'Haus am See', strasse: 'Weg 1', plz: '10115', ort: 'Berlin', email: 'vermieter@example.com', telefon: '0123', akzentfarbe: '#336699' }
|
||||
const ctx = { featureId: 'f1', customerId: 'c1', projectId: 'p1', siteKey: 'abcdefghijklmnop', besitzerEmail: 'besitzer@example.com', einstellungen: EIN }
|
||||
const POSTEN = [{ text: 'Übernachtung, 2 Nächte', menge: 2, einzelCent: 10000, ustProzent: 7 }, { text: 'Endreinigung', menge: 1, einzelCent: 5000, ustProzent: 19 }]
|
||||
const anfrage = (extra = {}) => ({
|
||||
$id: 'a1', featureId: 'f1', unterkunftId: 'u1', nummer: 'A-K7QM', von: '2026-11-12', bis: '2026-11-14', personen: 2, status: 'freigegeben',
|
||||
gast: { name: 'Eva Gast', email: 'eva@example.com', nachricht: 'Hallo' }, posten: POSTEN, summen: { bruttoCent: 25000 },
|
||||
fristAt: '2026-11-05T16:00:00.000Z', stornobedingungenText: 'Kostenlos bis 7 Tage vorher.', nachrichtVermieter: '', ...extra,
|
||||
})
|
||||
let sink
|
||||
beforeEach(async () => {
|
||||
for (const c of ['termineOutbox', 'ukRechnungen', 'ukUnterkuenfte', 'ukEinstellungen']) for (const id of aw.docs(c).keys()) await deleteDocument(c, id)
|
||||
config.smtp.from = 'noreply@webklar.com'
|
||||
resetOutboxForTests()
|
||||
mails._aufrufe.length = 0
|
||||
sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: 'f1', name: 'Wohnung See', maxPersonen: 4, mindestNaechte: 1, aktiv: true })
|
||||
})
|
||||
const gesendet = async () => {
|
||||
await warteAufVersand()
|
||||
return sink.sent
|
||||
}
|
||||
|
||||
test('G17: Absender = Firma, Antworten an den Vermieter, Buchungslink mit Token, Preis und Frist', async () => {
|
||||
await mails.anGast('G17', ctx, anfrage(), { token: 'tok123' })
|
||||
const [m] = await gesendet()
|
||||
assert.equal(m.from.name, 'Haus am See')
|
||||
assert.equal(m.replyTo[0].address, 'vermieter@example.com')
|
||||
assert.equal(m.to[0].address, 'eva@example.com')
|
||||
assert.ok(m.text.includes(`${config.publicBaseUrl}/unterkunft/abcdefghijklmnop/buchung/a1?t=tok123`))
|
||||
assert.ok(m.text.includes('250,00 €'))
|
||||
assert.ok(m.text.includes('12.11.2026 – 14.11.2026'))
|
||||
assert.ok(m.text.includes('Kostenlos bis 7 Tage vorher.'))
|
||||
assert.ok(m.text.includes('05.11.2026, 17:00 Uhr'))
|
||||
assert.ok(m.text.includes('Erst mit dem Klick auf „Zahlungspflichtig buchen“'))
|
||||
assert.deepEqual(mails._aufrufe[0], { an: 'gast', art: 'G17', anfrageId: 'a1', hatToken: true, grund: undefined })
|
||||
assert.ok(!JSON.stringify(mails._aufrufe).includes('tok123'))
|
||||
})
|
||||
|
||||
test('Minimaler ctx: Einstellungen und Unterkunft werden selbst geladen', async () => {
|
||||
aw.seed('ukEinstellungen', { $id: 'f1', featureId: 'f1', ...EIN, firma: 'Geladene Firma' })
|
||||
await mails.anGast('G21', { featureId: 'f1' }, anfrage())
|
||||
const [m] = await gesendet()
|
||||
assert.equal(m.from.name, 'Geladene Firma')
|
||||
assert.ok(m.text.includes('Wohnung See'))
|
||||
assert.doesNotMatch(m.text, /buchung\/a1/)
|
||||
})
|
||||
|
||||
test('G17 ohne Token oder ohne siteKey wirft vor dem Einreihen, G16 hat keinen Link', async () => {
|
||||
await assert.rejects(() => mails.anGast('G17', ctx, anfrage(), {}), TypeError)
|
||||
await assert.rejects(() => mails.anGast('G17', { ...ctx, siteKey: '' }, anfrage(), { token: 't' }), TypeError)
|
||||
assert.equal(aw.docs('termineOutbox').size, 0)
|
||||
await mails.anGast('G16', ctx, anfrage({ status: 'neu', posten: null, summen: null }))
|
||||
const [m] = await gesendet()
|
||||
assert.doesNotMatch(m.text, /\?t=/)
|
||||
})
|
||||
|
||||
test('G19 hängt die Rechnung als PDF an (online, unbezahlt: Bezahl-Hinweis), G22 die Stornorechnung mit Erstattung', async () => {
|
||||
const inhalt = { aussteller: { firma: 'Haus am See' }, empfaenger: { name: 'Eva Gast' }, unterkunft: { name: 'Wohnung See' }, von: '2026-11-12', bis: '2026-11-14', naechte: 2, personen: 2, posten: POSTEN, summen: { bruttoCent: 25000, nettoCent: 23000, ust: [] }, steuerart: 'kleinunternehmer', zahlart: 'online' }
|
||||
const re = await sp.legeRechnungAn({ featureId: 'f1', anfrageId: 'a1', art: 'rechnung', nummer: 'RE-2026-0001', datum: '2026-11-01', bruttoCent: 25000, inhalt })
|
||||
const st = await sp.legeRechnungAn({ featureId: 'f1', anfrageId: 'a1', art: 'storno', nummer: 'RE-2026-0002', datum: '2026-11-02', bruttoCent: -25000, inhalt: { ...inhalt, bezugNummer: 'RE-2026-0001' } })
|
||||
await mails.anGast('G19', ctx, anfrage({ status: 'gebucht', zahlart: 'online' }), { rechnung: re })
|
||||
await mails.anGast('G22', ctx, anfrage({ status: 'storniert' }), { stornoRechnung: st, erstattetCent: 25000, grund: 'Wasserschaden' })
|
||||
const sent = await gesendet()
|
||||
const g19 = sent.find((m) => m.subject.includes('bestätigt'))
|
||||
const g22 = sent.find((m) => m.subject.includes('storniert'))
|
||||
assert.equal(g19.attachments.length, 1)
|
||||
assert.equal(g19.attachments[0].filename, 'Rechnung-RE-2026-0001.pdf')
|
||||
assert.ok(Buffer.from(g19.attachments[0].content, 'base64').subarray(0, 4).toString() === '%PDF')
|
||||
assert.ok(g19.text.includes('Den Bezahl-Link finden Sie auf der Buchungsseite'))
|
||||
assert.equal(g22.attachments[0].filename, 'Stornorechnung-RE-2026-0002.pdf')
|
||||
assert.ok(g22.text.includes('Eine Erstattung von 250,00 € wird veranlasst.'))
|
||||
assert.ok(g22.text.includes('Wasserschaden'))
|
||||
})
|
||||
|
||||
test('G22 ohne Erstattung: kein Erstattungssatz; G19 vor Ort bezahlt: kein Bezahl-Hinweis', async () => {
|
||||
await mails.anGast('G22', ctx, anfrage({ status: 'storniert' }), { erstattetCent: 0 })
|
||||
await mails.anGast('G19', ctx, anfrage({ status: 'gebucht', zahlart: 'vorort' }), {})
|
||||
const sent = await gesendet()
|
||||
assert.ok(sent.every((m) => !m.text.includes('Erstattung von') && !m.text.includes('Bezahl-Link')))
|
||||
})
|
||||
|
||||
test('Vermieter: K31 geht an den Besitzer ohne BCC, mit Portal-Link; T22 ans Team; unbekannte Gastart wirft', async () => {
|
||||
await mails.anVermieter('K31', ctx, { anfrage: anfrage({ status: 'neu' }) })
|
||||
const [k] = await gesendet()
|
||||
assert.equal(k.to[0].address, 'besitzer@example.com')
|
||||
assert.ok(!k.bcc || k.bcc.length === 0)
|
||||
assert.ok(k.text.includes(`${config.publicBaseUrl}/dashboard.html#buchungen?projekt=p1`))
|
||||
assert.ok(k.text.includes('Eva Gast'))
|
||||
await assert.rejects(() => mails.anGast('K31', ctx, anfrage()), TypeError)
|
||||
})
|
||||
|
||||
test('Gastname wird in der Mail maskiert', async () => {
|
||||
await mails.anGast('G16', ctx, anfrage({ gast: { name: '<b>x</b>', email: 'eva@example.com' } }))
|
||||
const [m] = await gesendet()
|
||||
assert.ok(!m.html.includes('<b>x</b>'))
|
||||
assert.ok(m.html.includes('<b>x</b>'))
|
||||
})
|
||||
|
||||
test('K31: Kundenname kommt aus dem Kundendokument, wenn ctx keinen hat', async () => {
|
||||
aw.seed('customers', { $id: 'c1', name: 'Mimi Muster', email: 'besitzer@example.com' })
|
||||
await mails.anVermieter('K31', ctx, { anfrage: anfrage({ status: 'neu' }) })
|
||||
const [k] = await gesendet()
|
||||
assert.ok(k.text.includes('Hallo Mimi Muster'))
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { PdfDoc } from '../server/services/unterkunft/pdf.js'
|
||||
import { rechnungPdf } from '../server/services/unterkunft/rechnungPdf.js'
|
||||
|
||||
// Buchungskalender Task 19: PDF-Writer und Rechnungs-PDF
|
||||
const latin = (b) => b.toString('latin1')
|
||||
|
||||
function inhalt(extra = {}) {
|
||||
return {
|
||||
aussteller: { firma: 'Haus Seeblick', inhaber: 'Anna Muster', strasse: 'Seestr. 1', plz: '88662', ort: 'Ueberlingen', email: 'a@x.de', telefon: '0123', steuernummer: '12/345/67890', ustId: '', iban: 'DE02 1203 0000 0000 2020 51', bic: 'BYLADEM1001', bank: 'DKB' },
|
||||
empfaenger: { name: 'Max Gast', firma: '', strasse: 'Weg 2', plz: '10115', ort: 'Berlin', land: 'Deutschland' },
|
||||
unterkunft: { name: 'Ferienwohnung Süd' },
|
||||
von: '2026-11-12', bis: '2026-11-15', naechte: 3, personen: 2,
|
||||
posten: [{ text: 'Übernachtung (Frühstück), 3 Nächte', menge: 3, einzelCent: 9000, ustProzent: 7 }, { text: 'Endreinigung', menge: 1, einzelCent: 5000, ustProzent: 19 }],
|
||||
summen: { bruttoCent: 32000, nettoCent: 29000, ust: [{ satz: 7, nettoCent: 25234, steuerCent: 1766 }, { satz: 19, nettoCent: 4202, steuerCent: 798 }], steuerart: 'regel' },
|
||||
steuerart: 'regel', zahlart: 'vorort', bezugNummer: '', anfrageNummer: 'A-1', ...extra,
|
||||
}
|
||||
}
|
||||
const kleinInhalt = () => inhalt({
|
||||
steuerart: 'kleinunternehmer',
|
||||
posten: [{ text: 'Übernachtung, 3 Nächte', menge: 3, einzelCent: 9000, ustProzent: 0 }],
|
||||
summen: { bruttoCent: 27000, nettoCent: 27000, ust: [], steuerart: 'kleinunternehmer' },
|
||||
})
|
||||
|
||||
test('PdfDoc: Struktur, xref, Kopf und Ende', () => {
|
||||
const d = new PdfDoc()
|
||||
d.text(50, 50, 'Hallo')
|
||||
const b = d.toBuffer()
|
||||
const s = latin(b)
|
||||
assert.ok(s.startsWith('%PDF-1.4'))
|
||||
assert.ok(s.endsWith('%%EOF\n'))
|
||||
const start = Number(/startxref\n(\d+)\n/.exec(s)[1])
|
||||
assert.equal(s.slice(start, start + 4), 'xref')
|
||||
const eintraege = s.slice(start).split('\n').filter((z) => /^\d{10} 00000 n $/.test(z))
|
||||
assert.ok(eintraege.length >= 6)
|
||||
eintraege.forEach((z, k) => {
|
||||
const off = Number(z.slice(0, 10))
|
||||
assert.ok(s.slice(off).startsWith(`${k + 1} 0 obj`), `Objekt ${k + 1}`)
|
||||
})
|
||||
})
|
||||
|
||||
test('PdfDoc: CP1252, Euro, Escaping, Minus', () => {
|
||||
const d = new PdfDoc()
|
||||
d.text(10, 10, 'Übernachtung ß 5 € −3 (a) \\ ☃')
|
||||
const s = latin(d.toBuffer())
|
||||
assert.ok(s.includes('\xdcbernachtung \xdf 5 \x80 -3 \\(a\\) \\\\ ?'))
|
||||
assert.ok(s.includes('/Encoding /WinAnsiEncoding'))
|
||||
})
|
||||
|
||||
test('PdfDoc: Breiten und Umbruch', () => {
|
||||
const d = new PdfDoc()
|
||||
assert.equal(Math.round(d.breite('Hello', 10) * 100), 2278)
|
||||
assert.ok(d.breite('Hello', 10, true) > d.breite('Hello', 10))
|
||||
const z = d.umbrechen('eins zwei drei vier fünf sechs', 60, 10)
|
||||
assert.ok(z.length > 1)
|
||||
z.forEach((x) => assert.ok(d.breite(x, 10) <= 60))
|
||||
assert.ok(d.umbrechen('Donaudampfschifffahrtsgesellschaft', 50, 10).length > 1)
|
||||
assert.ok(d.breite('x', 10) > 0 && d.breite('€', 10) > 0)
|
||||
})
|
||||
|
||||
test('Rechnung: Regelbesteuerung', () => {
|
||||
const s = latin(rechnungPdf(inhalt(), { art: 'rechnung', nummer: 'RE-2026-0001', datum: '2026-11-01' }))
|
||||
assert.ok(s.includes('RE-2026-0001'))
|
||||
assert.ok(s.includes('Enthaltene USt 7 %'))
|
||||
assert.ok(s.includes('Nettobetrag 19 %'))
|
||||
assert.ok(s.includes('12.11.2026'))
|
||||
assert.ok(s.includes('Beherbergung Ferienwohnung S\xfcd'))
|
||||
assert.ok(s.includes('Zahlung vor Ort'))
|
||||
assert.ok(!s.includes('Gem\xe4\xdf \xa7 19 UStG'))
|
||||
assert.ok(s.includes('Steuernummer 12/345/67890'))
|
||||
assert.ok(s.includes('IBAN'))
|
||||
})
|
||||
|
||||
test('Rechnung: Kleinunternehmer und Online-Zahlung', () => {
|
||||
const s = latin(rechnungPdf({ ...kleinInhalt(), zahlart: 'online' }, { art: 'rechnung', nummer: 'RE-2026-0002', datum: '2026-11-01' }))
|
||||
assert.ok(s.includes('Gem\xe4\xdf \xa7 19 UStG wird keine Umsatzsteuer berechnet.'))
|
||||
assert.ok(s.includes('Online-Zahlung'))
|
||||
assert.ok(!s.includes('Enthaltene USt'))
|
||||
})
|
||||
|
||||
test('Storno: Titel mit Bezug, negative Betraege', () => {
|
||||
const neg = kleinInhalt()
|
||||
neg.posten = neg.posten.map((p) => ({ ...p, einzelCent: -p.einzelCent }))
|
||||
neg.summen = { ...neg.summen, bruttoCent: -27000, nettoCent: -27000 }
|
||||
neg.bezugNummer = 'RE-2026-0002'
|
||||
const s = latin(rechnungPdf(neg, { art: 'storno', nummer: 'RE-2026-0003', datum: '2026-11-02' }))
|
||||
assert.ok(s.includes('Stornorechnung zu RE-2026-0002'))
|
||||
assert.ok(s.includes('-270,00 \x80'))
|
||||
})
|
||||
|
||||
test('Viele Posten: Seitenumbruch', () => {
|
||||
const posten = Array.from({ length: 40 }, (_, k) => ({ text: `Posten ${k + 1}`, menge: 1, einzelCent: 1000, ustProzent: 0 }))
|
||||
const s = latin(rechnungPdf({ ...kleinInhalt(), posten }, { art: 'rechnung', nummer: 'RE-2026-0004', datum: '2026-11-01' }))
|
||||
assert.ok(s.includes('/Count 2'))
|
||||
assert.ok(s.includes('Seite 2 von 2'))
|
||||
})
|
||||
|
||||
test('Deterministisch', () => {
|
||||
const o = { art: 'rechnung', nummer: 'RE-2026-0001', datum: '2026-11-01' }
|
||||
assert.ok(rechnungPdf(inhalt(), o).equals(rechnungPdf(inhalt(), o)))
|
||||
})
|
||||
|
||||
test('Beispiel-PDF schreiben (nur wenn Zielordner existiert)', async () => {
|
||||
const { mkdirSync, writeFileSync } = await import('node:fs')
|
||||
const dir = '/root/reports/buchungskalender-2026-10-07'
|
||||
if (process.env.UK_BEISPIEL_PDF !== '1') return
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(`${dir}/beispiel-rechnung.pdf`, rechnungPdf(inhalt(), { art: 'rechnung', nummer: 'RE-2026-0001', datum: '2026-11-01' }))
|
||||
})
|
||||
Reference in new issue
Block a user