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