Shop J1: Leistungsbeschreibung und AVV Webshop (Fassung 2026-10-05) als Markdown, HTML, PDF und Prüfsumme, Satz/Modus shop in den Build-Skripten, Kanzlei-Fragenliste

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:15:35 +00:00
1 parent b8055b336c
commit 17ad5cbcdc
13 files changed
+1066 -25

No files matched your search

+127 -2
View File
@@ -16,12 +16,14 @@ 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, MarkdownFehler } = await import('../scripts/build-legal-html.mjs')
const { render, parse, lauf, termineDokumente, shopDokumente, MarkdownFehler } = await import('../scripts/build-legal-html.mjs')
const V = config.termine.avvVersion
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const lies = (rel) => readFileSync(join(ROOT, rel), 'utf8')
const DOKUMENTE = termineDokumente(V)
const S = config.shop.fassung
const SHOP = shopDokumente(S)
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'
@@ -357,5 +359,128 @@ 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|datenschutz]'))
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|shop|datenschutz]'))
})
// ---------------------------------------------------------------------------------------------------- Webshop (Task J1)
test('shopDokumente: lb und avv mit Quelle, Ziel und Navigation auf die Fassung; die Widerrufsbelehrung gehört zum Satz termine', () => {
assert.deepEqual(SHOP.map((d) => d.doc), ['lb', 'avv'])
assert.deepEqual(
SHOP.map((d) => [d.md, d.html]),
[
['docs/rechtliches/leistungsbeschreibung-webshop.md', `public/rechtliches/leistungsbeschreibung-webshop-${S}.html`],
['docs/rechtliches/avv-webshop.md', `public/rechtliches/avv-webshop-${S}.html`],
],
)
const adressen = Object.values(legalPaths('shop', S))
assert.equal(adressen.length, 3)
for (const d of SHOP) {
assert.equal(d.meta.fassung, S)
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}`)
}
// die Widerrufsbelehrung der Funktionen wird von shop nie erzeugt (sonst änderte shop die Datei des Satzes termine)
assert.ok(!SHOP.some((d) => d.html.includes('widerrufsbelehrung-funktionen')))
assert.equal(legalPaths('shop', S).widerruf, legalPaths('termine', S).widerruf)
// Standard der Fassung ist config.shop.fassung
assert.deepEqual(shopDokumente(), SHOP)
})
test('shop_html_gleich_generator: die eingecheckten Seiten sind genau render(Markdown); deterministisch; --check Exit 0', () => {
for (const d of SHOP) {
const md = 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 shop)`)
assert.equal(render(md, d.meta), html, 'deterministisch: zweiter Lauf gleiche Bytes')
assert.doesNotMatch(html.replaceAll(S, ''), /\b\d{4}-\d{2}-\d{2}\b/, `${d.html}: Datum außer der Fassung`)
// Kopf und Seitenhülle wie beim Terminplaner
assert.ok(html.includes(`<strong>Fassung ${S}</strong>`))
assert.ok(html.includes(`<meta name="webklar-fassung" content="${S}">`))
assert.ok(html.includes(`content: "WEBklar · ${d.meta.seitenkopf}"`))
assert.doesNotMatch(html, /<!--|Entwurf|ENTWURF|<script|<iframe|<img|<form|<link|\sstyle=|\ssrc=/, `${d.html}: verbotener Inhalt`)
assert.doesNotMatch(html, /⚖|\bQ\d+\b|\bF\d+\b|\{\{|\}\}|undefined|NaN/, `${d.html}: Marker oder Platzhalter`)
assert.equal([...html.matchAll(/<h1>/g)].length, 1)
const hrefs = [...html.matchAll(/\shref="([^"]*)"/g)].map((m) => m[1])
for (const h of hrefs) assert.ok(/^\/(?!\/)/.test(h) || /^https:\/\//.test(h), `${d.html}: Link ${h}`)
}
const lauf = spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', 'shop', '--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)
// der Satz termine bleibt unverändert (drei Seiten)
const termine = spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', 'termine', '--check'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
assert.equal(termine.status, 0, termine.stdout + termine.stderr)
assert.equal([...termine.stdout.matchAll(/^ok /gm)].length, 3)
})
test('shop_lauf: --check meldet fehlende oder abweichende Seiten mit Exit 1, schreibt nur lb und avv; unbekannter Satz: Exit 2 mit Usage', () => {
const dir = mkdtempSync(join(tmpdir(), 'legalhtml-shop-'))
try {
mkdirSync(join(dir, 'docs/rechtliches'), { recursive: true })
mkdirSync(join(dir, 'public/rechtliches'), { recursive: true })
for (const d of SHOP) 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('shop', '--check'), 1)
assert.equal(zeilen.filter((z) => z.startsWith('ABWEICHT')).length, 2)
assert.equal(existsSync(join(dir, SHOP[0].html)), false, '--check schreibt nichts')
assert.equal(ruf('shop'), 0)
assert.equal(ruf('shop', '--check'), 0)
for (const d of SHOP) assert.equal(readFileSync(join(dir, d.html), 'utf8'), lies(d.html), 'dieselben Bytes wie die eingecheckte Seite')
assert.equal(existsSync(join(dir, `public/rechtliches/widerrufsbelehrung-funktionen-${S}.html`)), false, 'shop schreibt die Widerrufsbelehrung nicht')
// Quelle geändert, Seite nicht nachgezogen
writeFileSync(join(dir, SHOP[1].md), `${lies(SHOP[1].md)}\n## 13. Neu\n\n(1) Neuer Absatz.\n`)
zeilen.length = 0
assert.equal(ruf('shop', '--check'), 1)
assert.deepEqual(zeilen.filter((z) => z.startsWith('ABWEICHT')), [`ABWEICHT ${SHOP[1].html}`])
// falscher Aufruf
zeilen.length = 0
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\]/)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
test('shop_pdf_dateien: je Dokument ein PDF unter dem Namen aus legalPdfFiles (mehr als eine Seite, größer als 20 KB), Prüfsumme der HTML-Seite daneben', () => {
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
const dateien = legalPdfFiles('shop', S)
assert.deepEqual(Object.keys(dateien), ['lb', 'avv', 'widerruf'])
for (const d of SHOP) {
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 seiten = bytes.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []
assert.ok(seiten.length >= 2, `${d.doc}: ${seiten.length} Seiten`)
const summe = lies(d.html.replace(/\.html$/, '.pdf.sha256'))
assert.match(summe, /^[0-9a-f]{64}\n$/, '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 shop)`)
}
// die Widerrufsbelehrung ist die gemeinsame Datei (PDF und Prüfsumme bestehen)
assert.ok(readFileSync(dateien.widerruf.path).length > 5 * 1024)
assert.match(lies(`public/rechtliches/widerrufsbelehrung-funktionen-${S}.pdf.sha256`), /^[0-9a-f]{64}\n$/)
})
test('shop_script_modus: build-legal-pdfs.sh kennt den Modus shop (Slugs der Registry, Fassung aus config.shop.fassung), all druckt ihn mit, Usage nennt ihn', () => {
const sh = lies('scripts/build-legal-pdfs.sh')
assert.match(sh, /^shop\)$/m)
assert.ok(sh.includes('config.shop.fassung'), 'die Vorgabe der Fassung kommt aus der Konstante des Servers')
assert.ok(sh.includes('SHOP_VERSION'))
const shop = sh.slice(sh.indexOf('\nshop_pdfs() {'), sh.indexOf('\ncase "$MODE"'))
for (const d of SHOP) {
const slug = /\/([a-z-]+)-\d{4}-\d{2}-\d{2}\.html$/.exec(d.html)[1]
assert.ok(shop.includes(slug), `Modus shop baut ${slug}`)
}
assert.ok(!shop.includes('widerrufsbelehrung'), 'die Widerrufsbelehrung druckt der Modus termine')
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]'))
const syntax = spawnSync('bash', ['-n', 'scripts/build-legal-pdfs.sh'], { cwd: ROOT, encoding: 'utf8' })
assert.equal(syntax.status, 0, syntax.stderr)
})
+18 -6
View File
@@ -291,7 +291,7 @@ test('News: Leistungsbeschreibung folgt der Fassung <F> (Adresse unverändert, k
assert.equal(Object.getOwnPropertyDescriptor(n, 'leistungsbeschreibung').get, undefined)
})
// Webshop (Block A3): eigener Satz mit eigener Fassung config.shop.fassung; die Dateien legt Block J an
// Webshop (Block A3): eigener Satz mit eigener Fassung config.shop.fassung; die Dateien hat Block J1 angelegt
test('Webshop-Satz: Adressen, PDF-Namen und Funktionszuordnung folgen config.shop.fassung', async () => {
const S = config.shop.fassung
assert.match(S, /^\d{4}-\d{2}-\d{2}$/)
@@ -323,15 +323,27 @@ test('Webshop-Satz: Adressen, PDF-Namen und Funktionszuordnung folgen config.sho
assert.deepEqual(featureLegalPaths('termine'), legalPaths('termine', V))
})
test('featureLegalReady("shop") ist true genau dann, wenn alle sechs Dateien der Fassung da sind (bis Block J: false)', async () => {
test('featureLegalReady("shop") ist true: alle sechs Dateien der Fassung (Leistungsbeschreibung, AVV, Widerrufsbelehrung als HTML und PDF) sind da (Block J1)', async () => {
const S = config.shop.fassung
const html = Object.values(legalPaths('shop', S))
const pdfs = Object.values(legalPdfFiles('shop', S)).map((x) => x.path)
const alleDa = html.every((p) => existsSync(root(`public${p}`))) && pdfs.every((p) => existsSync(p))
assert.equal(await featureLegalReady('shop'), alleDa)
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('shop'), true)
assert.equal(await featureLegalReady('shop', { fassung: '1999-01-01' }), false)
// nie ein Wurf bei fehlenden Dateien; die Anhaenge werfen dagegen
if (!alleDa) await assert.rejects(featureLegalAttachments('shop'))
// die Anhaenge der Bestellmail: Leistungsbeschreibung, AVV, Widerrufsbelehrung als echte PDFs
const anhaenge = await featureLegalAttachments('shop')
assert.deepEqual(anhaenge.map((a) => a.filename), [
`Leistungsbeschreibung-Webshop-${S}.pdf`,
`AVV-Webshop-${S}.pdf`,
`Widerrufsbelehrung-Muster-Widerrufsformular-${S}.pdf`,
])
for (const a of anhaenge) {
assert.equal(a.contentType, 'application/pdf')
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)', () => {