Files
Webklar-Kundenbereich/test/auftraegeRechtstexte.test.js
T

591 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
// Rechtstexte der Aufträge und Rechnungen (Strang L7; Spec Abschnitt 8 „Rechtsrunde“, Übersicht V7).
// Geprüft werden die Wortlaute in server/services/auftraege/texte.js gegen die gesetzlichen Muster (docs/rechtliches/quellen/), die Seiten
// und PDFs unter public/rechtliches/ (Satz „auftraege“ von scripts/build-legal-html.mjs und build-legal-pdfs.sh), die AGB-Kopie gegen die
// Website und die Abschnitte der Datenschutzerklärung. Es wird nur gelesen, geschrieben wird nur in temporären Verzeichnissen.
const ROOT = fileURLToPath(new URL('..', import.meta.url))
const lies = (rel) => readFileSync(join(ROOT, rel), 'utf8')
const sha256 = (daten) => createHash('sha256').update(daten).digest('hex')
const norm = (text) => String(text).replace(/\s+/g, ' ').trim()
const { config } = await import('../server/config.js')
const texte = await import('../server/services/auftraege/texte.js')
const { AGB_FASSUNG, AUFTRAG_WEGE } = await import('../server/services/auftraege/konstanten.js')
const { widerrufsbelehrungFunktion } = await import('../server/services/termine/rechtstexte.js')
const gen = await import('../scripts/build-legal-html.mjs')
const T = texte.AB_TEXTE
// ---------------------------------------------------------------------------------------------------------- Exporte und Formen (V7)
const V7_EXPORTE = ['ABSENDER', 'AB_TEXTE', 'BESTAETIGUNG_TEXT', 'FRUEHBEGINN_FASSUNG', 'FRUEHBEGINN_TEXT', 'TEXTE_FASSUNG', 'WIDERRUFSBELEHRUNG_PROJEKT', 'WIDERRUFSFORMULAR']
test('texte_exporte: genau die Exporte und Formen aus V7 der Übersicht, Fassungen im Format JJJJ-MM-TT', () => {
assert.deepEqual(Object.keys(texte).sort(), [...V7_EXPORTE].sort())
for (const fassung of [texte.TEXTE_FASSUNG, texte.FRUEHBEGINN_FASSUNG]) {
assert.match(fassung, /^\d{4}-\d{2}-\d{2}$/)
assert.ok(fassung >= '2026-10-06', `Fassung ${fassung} liegt vor der Rechtsrunde`)
}
assert.deepEqual(texte.ABSENDER, {
name: 'WEBklar GbR', strasse: 'Am Schwimmbad 10', plzOrt: '67722 Winnweiler', telefon: '0176 23726355',
email: 'support@webklar.com', web: 'webklar.com', vertretenDurch: 'Kenso Grimm und Justin Klein',
})
assert.deepEqual(Object.keys(T).sort(), ['agb', 'aufwand', 'einleitung', 'ersetzt', 'gruss', 'kleinunternehmer', 'mitwirkung', 'termine', 'titel', 'zahlungsplan'])
assert.deepEqual(Object.keys(T.zahlungsplan).sort(), ['anzahlung', 'frei', 'monatlich', 'nach_abnahme'])
for (const name of ['einleitung', 'aufwand', 'termine', 'agb', 'ersetzt']) assert.equal(typeof T[name], 'function', name)
for (const name of ['anzahlung', 'frei', 'monatlich', 'nach_abnahme']) assert.equal(typeof T.zahlungsplan[name], 'function', name)
for (const name of ['titel', 'kleinunternehmer', 'mitwirkung', 'gruss']) assert.equal(typeof T[name], 'string', name)
assert.equal(typeof texte.BESTAETIGUNG_TEXT, 'string')
assert.equal(typeof texte.FRUEHBEGINN_TEXT, 'string')
assert.deepEqual(Object.keys(texte.WIDERRUFSBELEHRUNG_PROJEKT), ['titel', 'abschnitte'])
for (const abschnitt of texte.WIDERRUFSBELEHRUNG_PROJEKT.abschnitte) {
assert.deepEqual(Object.keys(abschnitt), ['ueberschrift', 'absaetze'])
assert.ok(abschnitt.absaetze.length > 0 && abschnitt.absaetze.every((s) => typeof s === 'string' && s !== ''))
}
assert.deepEqual(Object.keys(texte.WIDERRUFSFORMULAR), ['titel', 'einleitung', 'zeilen'])
})
// ---------------------------------------------------------------------------------------------------------- Textbausteine der AB
test('ab_texte_einleitung: Datum und Weg der Absprache; Schlüssel aus AUFTRAG_WEGE wird zum Klartext; Pflichtwert fehlt = Fehler', () => {
assert.equal(T.titel, 'Auftragsbestätigung')
assert.equal(
T.einleitung({ datum: '05.10.2026', weg: 'telefonisch' }),
'Vielen Dank für Ihren Auftrag vom 05.10.2026 (telefonisch). Wir bestätigen ihn wie folgt:',
)
for (const [schluessel, klartext] of Object.entries(AUFTRAG_WEGE)) {
const erwartet = `Vielen Dank für Ihren Auftrag vom 05.10.2026 (${klartext}). Wir bestätigen ihn wie folgt:`
assert.equal(T.einleitung({ datum: '05.10.2026', weg: schluessel }), erwartet, schluessel)
assert.equal(T.einleitung({ datum: '05.10.2026', weg: klartext }), erwartet, klartext)
}
assert.equal(T.einleitung({ datum: '05.10.2026', weg: '' }), 'Vielen Dank für Ihren Auftrag vom 05.10.2026. Wir bestätigen ihn wie folgt:')
assert.equal(T.einleitung({ datum: '05.10.2026', weg: 'constructor' }), 'Vielen Dank für Ihren Auftrag vom 05.10.2026 (constructor). Wir bestätigen ihn wie folgt:', 'kein Treffer in der Prototypkette')
assert.throws(() => T.einleitung({ weg: 'telefon' }), TypeError)
assert.throws(() => T.einleitung(), TypeError)
})
test('ab_texte_aufwand: Stundensatz, 15-Minuten-Schritte, Unterstützung (AGB Nr. 9), unverbindliche Schätzung und Zusage nach § 649 BGB (Kostenanschlag) nur mit Schätzung', () => {
const regel = 'Wir rechnen nach Aufwand ab: 100,00 € pro Stunde, erfasst je Arbeitsschritt in Schritten von 15 Minuten; angefangene 15 Minuten werden aufgerundet. '
+ 'Wirkt eine weitere Person unterstützend mit, berechnen wir für sie die Hälfte der Arbeitszeit (AGB Nr. 9).'
const schaetzung = (stunden, wort) => `${regel} Der geschätzte Umfang beträgt etwa ${stunden} ${wort}; die Schätzung ist unverbindlich. Zeichnet sich eine deutliche Überschreitung ab, melden wir uns vorher.`
assert.equal(T.aufwand({ satz: '100,00 €', schaetzung: '10' }), schaetzung('10', 'Stunden'))
assert.equal(T.aufwand({ satz: '100,00 €', schaetzung: '1' }), schaetzung('1', 'Stunde'))
assert.equal(T.aufwand({ satz: '100,00 €', schaetzung: '7,5' }), schaetzung('7,5', 'Stunden'))
// ohne Schätzung gibt es nichts zu überschreiten: weder Schätzung noch Zusage
for (const leer of [null, undefined, '']) assert.equal(T.aufwand({ satz: '100,00 €', schaetzung: leer }), regel)
assert.throws(() => T.aufwand({ schaetzung: '10' }), TypeError)
})
test('ab_texte_kleinunternehmer_mitwirkung_agb_ersetzt_gruss: Wortlaut', () => {
assert.equal(T.kleinunternehmer, 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.')
assert.equal(
T.mitwirkung,
'Mitwirkung und Abnahme richten sich nach Nr. 7 und Nr. 8 unserer Allgemeinen Geschäftsbedingungen. '
+ 'Bitte stellen Sie uns die benötigten Inhalte, Daten und Freigaben rechtzeitig bereit.',
)
assert.equal(
T.agb({ fassung: '30.09.2026' }),
'Es gelten unsere Allgemeinen Geschäftsbedingungen in der Fassung vom 30.09.2026, die dieser Bestätigung beiliegen.',
)
assert.equal(T.ersetzt({ nummer: 'AB-2026-0001', datum: '05.10.2026' }), 'Diese Auftragsbestätigung ersetzt AB-2026-0001 vom 05.10.2026.')
assert.equal(T.gruss, 'Mit freundlichen Grüßen')
assert.throws(() => T.agb({}), TypeError)
assert.throws(() => T.ersetzt({ nummer: 'AB-2026-0001' }), TypeError)
})
test('ab_texte_zahlungsplan: vier Pläne, Zahlungsziel als „innerhalb von n Tagen nach Rechnungsdatum“', () => {
const Z = T.zahlungsplan
const ziel14 = 'innerhalb von 14 Tagen nach Rechnungsdatum'
assert.equal(Z.nach_abnahme({ ziel: 14 }), `Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar ${ziel14}.`)
assert.equal(
Z.anzahlung({ prozent: 50, anzahlung: '400,00 €', rest: '400,00 €', ziel: 14 }),
`Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; zahlbar jeweils ${ziel14}.`,
)
assert.equal(Z.anzahlung({ prozent: 33.5, anzahlung: '335,00 €', rest: '665,00 €', ziel: 7 }), 'Anzahlung 33,5 % (335,00 €) nach Auftragsbestätigung, Rest (665,00 €) nach Abnahme; zahlbar jeweils innerhalb von 7 Tagen nach Rechnungsdatum.')
assert.equal(Z.monatlich({ ziel: 14 }), `Wir stellen den angefallenen Aufwand monatlich in Rechnung; zahlbar ${ziel14}.`)
assert.equal(
Z.frei({ text: 'Je ein Drittel bei Auftrag, zur Halbzeit und nach Abnahme', ziel: 7 }),
'Je ein Drittel bei Auftrag, zur Halbzeit und nach Abnahme. Zahlbar jeweils innerhalb von 7 Tagen nach Rechnungsdatum.',
)
assert.equal(Z.frei({ text: 'Zahlung in zwei Raten.', ziel: 14 }), `Zahlung in zwei Raten. Zahlbar jeweils ${ziel14}.`, 'kein doppelter Punkt')
// Zahlungsziel: 0 = sofort, 1 = Einzahl, Strings und Zahlen, ungültig oder fehlend = 14 Tage
assert.equal(Z.nach_abnahme({ ziel: 0 }), 'Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar sofort nach Rechnungsstellung.')
assert.equal(Z.nach_abnahme({ ziel: 1 }), 'Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar innerhalb von 1 Tag nach Rechnungsdatum.')
assert.equal(Z.nach_abnahme({ ziel: '30' }), 'Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar innerhalb von 30 Tagen nach Rechnungsdatum.')
for (const ungueltig of [undefined, null, '', 'bald', 7.5, -3, NaN]) {
assert.equal(Z.nach_abnahme({ ziel: ungueltig }), `Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar ${ziel14}.`, String(ungueltig))
}
assert.equal(Z.nach_abnahme(), `Wir stellen die Vergütung nach Abnahme in Rechnung; zahlbar ${ziel14}.`)
// Pflichtwerte
assert.throws(() => Z.anzahlung({ prozent: 50, anzahlung: '400,00 €', ziel: 14 }), TypeError)
assert.throws(() => Z.frei({ text: ' ', ziel: 14 }), TypeError)
})
test('ab_texte_termine: Beginn und voraussichtliche Fertigstellung; für Privatpersonen der Satz zum Beginn nach Ablauf der Widerrufsfrist', () => {
const privat = 'Ohne Ihr ausdrückliches Verlangen beginnen wir erst nach Ablauf der Widerrufsfrist.'
assert.equal(T.termine({ start: '08.10.2026', fertig: '31.10.2026' }), 'Beginn: 08.10.2026; voraussichtliche Fertigstellung: 31.10.2026.')
assert.equal(T.termine({ start: '08.10.2026', fertig: null }), 'Beginn: 08.10.2026.')
assert.equal(T.termine({ start: null, fertig: '31.10.2026' }), 'Voraussichtliche Fertigstellung: 31.10.2026.')
assert.equal(T.termine({ start: null, fertig: null }), null)
assert.equal(T.termine({}), null)
assert.equal(T.termine({ start: '08.10.2026', fertig: '31.10.2026', privat: false }), 'Beginn: 08.10.2026; voraussichtliche Fertigstellung: 31.10.2026.')
assert.equal(T.termine({ start: '08.10.2026', fertig: '31.10.2026', privat: true }), `Beginn: 08.10.2026; voraussichtliche Fertigstellung: 31.10.2026. ${privat}`)
assert.equal(T.termine({ start: null, fertig: null, privat: true }), privat, 'der Satz steht auch ohne Termine')
})
test('ab_texte_stil: Sie-Form, keine Gedankenstriche im Fließtext, keine veraltete Zählung, kein „undefined“', () => {
const alle = [
T.titel,
T.einleitung({ datum: '05.10.2026', weg: 'telefon' }),
T.aufwand({ satz: '100,00 €', schaetzung: '10' }),
T.aufwand({ satz: '100,00 €' }),
T.kleinunternehmer,
T.zahlungsplan.nach_abnahme({ ziel: 14 }),
T.zahlungsplan.anzahlung({ prozent: 50, anzahlung: '400,00 €', rest: '400,00 €', ziel: 14 }),
T.zahlungsplan.monatlich({ ziel: 14 }),
T.zahlungsplan.frei({ text: 'Je ein Drittel', ziel: 7 }),
T.termine({ start: '08.10.2026', fertig: '31.10.2026', privat: true }),
T.mitwirkung,
T.agb({ fassung: '30.09.2026' }),
T.ersetzt({ nummer: 'AB-2026-0001', datum: '05.10.2026' }),
T.gruss,
texte.BESTAETIGUNG_TEXT,
texte.FRUEHBEGINN_TEXT,
]
for (const text of alle) {
assert.doesNotMatch(text, /[–—]/, `Gedankenstrich: ${text}`)
assert.doesNotMatch(text, /\bdein|\bdu\b|\bdir\b|\bdich\b/i, `Du-Form: ${text}`)
assert.doesNotMatch(text, /undefined|null|NaN|\{\{|\}\}/, `Platzhalter: ${text}`)
assert.doesNotMatch(text, /Abs\. 4/, `veraltete Zählung: ${text}`)
}
})
// ---------------------------------------------------------------------------------------------------------- Bestätigung und Frühbeginn
test('bestaetigung_text: Häkchen im Wortlaut der Spezifikation (Alternative mit Bezug auf die AGB ist Entscheidung E2 der Rechtsrunde)', () => {
assert.equal(texte.BESTAETIGUNG_TEXT, 'Ich bestätige den Auftrag zu diesen Bedingungen.')
})
const NORMEN = lies('docs/rechtliches/quellen/normen-auftraege-2026-10-06.txt')
test('normen_zaehlung: § 356 Abs. 5 Nr. 2 BGB regelt das Erlöschen bei entgeltlichen Dienstleistungen (Abs. 4 ist die Höchstfrist), § 357a Abs. 2 BGB den Wertersatz', () => {
const n = norm(NORMEN)
// Abs. 4 = Höchstfrist, kein Erlöschen durch Leistung
assert.ok(n.includes('(4) Das Widerrufsrecht erlischt spätestens zwölf Monate und 14 Tage nach dem in Absatz 2 oder § 355 Absatz 2 Satz 2 genannten Zeitpunkt.'))
// Abs. 5 Nr. 2 = Vertrag mit Preis: Zustimmung (a), bei Verträgen außerhalb von Geschäftsräumen Datenträger (b), Kenntnis (c)
const abs5 = n.slice(n.indexOf('(5) Das Widerrufsrecht erlischt bei Verträgen über die Erbringung von Dienstleistungen'), n.indexOf('(6) Das Widerrufsrecht erlischt bei Verträgen über die Bereitstellung'))
assert.ok(abs5.length > 500, 'Absatz 5 fehlt in der Quelle')
const nr2 = abs5.slice(abs5.indexOf('2. bei einem Vertrag, der den Verbraucher zur Zahlung eines Preises verpflichtet'), abs5.indexOf('3. bei einem Vertrag, bei dem der Verbraucher den Unternehmer ausdrücklich aufgefordert hat'))
for (const teil of [
'mit der vollständigen Erbringung der Dienstleistung, wenn der Verbraucher vor Beginn der Erbringung',
'a) ausdrücklich zugestimmt hat, dass der Unternehmer mit der Erbringung der Dienstleistung vor Ablauf der Widerrufsfrist beginnt,',
'b) bei einem außerhalb von Geschäftsräumen geschlossenen Vertrag die Zustimmung nach Buchstabe a auf einem dauerhaften Datenträger übermittelt hat und',
'c) seine Kenntnis davon bestätigt hat, dass sein Widerrufsrecht mit vollständiger Vertragserfüllung durch den Unternehmer erlischt,',
]) assert.ok(nr2.includes(teil), `§ 356 Abs. 5 Nr. 2: ${teil}`)
// Nr. 1 betrifft unentgeltliche Verträge, Abs. 6 die Bereitstellung digitaler Inhalte: beides passt nicht zum Projektauftrag
assert.ok(abs5.includes('1. bei einem Vertrag, der den Verbraucher nicht zur Zahlung eines Preises verpflichtet'))
// § 357a Abs. 2: Wertersatz für Dienstleistungen mit Preis, ausdrückliches Verlangen als Voraussetzung
assert.ok(n.includes('(2) Der Verbraucher hat Wertersatz für die bis zum Widerruf erbrachten Dienstleistungen, für die der Vertrag die Zahlung eines Preises vorsieht'))
assert.ok(n.includes('1. der Verbraucher von dem Unternehmer ausdrücklich verlangt hat, dass mit der Leistung vor Ablauf der Widerrufsfrist begonnen werden soll,'))
assert.ok(n.includes('Bei der Berechnung des Wertersatzes ist der vereinbarte Gesamtpreis zu Grunde zu legen.'))
})
test('normen_kostenanschlag: § 649 BGB ist der Kostenanschlag (Abs. 2: unverzügliche Anzeige), § 650 BGB regelt etwas anderes', () => {
const n = norm(NORMEN)
const block = (nr) => {
const a = n.indexOf(`Quelle: https://www.gesetze-im-internet.de/bgb/__${nr}.html`)
assert.ok(a >= 0, `§ ${nr} fehlt im Nachweis`)
const b = n.indexOf('Quelle: ', a + 10)
return n.slice(a, b < 0 ? undefined : b)
}
const kostenanschlag = block(649)
assert.ok(kostenanschlag.includes('§ 649 Kostenanschlag'), 'amtliche Überschrift von § 649')
assert.ok(kostenanschlag.includes('(1) Ist dem Vertrag ein Kostenanschlag zugrunde gelegt worden'))
assert.ok(kostenanschlag.includes('(2) Ist eine solche Überschreitung des Anschlags zu erwarten, so hat der Unternehmer dem Besteller unverzüglich Anzeige zu machen.'))
// § 650 trägt eine andere Überschrift; der Kostenanschlag kommt in seinem Gesetzestext nicht vor
const werklieferung = block(650)
assert.ok(werklieferung.includes('§ 650 Werklieferungsvertrag; Verbrauchervertrag über die Herstellung digitaler Produkte'))
assert.ok(!werklieferung.slice(werklieferung.indexOf('§ 650 Werklieferungsvertrag')).includes('Kostenanschlag'))
// das Ergebnis der Prüfung vom 06.10.2026 steht mit dem Zitat im Kopf der Datei
assert.ok(n.includes('Der Kostenanschlag steht in § 649 BGB (amtliche Überschrift „Kostenanschlag“).'))
assert.ok(n.includes('ein Verweis auf „§ 650“ für den Kostenanschlag ist falsch'))
assert.doesNotMatch(lies('server/services/auftraege/texte.js'), /§ 650/, 'texte.js nennt § 650 nicht')
})
test('normen_kundenseiten: Widerruf formfrei (§ 355 Abs. 1), Widerrufsfunktion und Eingangsbestätigung (§ 356a Abs. 2 und 4), digitale Produkte (§ 650 Abs. 2)', () => {
const n = norm(NORMEN)
assert.ok(n.includes('Der Widerruf erfolgt durch Erklärung gegenüber dem Unternehmer. Aus der Erklärung muss der Entschluss des Verbrauchers zum Widerruf des Vertrags eindeutig hervorgehen.'))
assert.ok(n.includes('2. Angaben zur Identifizierung des Vertrags oder des Teils des Vertrags, den der Verbraucher widerrufen möchte,'))
assert.ok(n.includes('(4) Der Unternehmer hat dem Verbraucher, wenn dieser die Bestätigungsfunktion aktiviert hat, auf einem dauerhaften Datenträger unverzüglich eine Eingangsbestätigung zu übermitteln, die zumindest den Inhalt der Widerrufserklärung nach Absatz 2 sowie das Datum und die Uhrzeit ihres Eingangs enthält.'))
assert.ok(n.includes('(2) Auf einen Verbrauchervertrag, bei dem der Unternehmer sich verpflichtet, 1. digitale Inhalte herzustellen,'))
assert.ok(n.includes('sind die §§ 633 bis 639 über die Rechte bei Mängeln sowie § 640 über die Abnahme nicht anzuwenden.'))
})
// Zeichen, die Figtree Regular und SemiBold haben (die Schrift des PDFs, server/assets/fonts/ von Strang L4): Basic Latin, Umlaute, ß, § und die
// typografischen Zeichen der Texte. Geprüft am 06.10.2026 mit fontkit (hasGlyphForCodePoint, @expo-google-fonts/figtree 0.4.1, beide Schnitte).
const FIGTREE_ZEICHEN = new Set([
...Array.from({ length: 95 }, (_, i) => String.fromCodePoint(0x20 + i)),
...'äöüÄÖÜß§€–—„“‚‘’”…−•→',
String.fromCodePoint(0xa0),
'\n',
])
test('zeichenvorrat_figtree: texte.js enthält nur Zeichen der PDF-Schrift Figtree (sonst ersetzt das PDF still ein Zeichen und weicht vom freigegebenen Wortlaut ab)', () => {
const fremd = [...new Set(lies('server/services/auftraege/texte.js'))].filter((zeichen) => !FIGTREE_ZEICHEN.has(zeichen))
const bezeichnet = fremd.map((zeichen) => `U+${zeichen.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')} ${zeichen}`)
assert.deepEqual(bezeichnet, [], 'Zeichen außerhalb des geprüften Zeichenvorrats (auch in Kommentaren): vor der Aufnahme gegen Figtree prüfen und FIGTREE_ZEICHEN ergänzen; der Wächter-Test von L4 (auftraegePdfDaten) prüft nach dem Merge gegen die Schrift selbst')
})
test('fruehbeginn_text: Verlangen, Kenntnis vom Erlöschen und Wertersatz in einem Satzgefüge; zitiert § 356 Abs. 5 Nr. 2 und § 357a Abs. 2 BGB', () => {
assert.equal(texte.FRUEHBEGINN_TEXT,
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Ausführung dieses Projektauftrags beginnt. '
+ 'Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). '
+ 'Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).')
const saetze = texte.FRUEHBEGINN_TEXT.split(/(?<=\.) (?=Mir ist bekannt|Widerrufe ich)/)
assert.equal(saetze.length, 3, 'drei Sätze: Verlangen, Kenntnis, Wertersatz')
// Verlangen (§ 357a Abs. 2 Nr. 1) und Zustimmung (§ 356 Abs. 5 Nr. 2 Buchstabe a): ausdrücklich, vor Ablauf der Widerrufsfrist, für diesen Auftrag
assert.ok(saetze[0].startsWith('Ich verlange ausdrücklich') && saetze[0].includes('vor Ablauf der Widerrufsfrist') && saetze[0].includes('dieses Projektauftrags'))
// Kenntnis (§ 356 Abs. 5 Nr. 2 Buchstabe c): Erlöschen mit vollständiger Erbringung
assert.ok(saetze[1].startsWith('Mir ist bekannt') && saetze[1].includes('erlischt') && saetze[1].includes('vollständigen Erbringung'))
// Wertersatz (§ 357a Abs. 2): angemessener Betrag für die bis dahin erbrachte Leistung
assert.ok(saetze[2].includes('angemessenen Betrag') && saetze[2].includes('bis dahin erbrachte Leistung'))
assert.doesNotMatch(texte.FRUEHBEGINN_TEXT, /§ 356 Abs\. 4|§ 356 Abs\. 6|§ 357 Abs\. 8/)
// dasselbe Satzende wie bei den Funktionen (News, Terminplaner, Webshop, Öffnungszeiten): gleiche Normen, gleiche Wertersatz-Formel
const ende = '(§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).'
assert.ok(texte.FRUEHBEGINN_TEXT.endsWith(ende))
assert.ok(config.funktionen.consentText.endsWith(ende), 'die Erklärung der Funktionen hat dasselbe Satzende')
// beide Zitate stehen in der Quelle der Normen
assert.ok(norm(NORMEN).includes('Quelle: https://www.gesetze-im-internet.de/bgb/__356.html'))
assert.ok(norm(NORMEN).includes('Quelle: https://www.gesetze-im-internet.de/bgb/__357a.html'))
})
// ---------------------------------------------------------------------------------------------------------- Widerrufsbelehrung und Formular
const ANLAGE_1 = lies('docs/rechtliches/quellen/anlage-1.txt')
const ANLAGE_2 = lies('docs/rechtliches/quellen/anlage-2.txt')
const zeilenVon = (text) => text.split('\n').map((z) => z.trim())
const KONTAKT = 'WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com'
const B = texte.WIDERRUFSBELEHRUNG_PROJEKT
const F = texte.WIDERRUFSFORMULAR
/** Die Belehrung als ein Text: Titel, Überschriften und Absätze in Dokumentreihenfolge. */
const belehrungText = () => [B.titel, ...B.abschnitte.flatMap((a) => [a.ueberschrift, ...a.absaetze])].join('\n')
test('belehrung_muster: jeder Absatz der Anlage 1 steht wörtlich darin, nur die Lücken sind gefüllt (Frist „des Vertragsabschlusses.“, Kontaktangaben)', () => {
const z = zeilenVon(ANLAGE_1)
const von = z.indexOf('Widerrufsbelehrung')
const bis = z.findIndex((s) => s.startsWith('Gestaltungshinweise:'))
assert.ok(von >= 0 && bis > von, 'Aufbau von anlage-1.txt hat sich geändert')
const muster = z.slice(von, bis).filter(Boolean)
assert.equal(muster.length, 8, 'Anlage 1: Titel, Widerrufsrecht, 3 Absätze, Folgen des Widerrufs und 1 Absatz')
const frist = /Dienstleistungsvertrags[^„]*„([^“]+)“/.exec(ANLAGE_1)?.[1]
assert.equal(frist, 'des Vertragsabschlusses.', 'Textbaustein a) für Dienstleistungsverträge')
const text = belehrungText()
for (const zeile of muster) {
const erwartet = zeile.replace('ab dem Tag .', `ab dem Tag ${frist}`).replace('uns ( )', `uns (${KONTAKT})`)
assert.ok(text.includes(erwartet), `Absatz fehlt oder ist verändert: ${erwartet}`)
}
})
test('belehrung_widerrufsfunktion: Satzrahmen der Anlage wörtlich, die Lücke „…“ ist die Adresse der Widerrufsseite', () => {
const m = /„(Sie können Ihr Widerrufsrecht auch online unter) … \[[^\]]+\] (ausüben\.) (Wenn Sie diese Online-Funktion nutzen, [^“]+?)\s*“/.exec(ANLAGE_1)
assert.ok(m, 'Gestaltungshinweis zur Widerrufsfunktion fehlt oder hat sich geändert in anlage-1.txt')
assert.ok(m[3].includes('Eingangsbestätigung') && m[3].includes('Uhrzeit ihres Eingangs'))
const absatz = `${m[1]} https://project.webklar.com/widerrufen.html ${m[2]} ${m[3]}`
assert.ok(belehrungText().includes(absatz), 'Absatz zur Widerrufsfunktion fehlt oder weicht vom Satzrahmen der Anlage ab')
// die Widerrufsseite kennt die Vertragsart „Projektauftrag“ (L5/L6); die Adresse ist dieselbe wie bei den Funktionen
assert.equal(belehrungText().split('https://project.webklar.com/widerrufen.html').length - 1, 1)
})
test('belehrung_wertersatz: Wertersatz für Dienstleistungen, Wasser/Gas/Strom/Fernwärme gestrichen, keine Bausteine zu Waren', () => {
const m = /Haben Sie verlangt, dass die Dienstleistungen oder Lieferung von Wasser\/Gas\/Strom\/Fernwärme \[Unzutreffendes streichen\] (während[^“]+?)\s*“/.exec(ANLAGE_1)
assert.ok(m, 'Wertersatz-Absatz fehlt in anlage-1.txt')
const text = belehrungText()
assert.ok(text.includes(`Haben Sie verlangt, dass die Dienstleistungen ${m[1]}`), 'Wertersatz-Absatz fehlt oder weicht ab')
assert.doesNotMatch(text, /Wasser|Gas\b|Strom|Fernwärme|\[Unzutreffendes|Rücksendung|in Besitz genommen|Wertverlust/)
})
test('formular_muster: Muster-Widerrufsformular der Anlage 2 wörtlich, Unternehmer in der „An“-Zeile, (*)-Alternativen unverändert', () => {
const z = zeilenVon(ANLAGE_2)
const start = z.indexOf('Muster-Widerrufsformular')
assert.ok(start >= 0, 'Aufbau von anlage-2.txt hat sich geändert')
const inhalt = z.slice(start).filter((s) => s && s !== '–')
assert.equal(inhalt.length, 10, 'Anlage 2: Titel, Einleitung, 7 Zeilen und der Hinweis (*)')
const zeilen = [F.titel, F.einleitung, ...F.zeilen.map((s) => s.replace(/^– /, ''))]
let anZeilen = 0
for (const zeile of inhalt) {
if (zeile.startsWith('An [hier ist')) {
anZeilen += 1
assert.ok(zeilen.includes('An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:'), '„An“-Zeile mit dem Unternehmer fehlt')
} else {
assert.ok(zeilen.includes(zeile), `Formularzeile fehlt oder ist verändert: ${zeile}`)
}
}
assert.equal(anZeilen, 1)
assert.equal(F.titel, 'Muster-Widerrufsformular')
// Aufzählungszeichen des Musters vor jeder Formularzeile, der Hinweis (*) steht zuletzt ohne Zeichen
assert.ok(F.zeilen.slice(0, -1).every((s) => s.startsWith('– ')))
assert.equal(F.zeilen.at(-1), '(*) Unzutreffendes streichen.')
})
test('belehrung_gleich_funktionen: Belehrung und Formular sind wörtlich die Belehrung für die Bestellung einer Funktion', () => {
const absaetze = [
B.titel,
...B.abschnitte.flatMap((a) => [a.ueberschrift, ...a.absaetze]),
F.titel,
F.einleitung,
F.zeilen.slice(0, -1).join('\n'),
F.zeilen.at(-1),
]
assert.equal(absaetze.join('\n\n'), widerrufsbelehrungFunktion())
assert.deepEqual(B.abschnitte.map((a) => a.ueberschrift), ['Widerrufsrecht', 'Folgen des Widerrufs'])
})
// ---------------------------------------------------------------------------------------------------------- AGB-Kopie
const AGB_MD = 'docs/rechtliches/agb.md'
// Quelle der Website-AGB auf dem Server (Repository webklar.com). Fehlt die Datei (anderer Rechner, Container), entfällt nur der Vergleich mit der Website.
const AGB_WEBSITE = process.env.WEBKLAR_AGB_QUELLE || '/home/webklar/apps/webklar.com/build/src/pages/AGB.tsx'
// SHA-256 des normierten Klartexts der Website-AGB vom 30.09.2026 (Commit 72f278b der Website; Titelblock und Nr. 1 bis 14). Ändert sich der Text der Kopie,
// braucht sie eine neue Fassung: AGB_FASSUNG hochziehen, agb.md ändern, Seite und PDF neu erzeugen, diesen Hash eintragen.
const AGB_TEXT_SHA256 = 'f3374352ef97e6050bb0908c1b4abd806f759851b5b85fa9d030512d46b36659'
/** Textknoten der Kopie: alles nach der Fassungszeile, ohne Überschriftszeichen, Aufzählungszeichen, Absatznummern „(1)“ und Fettdruck. */
function agbKnotenAusMarkdown(md) {
const zeilen = md.replace(/\r/g, '').split('\n')
const start = zeilen.findIndex((z) => z.startsWith('**Fassung '))
assert.ok(start > 0, 'agb.md: Fassungszeile fehlt')
return zeilen
.slice(start + 1)
.filter((z) => z.trim() !== '')
.map((z) => z.replace(/^#{1,3} /, '').replace(/^- /, '').replace(/^\(\d+\) /, '').replace(/\*\*/g, '').replace(/\s+/g, ' ').trim())
}
/** Textknoten der Website-Seite AGB.tsx: vom Seitentitel bis zum Ende des Vertragstexts, ohne JSX-Kommentare und Markup. */
function agbKnotenAusWebsite(tsx) {
const von = tsx.indexOf('<h1 className="wk-seite__titel">')
const bis = tsx.indexOf('</article>')
assert.ok(von > 0 && bis > von, 'AGB.tsx: Aufbau hat sich geändert (Seitentitel oder article nicht gefunden)')
return tsx
.slice(von, bis)
.replace(/\{\/\*[\s\S]*?\*\/\}/g, ' ')
.replace(/\{" "\}/g, ' ')
.replace(/<[^>]*>/g, '\n')
.split('\n')
.map((z) => z.replace(/\s+/g, ' ').trim())
.filter(Boolean)
}
const agbText = (knoten) => norm(knoten.join(' '))
/** Die erste Stelle, an der zwei Texte auseinanderlaufen, mit etwas Umgebung (für die Fehlermeldung). */
const ersterUnterschied = (a, b) => {
let i = 0
while (i < a.length && i < b.length && a[i] === b[i]) i += 1
return `Kopie: „…${a.slice(Math.max(0, i - 40), i + 60)}…“, Website: „…${b.slice(Math.max(0, i - 40), i + 60)}…“`
}
test('agb_fassung: AGB_FASSUNG ist der 30.09.2026, der Tag der letzten Textänderung der Website-AGB (Commit 72f278b); die Kopie trägt dieselbe Fassung', () => {
assert.equal(AGB_FASSUNG, '2026-09-30')
assert.ok(lies(AGB_MD).startsWith(`# Allgemeine Geschäftsbedingungen\n\n**Fassung ${AGB_FASSUNG}**`))
})
test('agb_md_aufbau: vierzehn Abschnitte in Reihenfolge; die Verweise der Auftragsbestätigung auf Nr. 7, 8 und 9 treffen', () => {
const md = lies(AGB_MD)
const abschnitte = md.split('\n').filter((z) => /^## \d+\. /.test(z)).map((z) => z.replace(/^## /, ''))
assert.deepEqual(abschnitte.map((z) => Number.parseInt(z, 10)), Array.from({ length: 14 }, (_, i) => i + 1))
const titel = Object.fromEntries(abschnitte.map((z) => [Number.parseInt(z, 10), z.replace(/^\d+\. /, '')]))
assert.equal(titel[7], 'Mitwirkungspflichten des Kunden')
assert.equal(titel[8], 'Abnahme')
assert.equal(titel[9], 'Vergütung und Zahlung')
assert.ok(T.mitwirkung.includes('Nr. 7 und Nr. 8'))
assert.ok(T.aufwand({ satz: '100,00 €' }).includes('(AGB Nr. 9)'))
const nr9 = md.slice(md.indexOf('## 9. '), md.indexOf('## 10. '))
assert.ok(nr9.includes('in Schritten von 15 Minuten') && nr9.includes('die Hälfte der Arbeitszeit'), 'Nr. 9 enthält die Regel, auf die die Auftragsbestätigung verweist')
// der Titelblock der Website steht als Überschrift und Parteien, die Verweise auf die Abos bleiben Text
assert.ok(md.includes('## Kaufvertrag – WEBklar (Modularer Projektvertrag)'))
assert.ok(md.includes('**zwischen**') && md.includes('**und**'))
})
test('agb_text_pin: der normierte Text der Kopie hat den festgehaltenen SHA-256 (keine versehentliche Änderung eines Rechtstextes)', () => {
const hash = sha256(agbText(agbKnotenAusMarkdown(lies(AGB_MD))))
assert.equal(hash, AGB_TEXT_SHA256, `Der Text der AGB-Kopie hat sich geändert (Hash jetzt ${hash}). Änderungen nur mit neuer Fassung: AGB_FASSUNG hochziehen, Seite und PDF neu erzeugen.`)
})
test('agb_text_gleich_website: die Kopie ist wortgleich mit der Seite AGB.tsx der Website (Titelblock und Nr. 1 bis 14)', (t) => {
if (!existsSync(AGB_WEBSITE)) return t.skip(`Website-AGB nicht erreichbar: ${AGB_WEBSITE}`)
const website = agbText(agbKnotenAusWebsite(readFileSync(AGB_WEBSITE, 'utf8')))
const kopie = agbText(agbKnotenAusMarkdown(lies(AGB_MD)))
assert.ok(kopie === website, `Die AGB-Kopie weicht von den AGB der Website ab (${ersterUnterschied(kopie, website)}). Ist die Website neu, braucht die Kopie eine neue Fassung (AGB_FASSUNG, agb.md, Seite, PDF, Hash).`)
assert.equal(sha256(website), AGB_TEXT_SHA256)
})
// ---------------------------------------------------------------------------------------------------------- Seiten der Widerrufsbelehrung und der AGB
// jedes Dokument wird in den Tests neu geholt, damit ein fehlender Generator nur die betroffenen Tests scheitern lässt (nicht das Laden der Datei)
const dokumente = () => gen.auftraegeDokumente()
const klartext = (html) =>
html
.replace(/<style>[\s\S]*?<\/style>/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim()
const quelleVon = (d) => (d.markdown ? d.markdown() : lies(d.md))
test('auftraegeDokumente: widerruf (aus texte.js) und agb (aus docs/rechtliches/agb.md) mit Quelle, Ziel und Navigation auf die Fassungen', () => {
const DOKUMENTE = dokumente()
assert.deepEqual(DOKUMENTE.map((d) => d.doc), ['widerruf', 'agb'])
assert.deepEqual(
DOKUMENTE.map((d) => [d.md, d.html]),
[
[null, `public/rechtliches/widerrufsbelehrung-projektauftrag-${texte.TEXTE_FASSUNG}.html`],
['docs/rechtliches/agb.md', `public/rechtliches/agb-${AGB_FASSUNG}.html`],
],
)
assert.deepEqual(DOKUMENTE.map((d) => d.meta.fassung), [texte.TEXTE_FASSUNG, AGB_FASSUNG])
const adressen = DOKUMENTE.map((d) => d.html.replace(/^public/, ''))
for (const d of DOKUMENTE) {
const hrefs = d.meta.nav.map((l) => l.href)
for (const a of adressen) assert.ok(hrefs.includes(a), `Navigation von ${d.doc} verlinkt ${a}`)
assert.ok(hrefs.includes('/abo-bedingungen.html') && hrefs.includes('/datenschutz.html'))
}
// andere Fassungen wirken auf Dateinamen und Meta; die Quelle der Belehrung folgt der Fassung
const andere = gen.auftraegeDokumente('2027-01-02', '2027-01-03')
assert.equal(andere[0].html, 'public/rechtliches/widerrufsbelehrung-projektauftrag-2027-01-02.html')
assert.equal(andere[1].html, 'public/rechtliches/agb-2027-01-03.html')
assert.ok(andere[0].markdown().includes('**Fassung 2027-01-02**'))
})
test('auftraege_html_gleich_generator: die eingecheckten Seiten sind genau render(Quelle), deterministisch, ohne Datum außer den Fassungen; --check Exit 0', () => {
const DOKUMENTE = dokumente()
for (const d of DOKUMENTE) {
const md = quelleVon(d)
const html = gen.render(md, d.meta)
assert.equal(lies(d.html), html, `${d.html} weicht vom Generator ab (node scripts/build-legal-html.mjs auftraege)`)
assert.equal(gen.render(md, d.meta), html, 'deterministisch: zweiter Lauf gleiche Bytes')
assert.doesNotMatch(html.replaceAll(texte.TEXTE_FASSUNG, '').replaceAll(AGB_FASSUNG, ''), /\b\d{4}-\d{2}-\d{2}\b/, `${d.html}: Datum außer den Fassungen`)
}
const lauf = spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', 'auftraege', '--check'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
assert.equal(lauf.status, 0, lauf.stdout + lauf.stderr)
assert.equal([...lauf.stdout.matchAll(/^ok /gm)].length, 2)
})
test('auftraege_html_text_gleich_quelle: der sichtbare Text jeder Seite ist genau der Text der Quelle (plus Navigation und Marke)', () => {
const DOKUMENTE = dokumente()
for (const d of DOKUMENTE) {
const aus = quelleVon(d).replace(/\r/g, '').split('\n').filter((z) => z.trim() !== '').map((z) => z.replace(/^#{1,3} /, '').replace(/^- /, ''))
const erwartet = norm(aus.join(' ').replace(/\*\*|`/g, ''))
const html = lies(d.html)
const main = html.slice(html.indexOf('<main>'), html.indexOf('</main>')).replace(/<nav class="top">[\s\S]*?<\/nav>/, '').replace('<div class="brand">WEBKLAR</div>', '')
assert.equal(klartext(main), erwartet, `${d.html}: sichtbarer Text weicht von der Quelle ab`)
}
})
test('auftraege_html_sauber: keine Kommentare, Entwurfsmarken, Skripte oder Ereignis-Attribute; Fassung im Kopf; Links nur relativ oder https', () => {
const DOKUMENTE = dokumente()
for (const d of DOKUMENTE) {
const html = lies(d.html)
assert.doesNotMatch(html, /<!--|Entwurf|ENTWURF|Prüfpunkt|<script|<iframe|<img|<form|<link|\sstyle=|\ssrc=/, `${d.html}: verbotener Inhalt`)
assert.doesNotMatch(html, /\son[a-z]+\s*=/i, `${d.html}: Ereignis-Attribut`)
assert.doesNotMatch(html, /⚖|\bQ\d+\b|\{\{|\}\}|undefined|NaN/, `${d.html}: Marker oder Platzhalter`)
assert.ok(html.includes(`<strong>Fassung ${d.meta.fassung}</strong>`), `${d.html}: Fassung fehlt im Kopf`)
assert.ok(html.includes(`<meta name="webklar-fassung" content="${d.meta.fassung}">`))
assert.match(html, /<html lang="de">/)
assert.match(html, /<title>[^<]+ – WEBklar<\/title>/)
const hrefs = [...html.matchAll(/\shref="([^"]*)"/g)].map((m) => m[1])
assert.ok(hrefs.length >= 6)
for (const h of hrefs) assert.ok(/^\/(?!\/)/.test(h) || /^https:\/\//.test(h), `${d.html}: Link ${h}`)
assert.ok(html.includes(`content: "WEBklar · ${d.meta.seitenkopf}"`))
assert.equal([...html.matchAll(/<h1>/g)].length, 1)
assert.ok(html.includes('<div class="brand">WEBKLAR</div>'))
}
})
test('auftraege_lauf: --check meldet fehlende oder abweichende Seiten mit Exit 1 und schreibt nichts; ohne --check werden beide Seiten geschrieben; Aufruf nennt alle Sätze', () => {
const DOKUMENTE = dokumente()
const dir = mkdtempSync(join(tmpdir(), 'legalhtml-auftraege-'))
try {
mkdirSync(join(dir, 'docs/rechtliches'), { recursive: true })
mkdirSync(join(dir, 'public/rechtliches'), { recursive: true })
cpSync(join(ROOT, AGB_MD), join(dir, AGB_MD))
const zeilen = []
const ruf = (...args) => gen.lauf(args, { root: dir, log: (z) => zeilen.push(z), fehler: (z) => zeilen.push(z) })
// noch keine Seiten: --check meldet beide und schreibt nichts
assert.equal(ruf('auftraege', '--check'), 1)
assert.equal(zeilen.filter((z) => z.startsWith('ABWEICHT')).length, 2)
assert.equal(existsSync(join(dir, DOKUMENTE[0].html)), false, '--check schreibt nichts')
// schreiben, dann stimmt --check, und die Bytes sind die der eingecheckten Seiten
assert.equal(ruf('auftraege'), 0)
assert.equal(ruf('auftraege', '--check'), 0)
for (const d of DOKUMENTE) assert.equal(readFileSync(join(dir, d.html), 'utf8'), lies(d.html), 'dieselben Bytes wie die eingecheckte Seite')
// eine Seite von Hand geändert, die andere Quelle geändert: Abweichung, die Dateien bleiben wie sie sind
const ziel = join(dir, DOKUMENTE[1].html)
const handgemacht = readFileSync(ziel, 'utf8').replace('<h2>1. Vertragsgegenstand</h2>', '<h2>1. Vertragsgegenstand (von Hand)</h2>')
assert.notEqual(handgemacht, readFileSync(ziel, 'utf8'))
writeFileSync(ziel, handgemacht)
zeilen.length = 0
assert.equal(ruf('auftraege', '--check'), 1)
assert.deepEqual(zeilen.filter((z) => z.startsWith('ABWEICHT')), [`ABWEICHT ${DOKUMENTE[1].html}`])
assert.equal(readFileSync(ziel, 'utf8'), handgemacht, '--check ändert die Datei nicht')
writeFileSync(ziel, lies(DOKUMENTE[1].html))
writeFileSync(join(dir, AGB_MD), `${lies(AGB_MD)}\n## 15. Neu\n\n(1) Neuer Absatz.\n`)
assert.equal(ruf('auftraege', '--check'), 1)
// falscher Aufruf: Exit 2, die Meldung nennt termine, shop und auftraege
zeilen.length = 0
assert.equal(ruf('analyse'), 2)
assert.equal(ruf('auftraege', '--schreiben'), 2)
assert.equal(ruf(), 2)
assert.match(zeilen[0], /Aufruf: node scripts\/build-legal-html\.mjs termine\|shop\|unterkunft \[--check\]/)
assert.match(zeilen[0], /node scripts\/build-legal-html\.mjs auftraege \[--check\]/)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
// ---------------------------------------------------------------------------------------------------------- PDFs
test('auftraege_pdf_dateien: je Seite ein PDF unter demselben Namen, Prüfsumme der HTML-Seite daneben (HTML geändert ohne neues PDF fällt auf)', () => {
const DOKUMENTE = dokumente()
const mindest = { widerruf: { seiten: 1, bytes: 5 * 1024 }, agb: { seiten: 2, bytes: 20 * 1024 } }
for (const d of DOKUMENTE) {
const pdf = d.html.replace(/\.html$/, '.pdf')
const bytes = readFileSync(join(ROOT, pdf))
assert.equal(bytes.subarray(0, 5).toString('latin1'), '%PDF-', `${pdf}: kein PDF`)
assert.ok(bytes.length > mindest[d.doc].bytes, `${pdf}: ${bytes.length} Bytes sind zu wenig`)
assert.ok(bytes.subarray(-1024).toString('latin1').includes('%%EOF'), `${pdf}: PDF ist abgeschnitten`)
const seiten = bytes.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []
assert.ok(seiten.length >= mindest[d.doc].seiten, `${pdf}: ${seiten.length} Seiten`)
const summe = lies(`${pdf}.sha256`)
assert.match(summe, /^[0-9a-f]{64}\n$/, `${pdf}.sha256: nur die Prüfsumme und ein Zeilenende`)
assert.equal(summe.trim(), sha256(readFileSync(join(ROOT, d.html))), `${d.html} wurde geändert, ohne das PDF neu zu drucken (scripts/build-legal-pdfs.sh auftraege)`)
}
// die AGB liegen unter dem Namen, den die Auftragsbestätigung (Mail K31 und Bestätigungsseite) verlinkt und anhängt
assert.ok(existsSync(join(ROOT, `public/rechtliches/agb-${AGB_FASSUNG}.pdf`)))
})
test('auftraege_script_modus: build-legal-pdfs.sh kennt auftraege (Fassungen aus texte.js und konstanten.js), all druckt es mit, bestehende Modi und Grenzen unverändert', () => {
const sh = lies('scripts/build-legal-pdfs.sh')
assert.match(sh, /^auftraege\)\n {2}auftraege_pdfs\n {2};;$/m)
for (const name of ['TEXTE_VERSION', 'TEXTE_FASSUNG', 'AGB_VERSION', 'AGB_FASSUNG', 'widerrufsbelehrung-projektauftrag:$texte', 'agb:$agb']) assert.ok(sh.includes(name), name)
// die Funktionen stehen vor shop_pdfs (test/buildLegalHtml.test.js liest ab dort den Modus shop) und zeigen auf die HTML-Quelle
const auftraege = sh.slice(sh.indexOf('\nauftraege_pdfs() {'), sh.indexOf('\nshop_pdfs() {'))
assert.ok(auftraege.length > 100 && sh.indexOf('\nauftraege_pdfs() {') > 0, 'auftraege_pdfs steht vor shop_pdfs')
assert.ok(auftraege.includes('sha256sum "$quelle"') && auftraege.includes('echo "$summe" > "$ziel.sha256"') && auftraege.includes('(unveraendert)') && auftraege.includes('${FORCE:-}'))
assert.ok(auftraege.includes('node scripts/build-legal-html.mjs auftraege'), 'fehlt die HTML-Seite, nennt die Meldung den Generator')
assert.ok(sh.slice(sh.indexOf('\nall)'), sh.indexOf('\n*)')).includes(' auftraege_pdfs\n'), 'all druckt auch diese PDFs')
assert.ok(sh.includes('Usage: $0 [all|analyse|termine|shop|unterkunft|datenschutz]') && sh.includes('$0 auftraege'), 'Aufruf nennt alle Modi')
// je Chrome-Aufruf höchstens 120 Sekunden, kein paralleler Lauf, Node-Aufrufe mit Heap-Grenze und Zeitgrenze
assert.match(sh, /^ {2}timeout 120 "\$CHROME" /m)
assert.equal([...sh.matchAll(/timeout 60 node --max-old-space-size=256 /g)].length, 5, 'je Fassungsfunktion ein Aufruf: termine, shop, unterkunft, texte und agb')
assert.ok(!/ &$|^\s*wait\b|xargs -P/m.test(sh), 'keine parallelen Läufe')
assert.equal(spawnSync('bash', ['-n', 'scripts/build-legal-pdfs.sh'], { cwd: ROOT, encoding: 'utf8' }).status, 0)
})