Merge feature/terminplaner-tp2 (a629b25) in feature/oeffnungszeiten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
1d5a565e9f
95 files changed
+12879
-546
No files matched your search
@@ -0,0 +1,361 @@
|
||||
import { test, after } 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'
|
||||
import express, { Router } from 'express'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
|
||||
// HTML-Generator der Rechtstexte (scripts/build-legal-html.mjs) und die ausgelieferten Dateien des Terminplaners (Task F6).
|
||||
// Die Markdown-Quellen stehen unter docs/rechtliches/, die Seiten und PDFs unter public/rechtliches/<slug>-<Fassung>.html|pdf.
|
||||
|
||||
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 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 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'
|
||||
|
||||
/** HTML in Klartext: Tags weg, Entitäten zurück, Leerraum normalisiert. */
|
||||
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()
|
||||
|
||||
test('termineDokumente: lb, avv, widerruf mit Quelle, Ziel und Navigation auf die Fassung', () => {
|
||||
assert.deepEqual(DOKUMENTE.map((d) => d.doc), ['lb', 'avv', 'widerruf'])
|
||||
assert.deepEqual(
|
||||
DOKUMENTE.map((d) => [d.md, d.html]),
|
||||
[
|
||||
['docs/rechtliches/leistungsbeschreibung-terminplaner.md', `public/rechtliches/leistungsbeschreibung-terminplaner-${V}.html`],
|
||||
['docs/rechtliches/avv-terminplaner.md', `public/rechtliches/avv-terminplaner-${V}.html`],
|
||||
['docs/rechtliches/widerrufsbelehrung-funktionen.md', `public/rechtliches/widerrufsbelehrung-funktionen-${V}.html`],
|
||||
],
|
||||
)
|
||||
const adressen = Object.values(legalPaths('termine', V))
|
||||
for (const d of DOKUMENTE) {
|
||||
assert.equal(d.meta.fassung, V)
|
||||
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}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('html_gleich_generator: die eingecheckten Seiten sind genau render(Markdown) und werden bei zweitem Lauf gleich', () => {
|
||||
for (const d of DOKUMENTE) {
|
||||
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 termine)`)
|
||||
assert.equal(render(md, d.meta), html, 'deterministisch: zweiter Lauf gleiche Bytes')
|
||||
// keine Datumsangabe außer der Fassung
|
||||
assert.doesNotMatch(html.replaceAll(V, ''), /\b\d{4}-\d{2}-\d{2}\b/, `${d.html}: Datum außer der Fassung`)
|
||||
}
|
||||
// --check: Exit 0 bei gleichem Stand
|
||||
const lauf = spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', 'termine', '--check'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
|
||||
assert.equal(lauf.status, 0, lauf.stdout + lauf.stderr)
|
||||
assert.equal([...lauf.stdout.matchAll(/^ok /gm)].length, 3)
|
||||
})
|
||||
|
||||
test('html_gleich_generator: --check meldet eine abweichende oder fehlende Seite mit Exit 1 und schreibt nichts; ohne --check wird geschrieben; falscher Aufruf: Exit 2', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'legalhtml-'))
|
||||
try {
|
||||
mkdirSync(join(dir, 'docs/rechtliches'), { recursive: true })
|
||||
mkdirSync(join(dir, 'public/rechtliches'), { recursive: true })
|
||||
for (const d of DOKUMENTE) 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) })
|
||||
// noch keine Seiten: --check meldet alle drei und schreibt nichts
|
||||
assert.equal(ruf('termine', '--check'), 1)
|
||||
assert.equal(zeilen.filter((z) => z.startsWith('ABWEICHT')).length, 3)
|
||||
assert.equal(existsSync(join(dir, DOKUMENTE[0].html)), false, '--check schreibt nichts')
|
||||
// schreiben, dann stimmt --check
|
||||
assert.equal(ruf('termine'), 0)
|
||||
assert.equal(ruf('termine', '--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 (oder die Quelle geändert): Abweichung, Datei bleibt wie sie ist
|
||||
const ziel = join(dir, DOKUMENTE[1].html)
|
||||
const handgemacht = readFileSync(ziel, 'utf8').replace('<h2>7. Unterauftragsverarbeiter</h2>', '<h2>7. Unterauftragsverarbeiter (von Hand)</h2>')
|
||||
writeFileSync(ziel, handgemacht)
|
||||
zeilen.length = 0
|
||||
assert.equal(ruf('termine', '--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')
|
||||
// Quelle geändert, Seite nicht nachgezogen
|
||||
writeFileSync(ziel, lies(DOKUMENTE[1].html))
|
||||
writeFileSync(join(dir, DOKUMENTE[1].md), `${lies(DOKUMENTE[1].md)}\n## 13. Neu\n\n(1) Neuer Absatz.\n`)
|
||||
assert.equal(ruf('termine', '--check'), 1)
|
||||
// falscher Aufruf
|
||||
assert.equal(ruf(), 2)
|
||||
assert.equal(ruf('analyse'), 2)
|
||||
assert.equal(ruf('termine', '--schreiben'), 2)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
// als Programm: Exit 0 auf dem eingecheckten Stand, Exit 2 ohne Argumente
|
||||
const prog = (...args) => spawnSync(process.execPath, ['--max-old-space-size=512', 'scripts/build-legal-html.mjs', ...args], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
|
||||
assert.equal(prog('termine', '--check').status, 0)
|
||||
assert.equal(prog().status, 2)
|
||||
})
|
||||
|
||||
test('huelle_gleich_analyse: dieselbe Seitenhülle wie die Seiten der Website-Analyse (bis auf die meta-Zeile mit der Fassung)', () => {
|
||||
const analyse = termineDokumenteAnalyse()
|
||||
for (const a of analyse) {
|
||||
const neu = render(lies(a.md), a.meta).replace(/^ {2}<meta name="webklar-fassung"[^\n]*\n/m, '')
|
||||
assert.equal(neu, lies(a.html), `${a.html}: Seitenhülle weicht ab`)
|
||||
}
|
||||
})
|
||||
|
||||
function termineDokumenteAnalyse() {
|
||||
const nav = [
|
||||
{ href: '/dashboard.html', text: '← Kundenportal' },
|
||||
{ href: '/abo-bedingungen.html', text: 'Abo-Bedingungen' },
|
||||
{ href: '/rechtliches/avv-website-analyse-2026-10-02.html', text: 'AVV Website-Analyse' },
|
||||
{ href: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html', text: 'Leistungsbeschreibung Website-Analyse' },
|
||||
{ href: '/datenschutz.html', text: 'Datenschutzerklärung' },
|
||||
{ href: 'https://webklar.com/impressum', text: 'Impressum' },
|
||||
]
|
||||
return [
|
||||
{ md: 'docs/rechtliches/avv-website-analyse.md', html: 'public/rechtliches/avv-website-analyse-2026-10-02.html', meta: { fassung: '2026-10-02', titel: 'Auftragsverarbeitungsvertrag Website-Analyse', seitenkopf: 'AVV Website-Analyse', nav } },
|
||||
{ md: 'docs/rechtliches/leistungsbeschreibung-website-analyse.md', html: 'public/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html', meta: { fassung: '2026-10-02', titel: 'Leistungsbeschreibung Website-Analyse', seitenkopf: 'Leistungsbeschreibung Website-Analyse', nav } },
|
||||
]
|
||||
}
|
||||
|
||||
test('html_text_gleich_markdown: der sichtbare Text jeder Seite ist genau der Text der Quelle (plus Navigation und Marke)', () => {
|
||||
for (const d of DOKUMENTE) {
|
||||
const md = lies(d.md)
|
||||
// Text der Quelle in Lesereihenfolge: Markierungen weg, Tabellenzellen einzeln, Trennzeile weg
|
||||
const aus = []
|
||||
for (const zeile of md.replace(/\r/g, '').split('\n')) {
|
||||
if (zeile.trim() === '' || /^\|[-| ]+\|$/.test(zeile)) continue
|
||||
if (zeile.startsWith('|')) aus.push(...zeile.slice(1, -1).split('|'))
|
||||
else aus.push(zeile.replace(/^#{1,3} /, '').replace(/^- /, ''))
|
||||
}
|
||||
const erwartet = aus.join(' ').replace(/\*\*|`/g, '').replace(/\s+/g, ' ').trim()
|
||||
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 vom Markdown ab`)
|
||||
// jeder Absatz einzeln (aussagekräftige Fehlermeldung)
|
||||
const text = klartext(main)
|
||||
for (const stueck of aus.map((z) => z.replace(/\*\*|`/g, '').replace(/\s+/g, ' ').trim()).filter(Boolean)) {
|
||||
assert.ok(text.includes(stueck), `${d.html}: fehlt im HTML: ${stueck.slice(0, 80)}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('html_sauber: keine Kommentare, Entwurfsmarken, Skripte oder Ereignis-Attribute; Fassung im Kopf; Links nur relativ oder https', () => {
|
||||
for (const d of DOKUMENTE) {
|
||||
const html = lies(d.html)
|
||||
assert.doesNotMatch(html, /<!--|Entwurf|ENTWURF|Prüfpunkt|<script|<iframe|<img|<form|<link|\sstyle=/, `${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 ${V}</strong>`), `${d.html}: "Fassung ${V}" fehlt im Kopf`)
|
||||
assert.ok(html.includes(`<meta name="webklar-fassung" content="${V}">`))
|
||||
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.doesNotMatch(html, /\ssrc=/)
|
||||
// @page-Kopf und Seitenzähler wie die Analyse-Seiten
|
||||
assert.ok(html.includes(`content: "WEBklar · ${d.meta.seitenkopf}"`))
|
||||
assert.ok(html.includes('content: "Seite " counter(page) " von " counter(pages)'))
|
||||
// nur ein h1, jede Seite hat die Marke
|
||||
assert.equal([...html.matchAll(/<h1>/g)].length, 1)
|
||||
assert.ok(html.includes('<div class="brand">WEBKLAR</div>'))
|
||||
}
|
||||
})
|
||||
|
||||
test('generator_lehnt_unbekannte_syntax_ab: Codeblock, Zitat, Bild, Link, rohes HTML, nummerierte Liste und mehr brechen laut ab', () => {
|
||||
assert.doesNotThrow(() => render(`${KOPF}## Abschnitt\n\n(1) Text mit **fett** und \`code\`.\n- Punkt\n- Punkt zwei\n\n| A | B |\n|---|---|\n| 1 | 2 |\n`, META))
|
||||
const schlecht = {
|
||||
Codeblock: '```js\nx\n```',
|
||||
'Codeblock (Tilde)': '~~~\nx\n~~~',
|
||||
Zitat: '> zitiert',
|
||||
Bild: '',
|
||||
Link: 'ein [Link](https://example.com) im Text',
|
||||
'rohes HTML': '<div>x</div>',
|
||||
'rohes HTML im Satz': 'Text <b>fett</b>',
|
||||
Kommentar: '<!-- x -->',
|
||||
'nummerierte Liste': '1. Eins\n2. Zwei',
|
||||
'Liste mit Stern': '* Punkt',
|
||||
'Liste mit Plus': '+ Punkt',
|
||||
Trennlinie: '---',
|
||||
'Überschrift ab Ebene 4': '#### Tief',
|
||||
'Überschrift ohne Leerzeichen': '##Tief',
|
||||
Einrückung: ' eingerückt',
|
||||
'Einrückung der Liste': '- Punkt\n - Unterpunkt',
|
||||
'offenes fett': 'ein **offenes fett',
|
||||
'offener Code': 'ein `offener Code',
|
||||
'leerer Listenpunkt': '- ',
|
||||
'zweites h1': '# Noch ein Titel',
|
||||
'Tabelle ohne Trennzeile': '| A | B |\n| 1 | 2 |',
|
||||
'Tabelle mit anderer Spaltenzahl': '| A | B |\n|---|---|\n| 1 | 2 | 3 |',
|
||||
'Tabelle mit Ausrichtung': '| A | B |\n|:--|---|\n| 1 | 2 |',
|
||||
}
|
||||
for (const [name, text] of Object.entries(schlecht)) {
|
||||
assert.throws(() => render(`${KOPF}${text}\n`, META), MarkdownFehler, `${name} muss abbrechen`)
|
||||
}
|
||||
// Fehlermeldung nennt Zeile und Grund
|
||||
assert.throws(() => render(`${KOPF}## A\n\n> Zitat\n`, META), /Zeile 7: nicht unterstützt \(Zitat\)/)
|
||||
// Kopf: erste Zeile Titel, danach der Fassungsabsatz mit der Fassung der Meta
|
||||
assert.throws(() => render('## ohne Titel\n', META), MarkdownFehler)
|
||||
assert.throws(() => render('# Titel\n\nText ohne Fassung\n', META), /Fassung 2026-10-05/)
|
||||
assert.throws(() => render('# Titel\n\n**Fassung 2026-10-06**\n', META), /Fassung 2026-10-05/)
|
||||
// Meta: Fassung im Format, Adressen der Navigation nur relativ oder https
|
||||
assert.throws(() => render(`${KOPF}Text\n`, { ...META, fassung: '5.10.2026' }), /fassung/)
|
||||
for (const href of ['javascript:alert(1)', '//evil.example/x', 'http://example.com', '/x"onmouseover="y', 'data:text/html,x']) {
|
||||
assert.throws(() => render(`${KOPF}Text\n`, { ...META, nav: [{ href, text: 'x' }] }), /Adresse in der Navigation/, href)
|
||||
}
|
||||
assert.throws(() => parse(42), TypeError)
|
||||
})
|
||||
|
||||
test('generator_escaped: <, & und " in Text, Code, Tabellen, Titel und Attributen werden maskiert', () => {
|
||||
const md = `${KOPF}## Wert < 5 & "zitiert" > 3\n\nEin Absatz mit a < b & c, "Anführung" und 'Apostroph'.\n\n- Punkt mit < und &\n- **fett & kühn** und \`a<b && c\`\n\n| Kopf & "Z" | B < 1 |\n|---|---|\n| x & y | "z" > 0 |\n`
|
||||
const html = render(md, { ...META, titel: 'Titel & "Mehr" <b>', seitenkopf: 'Kopf "x" \\ y', nav: [{ href: '/x?a=1&b=2', text: 'Link <1> & "2"' }] })
|
||||
assert.ok(html.includes('<h2>Wert < 5 & "zitiert" > 3</h2>'))
|
||||
assert.ok(html.includes('<p>Ein Absatz mit a < b & c, "Anführung" und 'Apostroph'.</p>'))
|
||||
assert.ok(html.includes('<li>Punkt mit < und &</li>'))
|
||||
assert.ok(html.includes('<li><strong>fett & kühn</strong> und <code>a<b && c</code></li>'))
|
||||
assert.ok(html.includes('<th>Kopf & "Z"</th><th>B < 1</th>'))
|
||||
assert.ok(html.includes('<td>x & y</td><td>"z" > 0</td>'))
|
||||
// Attribute und Titel
|
||||
assert.ok(html.includes('<a href="/x?a=1&b=2">Link <1> & "2"</a>'))
|
||||
assert.ok(html.includes('<title>Titel & "Mehr" <b> – WEBklar</title>'))
|
||||
assert.ok(html.includes('content: "WEBklar · Kopf \\"x\\" \\\\ y"'), 'CSS-Zeichenkette escaped')
|
||||
// kein ungeschütztes Tag aus dem Text im Ergebnis
|
||||
assert.doesNotMatch(html, /<b>|<1>/)
|
||||
})
|
||||
|
||||
test('pdf_dateien: je Dokument ein PDF unter dem Namen aus legalPdfFiles, größer als 20 KB', () => {
|
||||
const dateien = legalPdfFiles('termine', V)
|
||||
assert.deepEqual(Object.keys(dateien), ['lb', 'avv', 'widerruf'])
|
||||
for (const [doc, f] of Object.entries(dateien)) {
|
||||
assert.match(f.path, new RegExp(`/public/rechtliches/[a-z-]+-${V}\\.pdf$`))
|
||||
assert.equal(f.filename.endsWith(`-${V}.pdf`), true)
|
||||
const bytes = readFileSync(f.path)
|
||||
assert.equal(bytes.subarray(0, 5).toString('latin1'), '%PDF-', `${doc}: kein PDF`)
|
||||
assert.ok(bytes.length > 20 * 1024, `${doc}: PDF mit ${bytes.length} Bytes ist zu klein`)
|
||||
assert.ok(bytes.subarray(-1024).toString('latin1').includes('%%EOF'), `${doc}: PDF ist abgeschnitten`)
|
||||
// mehr als eine Seite bei LB und AVV, mindestens eine bei der Belehrung
|
||||
const seiten = bytes.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []
|
||||
assert.ok(seiten.length >= (doc === 'widerruf' ? 1 : 2), `${doc}: ${seiten.length} Seiten`)
|
||||
// das PDF gehört zur HTML-Seite derselben Fassung: gleicher Name bis auf die Endung
|
||||
const d = DOKUMENTE.find((x) => x.doc === doc)
|
||||
assert.equal(f.path, join(ROOT, d.html.replace(/\.html$/, '.pdf')))
|
||||
}
|
||||
})
|
||||
|
||||
test('pdf_pruefsumme: neben jedem PDF liegt <PDF>.sha256 mit der SHA-256 der HTML-Seite, aus der es gedruckt wurde (HTML geändert ohne neues PDF fällt auf)', () => {
|
||||
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
|
||||
for (const d of DOKUMENTE) {
|
||||
const pdf = d.html.replace(/\.html$/, '.pdf')
|
||||
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 termine)`)
|
||||
}
|
||||
// die Prüfung schlägt an: eine geänderte Seite ergibt eine andere Summe
|
||||
const d = DOKUMENTE[1]
|
||||
assert.notEqual(sha256(`${lies(d.html)} `), lies(`${d.html.replace(/\.html$/, '.pdf')}.sha256`).trim())
|
||||
// das Skript schreibt die Summe und druckt ein PDF mit gültiger Summe nicht neu
|
||||
const sh = lies('scripts/build-legal-pdfs.sh')
|
||||
assert.ok(sh.includes('sha256sum "$quelle"') && sh.includes('echo "$summe" > "$ziel.sha256"'))
|
||||
assert.ok(sh.includes('(unveraendert)') && sh.includes('FORCE'))
|
||||
})
|
||||
|
||||
test('anhaenge_drei: legalPdfAttachments("termine", F) liefert lb, avv, widerruf als application/pdf', async () => {
|
||||
const anhaenge = await legalPdfAttachments('termine', V)
|
||||
assert.deepEqual(anhaenge.map((a) => a.filename), [
|
||||
`Leistungsbeschreibung-Terminplaner-${V}.pdf`,
|
||||
`AVV-Terminplaner-${V}.pdf`,
|
||||
`Widerrufsbelehrung-Muster-Widerrufsformular-${V}.pdf`,
|
||||
])
|
||||
const dateien = legalPdfFiles('termine', V)
|
||||
for (const [i, doc] of ['lb', 'avv', 'widerruf'].entries()) {
|
||||
assert.equal(anhaenge[i].contentType, 'application/pdf')
|
||||
assert.ok(Buffer.isBuffer(anhaenge[i].content))
|
||||
assert.deepEqual(anhaenge[i].content, readFileSync(dateien[doc].path))
|
||||
assert.equal(anhaenge[i].content.subarray(0, 5).toString('latin1'), '%PDF-')
|
||||
}
|
||||
})
|
||||
|
||||
// Router wie in server/index.js: erst die Adressen ohne Fassung (legalRouter), dann die Dateien selbst (express.static)
|
||||
const rechtliches = Router()
|
||||
rechtliches.use(legalRouter())
|
||||
rechtliches.use(express.static(join(ROOT, 'public', 'rechtliches')))
|
||||
const app = await startTestApp({ '/rechtliches': rechtliches })
|
||||
after(() => app.close())
|
||||
|
||||
test('router_alias: /rechtliches/<slug>.html liefert die Bytes der Fassungsdatei; unbekannte Fassung und alte Adressen: 404', async () => {
|
||||
for (const d of DOKUMENTE) {
|
||||
const slug = /\/([a-z-]+)-\d{4}-\d{2}-\d{2}\.html$/.exec(d.html)[1]
|
||||
const alias = await fetch(`${app.url}/rechtliches/${slug}.html`)
|
||||
assert.equal(alias.status, 200)
|
||||
assert.match(alias.headers.get('content-type'), /text\/html/)
|
||||
assert.deepEqual(Buffer.from(await alias.arrayBuffer()), readFileSync(join(ROOT, d.html)), `${slug}: Alias liefert nicht die Datei der Fassung`)
|
||||
const fest = await fetch(`${app.url}/rechtliches/${slug}-${V}.html`)
|
||||
assert.equal(fest.status, 200)
|
||||
assert.deepEqual(Buffer.from(await fest.arrayBuffer()), readFileSync(join(ROOT, d.html)))
|
||||
const pdf = await fetch(`${app.url}/rechtliches/${slug}-${V}.pdf`)
|
||||
assert.equal(pdf.status, 200)
|
||||
assert.match(pdf.headers.get('content-type'), /application\/pdf/)
|
||||
const alt = await fetch(`${app.url}/rechtliches/${slug}-1999-01-01.html`)
|
||||
assert.equal(alt.status, 404, `${slug}-1999-01-01.html darf es nicht geben`)
|
||||
// die Prüfsummen-Datei neben dem PDF wird einfach mit ausgeliefert, die Adressen der Seiten bleiben davon unberührt
|
||||
const summe = await fetch(`${app.url}/rechtliches/${slug}-${V}.pdf.sha256`)
|
||||
assert.equal(summe.status, 200)
|
||||
assert.match(await summe.text(), /^[0-9a-f]{64}\n$/)
|
||||
assert.equal((await fetch(`${app.url}/rechtliches/${slug}.pdf.sha256`)).status, 404)
|
||||
}
|
||||
// Fassung der Konstante ohne Datei: der Alias antwortet 404
|
||||
const vorher = config.termine.avvVersion
|
||||
config.termine.avvVersion = '1999-01-01'
|
||||
try {
|
||||
assert.equal((await fetch(`${app.url}/rechtliches/avv-terminplaner.html`)).status, 404)
|
||||
} finally {
|
||||
config.termine.avvVersion = vorher
|
||||
}
|
||||
assert.equal((await fetch(`${app.url}/rechtliches/avv-terminplaner.html`)).status, 200)
|
||||
})
|
||||
|
||||
test('script_modus: build-legal-pdfs.sh kennt termine und datenschutz, lässt die all-Zeile unverändert und begrenzt Chrome auf 120 Sekunden', () => {
|
||||
const sh = lies('scripts/build-legal-pdfs.sh')
|
||||
assert.match(sh, /^termine\)$/m)
|
||||
assert.match(sh, /^datenschutz\)$/m)
|
||||
assert.match(sh, /^analyse\)$/m)
|
||||
assert.match(sh, /^all\)$/m)
|
||||
assert.ok(sh.includes('TERMINE_VERSION'))
|
||||
assert.ok(sh.includes('config.termine.avvVersion'), 'die Vorgabe der Fassung kommt aus der Konstante des Servers')
|
||||
// die unveränderte Zeile von all (und derselbe Aufruf im Modus datenschutz)
|
||||
const all = sh.slice(sh.indexOf('\nall)'), sh.indexOf('\n*)'))
|
||||
assert.ok(all.includes(' pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf\n'))
|
||||
assert.ok(all.includes(' pdf docs/leitfaden-empfehlungsgeber.html docs/WEBklar-Leitfaden-Empfehlungsgeber.pdf\n'))
|
||||
assert.ok(all.includes(' pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf\n'))
|
||||
const datenschutz = sh.slice(sh.indexOf('\ndatenschutz)'), sh.indexOf('\nall)'))
|
||||
assert.ok(datenschutz.includes('pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf'))
|
||||
// die drei Dokumente des Modus termine sind die Slugs der Registry
|
||||
const termine = sh.slice(sh.indexOf('\ntermine)'), sh.indexOf('\ndatenschutz)'))
|
||||
for (const f of Object.values(legalPdfFiles('termine', V))) {
|
||||
const slug = /\/([a-z-]+)-\d{4}-\d{2}-\d{2}\.pdf$/.exec(f.path)[1]
|
||||
assert.ok(termine.includes(slug), `Modus termine baut ${slug}`)
|
||||
}
|
||||
// Chrome höchstens 120 Sekunden je Aufruf; Aufbau und Aufrufe nacheinander
|
||||
assert.match(sh, /^ {2}timeout 120 "\$CHROME" /m)
|
||||
assert.match(sh, /v="\$\(timeout 60 node --max-old-space-size=256 /, 'auch der Node-Aufruf für die Fassung hat Heap-Grenze und Timeout')
|
||||
assert.ok(!/ &$|^\s*wait\b|xargs -P/m.test(sh), 'keine parallelen Läufe')
|
||||
// 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]'))
|
||||
})
|
||||
@@ -0,0 +1,308 @@
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
||||
|
||||
// Bestelltexte des Terminplaners (Task F7): die vier Zustimmungen (Haken), der Knopf, die Fassung, die Widerrufsbelehrung im
|
||||
// Register und der Zustimmungsnachweis beim Checkout (Review Focus 5: genau die gezeigten Texte, Fassungen und der Zeitpunkt
|
||||
// sind gespeichert, bevor die URL an den Browser geht).
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
featureConsents: schemaKeys('featureConsents'),
|
||||
termineConfig: schemaKeys('termineConfig'),
|
||||
termineBookings: schemaKeys('termineBookings'),
|
||||
termineClaims: schemaKeys('termineClaims'),
|
||||
termineOutbox: schemaKeys('termineOutbox'),
|
||||
contractEvents: schemaKeys('contractEvents'),
|
||||
customerViewLog: schemaKeys('customerViewLog'),
|
||||
},
|
||||
datetimes: TERMINE_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, post, send } = await import('./support/testApp.js')
|
||||
const { customerCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
|
||||
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
|
||||
const { default: termineRoutes } = await import('../server/routes/termine.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { featureConfig, featureHaken, oeffentlicheAnsicht } = await import('../server/services/featureRegistry.js')
|
||||
const { nachweisTexte, zustimmungsfassungen } = await import('../server/services/featureConsents.js')
|
||||
const { widerrufsbelehrungFunktion } = await import('../server/services/termine/rechtstexte.js')
|
||||
const { legalPaths } = await import('../server/services/legalDocs.js')
|
||||
|
||||
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/termine': termineRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
|
||||
const T = config.features.termine
|
||||
const NEWS = config.features.news
|
||||
const V = config.termine.avvVersion
|
||||
const STANDARD = { mode: config.termine.mode, pilotEmails: [...config.termine.pilotEmails] }
|
||||
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'termineConfig', 'contractEvents']) {
|
||||
for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
|
||||
}
|
||||
seedFixtures(aw)
|
||||
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Salon' })
|
||||
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))
|
||||
st.checkoutSessions.clear()
|
||||
config.termine.mode = 'pilot'
|
||||
config.termine.pilotEmails = ['*']
|
||||
delete T.bestellpruefung
|
||||
})
|
||||
after(() => {
|
||||
config.termine.mode = STANDARD.mode
|
||||
config.termine.pilotEmails = STANDARD.pilotEmails
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ die Texte
|
||||
|
||||
test('termine_haken_woertlich: vier Haken in fester Reihenfolge mit dem Wortlaut aus dem Brief', () => {
|
||||
assert.deepEqual(T.consent.items.map((i) => i.id), ['unterlagen', 'gesundheit', 'beginn', 'information'])
|
||||
assert.deepEqual(T.consent.items.map((i) => i.haken), [true, true, true, true])
|
||||
assert.deepEqual(T.consent.items.map((i) => i.text), [
|
||||
`Ich stimme der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${V} zu.`,
|
||||
'Mein Angebot liegt nicht im Gesundheitsbereich (zum Beispiel keine Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis).',
|
||||
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Terminplaners beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung 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).',
|
||||
'Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Terminplaners für diese Website: ein Widget an einer Stelle, gestaltet nach dem Fragebogen, mit Einbau und Datenschutz-Absatz), Gesamtpreis 149 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), voraussichtliche Dauer meist innerhalb von 1–2 Wochen, der Terminplaner endet mit dem Wartungs-Abo. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.',
|
||||
])
|
||||
assert.equal(T.consent.knopf, 'Zahlungspflichtig bestellen · 149 €')
|
||||
for (const item of T.consent.items) assert.deepEqual(Object.keys(item).sort(), ['haken', 'id', 'text'], 'ohne url')
|
||||
// die Bestellseite bekommt dieselben Texte
|
||||
const ansicht = oeffentlicheAnsicht('termine').consent
|
||||
assert.deepEqual(ansicht.items, T.consent.items.map((i) => ({ id: i.id, text: i.text, haken: true })))
|
||||
assert.equal(ansicht.knopf, T.consent.knopf)
|
||||
// das Zitat für das Erlöschen des Widerrufsrechts bei der Einrichtung (Dienstleistung): § 356 Abs. 5 Nr. 2 BGB, nicht Abs. 4
|
||||
assert.match(T.consent.items[2].text, /\(§ 356 Abs\. 5 Nr\. 2 BGB\)/)
|
||||
assert.doesNotMatch(T.consent.items[2].text, /Abs\. 4/)
|
||||
})
|
||||
|
||||
test('termine_version_gleich_dokumente: Fassung des Registers = Fassung aller Dokumente und im Haken 1', () => {
|
||||
assert.equal(T.consent.version, config.termine.avvVersion)
|
||||
assert.ok(T.consent.items[0].text.includes(`Fassung ${T.consent.version} zu.`))
|
||||
const adressen = legalPaths('termine', V)
|
||||
assert.deepEqual(T.leistungsbeschreibung, { version: V, url: adressen.lb })
|
||||
assert.deepEqual(T.avv, { version: V, url: adressen.avv })
|
||||
assert.deepEqual(T.widerruf, { version: V, url: adressen.widerruf })
|
||||
assert.equal(T.widerruf.url, `/rechtliches/widerrufsbelehrung-funktionen-${V}.html`)
|
||||
assert.deepEqual(zustimmungsfassungen(T), { zustimmung: V, leistungsbeschreibung: V, avv: V, widerruf: V })
|
||||
// News behält ihre Leistungsbeschreibung, hat aber keine Widerrufsbelehrung der Funktionen
|
||||
assert.equal(NEWS.widerruf, null)
|
||||
assert.equal(NEWS.consent.version, V)
|
||||
// folgt die Konstante, folgen Fassung und Text (die Haken werden je Fassung gebaut)
|
||||
config.termine.avvVersion = '2099-01-01'
|
||||
try {
|
||||
assert.equal(T.consent.version, '2099-01-01')
|
||||
assert.ok(T.consent.items[0].text.includes('in der Fassung 2099-01-01 zu.'))
|
||||
assert.equal(T.widerruf.version, '2099-01-01')
|
||||
assert.equal(T.widerruf.url, '/rechtliches/widerrufsbelehrung-funktionen-2099-01-01.html')
|
||||
} finally {
|
||||
config.termine.avvVersion = V
|
||||
}
|
||||
assert.ok(T.consent.items[0].text.includes(`in der Fassung ${V} zu.`))
|
||||
// Tests dürfen einzelne Texte ändern und zurückstellen: dasselbe Objekt, solange die Fassung gleich bleibt
|
||||
const text = T.consent.items[1].text
|
||||
T.consent.items[1].text = 'anders'
|
||||
assert.equal(T.consent.items[1].text, 'anders')
|
||||
T.consent.items[1].text = text
|
||||
})
|
||||
|
||||
test('information_nennt_register_werte: Preis, Umsatzsteuer-Hinweis und Dauer kommen aus dem Register', () => {
|
||||
const info = T.consent.items.find((i) => i.id === 'information').text
|
||||
assert.ok(info.includes(`Gesamtpreis ${T.priceLabel} einmalig (${config.ustHinweis})`))
|
||||
assert.ok(info.includes('149 €'))
|
||||
assert.ok(info.includes('ohne Umsatzsteuer gemäß § 19 UStG'))
|
||||
assert.ok(info.includes(`voraussichtliche Dauer ${T.setupEstimate}`))
|
||||
assert.ok(info.includes('der Terminplaner endet mit dem Wartungs-Abo'))
|
||||
assert.ok(info.endsWith('Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.'))
|
||||
// Kleinunternehmer: nie "inkl. MwSt." oder "brutto"
|
||||
for (const text of [...T.consent.items.map((i) => i.text), T.consent.knopf]) assert.doesNotMatch(text, /brutto|inkl\.|inklusive|Mehrwertsteuer|MwSt|zzgl/i)
|
||||
assert.equal(T.consent.knopf, `Zahlungspflichtig bestellen · ${T.priceLabel}`)
|
||||
})
|
||||
|
||||
test('keine_urls_im_text: kein Link, keine Adresse und kein Markup in den Haken und im Knopf', () => {
|
||||
for (const text of [...T.consent.items.map((i) => i.text), T.consent.knopf, ...NEWS.consent.items.map((i) => i.text), NEWS.consent.knopf]) {
|
||||
assert.doesNotMatch(text, /https?:\/\/|www\.|\/rechtliches|\.html|\.pdf|<|>|ENTWURF|Entwurf|Prüfpunkt|⚖/, text.slice(0, 40))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Pin auf die Bestelltexte beider Funktionen (Haken und Knopf in Registerreihenfolge, durch eine Leerzeile verbunden).
|
||||
* Bei Textänderung neues Fassungsdatum (config.termine.avvVersion) UND neuen Hash: eine Textänderung ohne Fassungswechsel
|
||||
* fällt hier auf, denn gespeicherte Nachweise tragen Text und Fassung.
|
||||
*/
|
||||
const TEXTSTAND = { fassung: '2026-10-05', sha256: 'f7c3adf183030d817e2a23f26e8e01dbcd90e35a375b58e2c3b3a33b6bf99b6c' }
|
||||
|
||||
test('textstand_pin: Hash über die verbundenen Bestelltexte beider Funktionen steht im Test', () => {
|
||||
const texte = [...NEWS.consent.items.map((i) => i.text), NEWS.consent.knopf, ...T.consent.items.map((i) => i.text), T.consent.knopf].join('\n\n')
|
||||
assert.equal(config.termine.avvVersion, TEXTSTAND.fassung, 'neue Fassung: Pin und Hash hier, in featureRegistry.test.js und in den Rechtstext-Tests nachziehen')
|
||||
assert.equal(sha256(texte), TEXTSTAND.sha256, 'Bestelltexte geändert: neues Fassungsdatum und neuen Hash eintragen')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Belehrung im Haken
|
||||
|
||||
test('belehrung_haken: der Standard-Haken liefert die Widerrufsbelehrung der Funktionen, News hat keinen', async () => {
|
||||
const belehrung = await featureHaken('termine').belehrung()
|
||||
assert.equal(belehrung, widerrufsbelehrungFunktion())
|
||||
assert.doesNotMatch(belehrung, /ENTWURF|Block F/)
|
||||
assert.match(belehrung, /Muster-Widerrufsformular/)
|
||||
assert.match(belehrung, /An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler/)
|
||||
assert.equal(featureHaken('news').belehrung, undefined)
|
||||
// der Nachweis führt Belehrung und Knopf hinter den Haken
|
||||
const texte = nachweisTexte(T, { belehrung })
|
||||
assert.deepEqual(texte.map((t) => t.id), ['unterlagen', 'gesundheit', 'beginn', 'information', 'belehrung', 'knopf'])
|
||||
assert.deepEqual(texte.slice(-2), [
|
||||
{ id: 'belehrung', text: belehrung, haken: false },
|
||||
{ id: 'knopf', text: T.consent.knopf, haken: false },
|
||||
])
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------------------ Checkout und Nachweis
|
||||
|
||||
const c1 = customerCookie('c1')
|
||||
const checkout = (body = {}) =>
|
||||
post('/api/funktionen/termine/checkout', { projectId: 'p1', zustimmung: { version: T.consent.version, haken: T.consent.items.map((i) => i.id) }, ...body }, { cookie: c1 })
|
||||
const bereit = () => {
|
||||
T.bestellpruefung = async () => []
|
||||
}
|
||||
const nachweise = () => [...aw.docs('featureConsents').values()]
|
||||
const sitzungen = () => [...st.checkoutSessions.values()]
|
||||
const termineDoc = () => [...aw.docs('portalFeatures').values()].find((d) => d.featureKey === 'termine')
|
||||
|
||||
test('nachweis_vor_umleitung: Stripe-Sitzung, dann Nachweis mit Texten, Fassungen und Klickzeit, dann ordered, dann erst die URL', async () => {
|
||||
bereit()
|
||||
// Reihenfolge der Schreibzugriffe auf featureConsents und portalFeatures und der Antwort an den Browser
|
||||
const ereignisse = []
|
||||
const echt = globalThis.fetch
|
||||
globalThis.fetch = async (url, init) => {
|
||||
const adresse = String(url?.url ?? url)
|
||||
const methode = init?.method ?? 'GET'
|
||||
const antwort = await echt(url, init)
|
||||
const sammlung = /\/collections\/(featureConsents|portalFeatures)\/documents/.exec(adresse)?.[1]
|
||||
if (sammlung && methode !== 'GET') ereignisse.push(`${methode} ${sammlung}`)
|
||||
else if (adresse === `${app.url}/api/funktionen/termine/checkout`) ereignisse.push('antwort')
|
||||
return antwort
|
||||
}
|
||||
const vor = new Date().toISOString()
|
||||
let res
|
||||
try {
|
||||
res = await checkout()
|
||||
} finally {
|
||||
globalThis.fetch = echt
|
||||
}
|
||||
const nach = new Date().toISOString()
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(Object.keys(res.body), ['url'])
|
||||
|
||||
// genau eine Sitzung (zuerst angelegt, ihre ID steht im Nachweis) und genau ein Nachweis
|
||||
assert.equal(sitzungen().length, 1)
|
||||
const [session] = sitzungen()
|
||||
assert.equal(res.body.url, session.url)
|
||||
assert.equal(nachweise().length, 1)
|
||||
const [n] = nachweise()
|
||||
assert.equal(n.stripeSessionId, session.id, 'der Nachweis trägt die ID der Stripe-Sitzung')
|
||||
|
||||
// erst der Nachweis, dann jede Änderung am Funktionsdokument (ordered), dann die Antwort mit der URL
|
||||
const nachweisPosition = ereignisse.indexOf('POST featureConsents')
|
||||
assert.ok(nachweisPosition >= 0, `Nachweis nicht geschrieben: ${ereignisse}`)
|
||||
assert.equal(ereignisse.filter((e) => e === 'POST featureConsents').length, 1)
|
||||
assert.equal(ereignisse.at(-1), 'antwort', 'die Antwort kommt zuletzt')
|
||||
const dokumentSchreiben = ereignisse.map((e, i) => (e.endsWith('portalFeatures') ? i : -1)).filter((i) => i >= 0)
|
||||
assert.ok(dokumentSchreiben.length >= 1)
|
||||
for (const i of dokumentSchreiben) assert.ok(i > nachweisPosition, `portalFeatures wird vor dem Nachweis geschrieben: ${ereignisse}`)
|
||||
assert.ok(nachweisPosition < ereignisse.indexOf('antwort'))
|
||||
|
||||
// gespeichert: genau die Registertexte (Haken), dann Belehrung, dann Knopf; die Fassungen mit widerruf; Hash; Klickzeit
|
||||
assert.deepEqual(JSON.parse(n.texte), [...T.consent.items.map((i) => ({ id: i.id, text: i.text, haken: true })), { id: 'belehrung', text: widerrufsbelehrungFunktion(), haken: false }, { id: 'knopf', text: T.consent.knopf, haken: false }])
|
||||
assert.deepEqual(JSON.parse(n.fassungen), { zustimmung: V, leistungsbeschreibung: V, avv: V, widerruf: V })
|
||||
assert.equal(n.textHash, sha256(n.texte))
|
||||
assert.equal(n.acceptedAt, session.metadata.consentAt, 'acceptedAt = Klickzeit = consentAt der Stripe-Sitzung')
|
||||
assert.ok(vor <= n.acceptedAt && n.acceptedAt <= nach, `${vor} <= ${n.acceptedAt} <= ${nach}`)
|
||||
assert.match(n.acceptedAt, /Z$/)
|
||||
const doc = termineDoc()
|
||||
assert.equal(doc.state, 'ordered')
|
||||
assert.equal(doc.consentAt, n.acceptedAt, 'das Dokument trägt dieselbe Klickzeit (Anker der Widerrufsfrist)')
|
||||
})
|
||||
|
||||
test('nachweis_vor_umleitung: schlägt das Speichern fehl, gibt es 500 ohne url und kein ordered; die Sitzung läuft nach 24 h ab', async () => {
|
||||
bereit()
|
||||
const log = mock.method(console, 'error', () => {})
|
||||
try {
|
||||
aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500, message: 'GEHEIMER-ROHTEXT' })
|
||||
const res = await checkout()
|
||||
assert.equal(res.status, 500)
|
||||
assert.equal(res.body.url, undefined)
|
||||
assert.doesNotMatch(JSON.stringify(res.body), /GEHEIMER-ROHTEXT|https?:/)
|
||||
assert.equal(nachweise().length, 0)
|
||||
const doc = termineDoc()
|
||||
assert.ok(!doc || doc.state === 'off', 'nicht ordered')
|
||||
assert.equal(sitzungen().length, 1, 'die Stripe-Sitzung entstand zuerst, ihre URL ging nie an den Browser')
|
||||
assert.equal(sitzungen()[0].status, 'open')
|
||||
} finally {
|
||||
log.mock.restore()
|
||||
}
|
||||
// die verwaiste Sitzung läuft nach 24 h ab (Stripe: expires_at beim Anlegen)
|
||||
const quelle = readFileSync(new URL('../server/services/stripeBilling.js', import.meta.url), 'utf8')
|
||||
assert.match(quelle, /expires_at: Math\.floor\(Date\.now\(\) \/ 1000\) \+ 24 \* 60 \* 60 - 5 \* 60/)
|
||||
})
|
||||
|
||||
test('belehrung_gezeigt_gleich_gespeichert: gespeicherter Text = bestellung.belehrung der Bestellseite = widerrufsbelehrungFunktion()', async () => {
|
||||
// gezeigt: GET /api/termine/einstellungen (Bestellseite liest bestellung.belehrung)
|
||||
const einstellungen = await send('GET', '/api/termine/einstellungen?projectId=p1', { cookie: c1 })
|
||||
assert.equal(einstellungen.status, 200, JSON.stringify(einstellungen.body))
|
||||
const gezeigt = einstellungen.body.bestellung.belehrung
|
||||
assert.equal(typeof gezeigt, 'string')
|
||||
assert.equal(gezeigt, widerrufsbelehrungFunktion())
|
||||
assert.deepEqual(einstellungen.body.bestellung.dokumente.map((d) => [d.id, d.version, d.url]), [
|
||||
['leistungsbeschreibung', V, legalPaths('termine', V).lb],
|
||||
['avv', V, legalPaths('termine', V).avv],
|
||||
['widerruf', V, legalPaths('termine', V).widerruf],
|
||||
])
|
||||
// gespeichert: der Nachweis des Checkouts
|
||||
bereit()
|
||||
assert.equal((await checkout()).status, 200)
|
||||
const gespeichert = JSON.parse(nachweise()[0].texte).find((t) => t.id === 'belehrung')
|
||||
assert.equal(gespeichert.text, gezeigt, 'gezeigter und gespeicherter Text sind dieselbe Zeichenkette')
|
||||
})
|
||||
|
||||
test('knopf_gleich_register_und_nachweis: bestellung.consent.knopf = Registertext = gespeicherter knopf-Eintrag', async () => {
|
||||
const einstellungen = await send('GET', '/api/termine/einstellungen?projectId=p1', { cookie: c1 })
|
||||
const knopf = einstellungen.body.bestellung.consent.knopf
|
||||
assert.equal(knopf, T.consent.knopf)
|
||||
assert.equal(knopf, featureConfig('termine').consent.knopf)
|
||||
assert.equal(knopf, 'Zahlungspflichtig bestellen · 149 €')
|
||||
bereit()
|
||||
assert.equal((await checkout()).status, 200)
|
||||
const gespeichert = JSON.parse(nachweise()[0].texte).find((t) => t.id === 'knopf')
|
||||
assert.deepEqual(gespeichert, { id: 'knopf', text: knopf, haken: false })
|
||||
// die Haken der Bestellseite sind die Haken des Nachweises
|
||||
assert.deepEqual(einstellungen.body.bestellung.consent.items.map((i) => i.text), JSON.parse(nachweise()[0].texte).filter((t) => t.haken).map((t) => t.text))
|
||||
})
|
||||
|
||||
test('alte_fassung_409: ein Body mit alter Fassung wird mit der neuen Fassung und allen Texten abgewiesen, ohne Spur', async () => {
|
||||
bereit()
|
||||
const res = await checkout({ zustimmung: { version: '2026-10-02', haken: T.consent.items.map((i) => i.id) } })
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.code, 'fassung_veraltet')
|
||||
assert.equal(res.body.consent.version, V)
|
||||
assert.deepEqual(res.body.consent.items.map((i) => i.id), ['unterlagen', 'gesundheit', 'beginn', 'information'])
|
||||
assert.equal(res.body.consent.items[0].text, T.consent.items[0].text)
|
||||
assert.equal(res.body.consent.knopf, T.consent.knopf)
|
||||
assert.equal(sitzungen().length, 0)
|
||||
assert.equal(nachweise().length, 0)
|
||||
assert.equal(termineDoc(), undefined)
|
||||
// fehlt ein Haken, ist es 400 (die Fassung ist die aktuelle)
|
||||
const zuWenig = await checkout({ zustimmung: { version: V, haken: ['unterlagen', 'gesundheit', 'beginn'] } })
|
||||
assert.equal(zuWenig.status, 400)
|
||||
assert.equal(zuWenig.body.code, 'zustimmung_fehlt')
|
||||
})
|
||||
@@ -239,12 +239,17 @@ test('Fassungen aus dem Register: Zustimmung plus die vorhandenen Fassungsfelder
|
||||
const news = featureConfig('news')
|
||||
assert.deepEqual(zustimmungsfassungen(news), { zustimmung: news.consent.version, leistungsbeschreibung: news.leistungsbeschreibung.version })
|
||||
const termine = featureConfig('termine')
|
||||
// Terminplaner: Zustimmung, Leistungsbeschreibung, AVV und Widerrufsbelehrung tragen alle die Fassung des Registers (Task F7)
|
||||
assert.deepEqual(zustimmungsfassungen(termine), {
|
||||
zustimmung: termine.consent.version,
|
||||
leistungsbeschreibung: termine.leistungsbeschreibung.version,
|
||||
avv: termine.avv.version,
|
||||
widerruf: termine.widerruf.version,
|
||||
})
|
||||
// widerruf kommt dazu, sobald das Register es führt
|
||||
assert.equal(termine.widerruf.version, config.termine.avvVersion)
|
||||
// ohne widerruf im Register fehlt der Eintrag (News führt keine Widerrufsbelehrung)
|
||||
assert.deepEqual(Object.keys(zustimmungsfassungen({ ...termine, widerruf: null })), ['zustimmung', 'leistungsbeschreibung', 'avv'])
|
||||
// ein anderer Stand der Belehrung steht im Nachweis
|
||||
const mitWiderruf = { ...termine, widerruf: { version: 'W1', url: '/x' } }
|
||||
assert.deepEqual(zustimmungsfassungen(mitWiderruf), {
|
||||
zustimmung: termine.consent.version,
|
||||
|
||||
+130
-10
@@ -8,7 +8,7 @@ import { schemaKeys } from './support/schema.js'
|
||||
// Mails K15 (Bestellung eingegangen) und K16 (Funktion bereit) je Funktion (Task B9a). Der News-Wortlaut ist als Literal
|
||||
// eingefroren (vor dem Umbau der Vorlagen aus den damaligen Mails uebernommen): "News-Mails bleiben woertlich".
|
||||
// B9b ergaenzt T16 (Team-Hinweis je Funktion) und T19 (Erstattung faellig) in dieser Datei. websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema.
|
||||
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } })
|
||||
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), termineConfig: schemaKeys('termineConfig') } })
|
||||
const st = await startFakeStripe()
|
||||
after(() => Promise.all([aw.close(), st.close()]))
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
@@ -145,7 +145,7 @@ test('News-Mails bleiben wörtlich: K15 mit Rechnung', async () => {
|
||||
'',
|
||||
'Funktion: News-Pinnwand – Einrichtung',
|
||||
'Projekt: Bäckerei',
|
||||
'Einmalig bezahlt: 30,00 € (brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)',
|
||||
'Einmalig bezahlt: 30,00 € (einmalig, ohne Umsatzsteuer gemäß § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo)',
|
||||
'Einrichtung: meist innerhalb von 1–2 Wochen',
|
||||
'',
|
||||
'Wie geht es weiter?',
|
||||
@@ -180,7 +180,7 @@ test('News-Mails bleiben wörtlich: K15 ohne Rechnung', async () => {
|
||||
'',
|
||||
'Funktion: News-Pinnwand – Einrichtung',
|
||||
'Projekt: Café',
|
||||
'Einmalig bezahlt: 30,00 € (brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)',
|
||||
'Einmalig bezahlt: 30,00 € (einmalig, ohne Umsatzsteuer gemäß § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo)',
|
||||
'Einrichtung: meist innerhalb von 1–2 Wochen',
|
||||
'',
|
||||
'Wie geht es weiter?',
|
||||
@@ -365,7 +365,7 @@ test('Termine-T16: Betreff, Text und Knopf nennen den Terminplaner', async () =>
|
||||
assert.equal(link, reg.anleitungsLink('c1', projectId))
|
||||
assert.ok(mail.text.includes(`Im Ticketsystem öffnen: ${link}`), mail.text)
|
||||
assert.ok(mail.html.includes(`href="${link.replaceAll('&', '&')}"`), mail.html)
|
||||
// die Warnblöcke bleiben bei einem ordentlich bezahlten Kauf aus (kein_abo nur ohne laufendes Abo, bereits_aktiv nur bei einer Zahlung auf ein vorhandenes Dokument: featurePaid.test.js; gesundheit füllt D4)
|
||||
// die Warnblöcke bleiben bei einem ordentlich bezahlten Kauf aus (kein_abo nur ohne laufendes Abo, bereits_aktiv nur bei einer Zahlung auf ein vorhandenes Dokument: featurePaid.test.js; gesundheit nur bei Treffern der Stichwortprüfung: T16 Gesundheit unten)
|
||||
for (const warnung of ['Zahlung fehlgeschlagen', 'Bezahlt, aber nicht zuzuordnen', 'Kein laufendes Wartungs-Abo', 'Gesundheitsbereich prüfen', 'Bezahlt, aber schon vorhanden']) {
|
||||
assert.ok(!mail.text.includes(warnung), `kein Block "${warnung}"`)
|
||||
}
|
||||
@@ -428,6 +428,116 @@ test('T16 gesundheit: Block aus ohne Warntext, Block an mit dem Text', () => {
|
||||
assert.ok(an.text.includes('Gesundheitsbereich prüfen: Branche Physiotherapie: Gesundheitsdaten möglich'), an.text)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------- T16 Block gesundheit (Termine, Task D4) ---
|
||||
const { ausVorlage } = await import('../server/services/termine/vorlagen.js')
|
||||
const GESUNDHEIT_ZEILE = 'Gesundheitsbereich prüfen: '
|
||||
|
||||
/** Einstellung eines Salons mit den Namen und Beschreibungen der Leistungen, die der Test braucht. */
|
||||
function salon(leistungen = []) {
|
||||
const e = ausVorlage('salon', { name: 'Salon Haarlinie', email: 'info@haarlinie.example', telefon: '06301 1234', adresse: 'Hauptstraße 1, 67722 Winnweiler', bundesland: 'RP' }, new Date('2026-10-05T10:00:00Z'))
|
||||
leistungen.forEach(([name, beschreibung], i) => Object.assign(e.leistungen[i], { name, beschreibung: beschreibung ?? '' }))
|
||||
return e
|
||||
}
|
||||
|
||||
/** Termine bestellen (mit einer Einstellung im termineConfig-Dokument), bezahlen und die T16-Mail liefern. */
|
||||
async function t16MitEinstellung(einstellung, { fehlerBeimLesen = false } = {}) {
|
||||
if (!aw.docs('subscriptions').has('sub_w_mails')) {
|
||||
aw.seed('subscriptions', { $id: 'sub_w_mails', stripeSubscriptionId: 'sub_w_mails', customerId: 'c1', productKey: 'wartung', status: 'active' })
|
||||
}
|
||||
const { projectId, sessionId } = await bestelle('termine', 'Salon Haarlinie')
|
||||
const doc = await sf.getFeatureDoc('c1', projectId, 'termine')
|
||||
if (einstellung) {
|
||||
aw.seed('termineConfig', { $id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(einstellung), version: 1, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: '' })
|
||||
}
|
||||
// der erste Lesezugriff gehoert dem Ticket (Kurzfassung), der zweite dem Hinweis fuer T16
|
||||
if (fehlerBeimLesen) aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500, after: 1 })
|
||||
const vor = mails.sent.length
|
||||
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine'))
|
||||
const t16 = t16Seit(vor)
|
||||
assert.equal(t16.length, 1, `genau eine T16-Mail: ${mails.sent.slice(vor).map((m) => m.subject)}`)
|
||||
const gesundheit = t16[0].text.split('\n').find((z) => z.startsWith(GESUNDHEIT_ZEILE))
|
||||
return { mail: t16[0], doc: await sf.getFeatureDoc('c1', projectId, 'termine'), gesundheit: gesundheit ? gesundheit.slice(GESUNDHEIT_ZEILE.length) : null }
|
||||
}
|
||||
|
||||
test('T16 Termine mit Leistung Physiotherapie: Warnblock "Gesundheitsbereich prüfen" mit dem Fundort, Ticket und Mail gehen raus', async () => {
|
||||
const { mail, doc, gesundheit } = await t16MitEinstellung(salon([['Physiotherapie']]))
|
||||
assert.equal(gesundheit, 'Leistungsname enthält „Physiotherapie“')
|
||||
assert.ok(mail.html.includes('Gesundheitsbereich prüfen: Leistungsname enthält „Physiotherapie“'), mail.html)
|
||||
assert.ok(doc.ticketId && aw.docs('workorders').get(doc.ticketId).details.includes('Gesundheitsbereich prüfen:'), 'das Ticket nennt es ebenfalls')
|
||||
// die uebrigen Bloecke sind unveraendert: Ticket, Knopf zur Anleitung, kein kein_abo
|
||||
assert.ok(mail.text.includes('Im Ticketsystem öffnen:'))
|
||||
assert.ok(!mail.text.includes('Kein laufendes Wartungs-Abo'))
|
||||
})
|
||||
|
||||
test('T16 Termine ohne Treffer oder ohne Einstellungen: kein Gesundheitsblock', async () => {
|
||||
const ohneTreffer = await t16MitEinstellung(salon([['Herrenschnitt', 'Schneiden und Föhnen']]))
|
||||
assert.equal(ohneTreffer.gesundheit, null)
|
||||
assert.ok(!ohneTreffer.mail.text.includes('Gesundheitsbereich'))
|
||||
const ohneConfig = await t16MitEinstellung(null)
|
||||
assert.equal(ohneConfig.gesundheit, null)
|
||||
assert.ok(!ohneConfig.mail.text.includes('Gesundheitsbereich') && !ohneConfig.mail.html.includes('Gesundheitsbereich'))
|
||||
})
|
||||
|
||||
test('T16 Termine: scheitert das Lesen der Einstellung, geht T16 trotzdem ohne Block raus (nie Geld ohne Spur)', async () => {
|
||||
const protokoll = []
|
||||
const alt = console.error
|
||||
console.error = (...args) => protokoll.push(args.join(' '))
|
||||
try {
|
||||
const { mail, doc, gesundheit } = await t16MitEinstellung(salon([['Physiotherapie']]), { fehlerBeimLesen: true })
|
||||
assert.equal(gesundheit, null)
|
||||
assert.ok(mail.text.includes('Neue Funktion bestellt: Terminplaner') && mail.text.includes('Im Ticketsystem öffnen:'))
|
||||
assert.ok(doc.ticketId, 'das Ticket entstand')
|
||||
assert.equal(protokoll.filter((z) => /Gesundheitshinweis/.test(z)).length, 1, protokoll.join('\n'))
|
||||
assert.ok(!protokoll.join('\n').includes('Physio'), 'das Log nennt keine Daten des Betriebs')
|
||||
} finally {
|
||||
console.error = alt
|
||||
}
|
||||
})
|
||||
|
||||
test('T16 Termine: Namen des Kunden werden als Text eingesetzt (HTML maskiert, nichts Halbes), bei vielen Treffern hoechstens 500 Zeichen', async () => {
|
||||
const boese = salon([
|
||||
[`<img src=x onerror=alert(1)> \uD83D${'x'.repeat(5)}`, 'Physiotherapie'],
|
||||
['Zeile1\n## Zeile2', 'Therapie'],
|
||||
])
|
||||
const eins = await t16MitEinstellung(boese)
|
||||
assert.ok(eins.gesundheit.includes('<img src=x onerror=alert(1)>'), eins.gesundheit)
|
||||
assert.ok(!eins.mail.html.includes('<img src=x'), 'im HTML maskiert')
|
||||
assert.ok(eins.mail.html.includes('<img src=x onerror=alert(1)>'))
|
||||
assert.ok(eins.mail.text.isWellFormed() && eins.mail.html.isWellFormed())
|
||||
assert.ok(!eins.gesundheit.includes('\n'))
|
||||
|
||||
// 10 Treffer: gekuerzt auf ganze Meldungen und "und N weitere", hoechstens 500 Zeichen
|
||||
const viele = salon(Array.from({ length: 4 }, (_, i) => [`${String(i)} ${'N'.repeat(36)}`, 'Physiotherapie']))
|
||||
viele.leistungen.push(...Array.from({ length: 7 }, (_, i) => ({ ...viele.leistungen[0], id: `s_v${i}`, name: `${String(i + 4)} ${'M'.repeat(36)}`, beschreibung: 'Physiotherapie' })))
|
||||
const zehn = await t16MitEinstellung(viele)
|
||||
assert.ok(zehn.gesundheit.length <= 500, String(zehn.gesundheit.length))
|
||||
assert.match(zehn.gesundheit, / … und \d+ weitere$/)
|
||||
assert.ok(zehn.gesundheit.startsWith('Beschreibung der Leistung „0 NNN'), zehn.gesundheit)
|
||||
})
|
||||
|
||||
test('T16 Termine, Zahlung fehlgeschlagen: kein Gesundheitsblock (es wurde nichts bestellt), auch mit Treffer in den Einstellungen', async () => {
|
||||
const { projectId, sessionId } = await bestelle('termine', 'Salon Haarlinie')
|
||||
const doc = await sf.getFeatureDoc('c1', projectId, 'termine')
|
||||
aw.seed('termineConfig', { $id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(salon([['Physiotherapie']])), version: 1, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: '' })
|
||||
const vor = mails.sent.length
|
||||
await sf.handleFeatureCheckoutEvent(failedEvent(sessionId, projectId, 'termine'))
|
||||
const t16 = t16Seit(vor)
|
||||
assert.equal(t16.length, 1)
|
||||
assert.match(t16[0].text, /Zahlung fehlgeschlagen/)
|
||||
assert.ok(!t16[0].text.includes('Gesundheitsbereich') && !t16[0].html.includes('Gesundheitsbereich'))
|
||||
})
|
||||
|
||||
test('T16 News: nie ein Gesundheitsblock, auch wenn unter der ID der News-Funktion ein Einstellungsdokument mit Treffer laege', async () => {
|
||||
const { projectId, sessionId } = await bestelle('news', 'Bäckerei Physiotherapie')
|
||||
const doc = await sf.getFeatureDoc('c1', projectId, 'news')
|
||||
aw.seed('termineConfig', { $id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(salon([['Physiotherapie']])), version: 1, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: '' })
|
||||
const vor = mails.sent.length
|
||||
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'news'))
|
||||
const t16 = t16Seit(vor)
|
||||
assert.equal(t16.length, 1)
|
||||
assert.ok(!t16[0].text.includes('Gesundheitsbereich') && !t16[0].html.includes('Gesundheitsbereich'))
|
||||
})
|
||||
|
||||
test('T16 anleitung: Knopf nur mit Block, Link trägt &anleitung=<Projekt-ID>', () => {
|
||||
const link = reg.anleitungsLink('c1', 'p42')
|
||||
assert.ok(link.endsWith('&anleitung=p42'))
|
||||
@@ -501,8 +611,13 @@ test('Platzhalter-/Blocklisten exakt: K15 und K16', () => {
|
||||
'danach',
|
||||
'portal_url',
|
||||
'rechnung_url',
|
||||
'fassung',
|
||||
'lb_url',
|
||||
'avv_url',
|
||||
'widerruf_url',
|
||||
'zustimmungen_text',
|
||||
])
|
||||
assert.deepEqual(k15.blocks, ['rechnung', 'behalten', 'neu'])
|
||||
assert.deepEqual(k15.blocks, ['rechnung', 'behalten', 'neu', 'anlagen', 'anhang', 'zustimmungen'])
|
||||
assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}')
|
||||
const k16 = loadTemplate('K16')
|
||||
assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url'])
|
||||
@@ -514,7 +629,7 @@ test('Platzhalter-/Blocklisten exakt: T16 und T19', () => {
|
||||
const t16 = loadTemplate('T16')
|
||||
assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}')
|
||||
assert.deepEqual(t16.placeholders, ['kunde_name', 'email', 'projekt', 'funktion', 'woid', 'sitekey', 'sitzung', 'zustand', 'anleitung_url', 'gesundheit_text'])
|
||||
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team'])
|
||||
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team', 'anhang_fehlt'])
|
||||
const t19 = loadTemplate('T19')
|
||||
assert.equal(t19.file, 'T19 Erstattung faellig.html')
|
||||
assert.equal(t19.subject, '[Abo] Erstattung fällig: {{funktion}}-Einrichtung')
|
||||
@@ -533,8 +648,9 @@ test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab',
|
||||
assert.equal(k16.template, 'K16')
|
||||
assert.deepEqual(Object.keys(k15.values).sort(), [...loadTemplate('K15').placeholders].sort(), `${key}: K15`)
|
||||
assert.deepEqual(Object.keys(k16.values).sort(), [...loadTemplate('K16').placeholders].sort(), `${key}: K16`)
|
||||
assert.deepEqual(k15.blocks, { rechnung: true, behalten: false, neu: true })
|
||||
assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false, behalten: false, neu: true })
|
||||
// ohne Rechtsunterlagen im Aufruf (F8: onFeaturePaid übergibt sie für den Terminplaner) bleiben anlagen, anhang und zustimmungen aus
|
||||
assert.deepEqual(k15.blocks, { rechnung: true, anlagen: false, anhang: false, zustimmungen: false, behalten: false, neu: true })
|
||||
assert.deepEqual(sf.bestellMail(feature, { ...ctx, invoiceUrl: '' }).blocks, { rechnung: false, anlagen: false, anhang: false, zustimmungen: false, behalten: false, neu: true })
|
||||
for (const nachricht of [k15, k16]) {
|
||||
const mail = renderMail(nachricht.template, nachricht.values, nachricht.blocks)
|
||||
assert.deepEqual(mail.problems, [], `${key}: ${nachricht.template}`)
|
||||
@@ -547,8 +663,12 @@ test('Die Mail-Werte je Funktion decken genau die Platzhalter der Vorlagen ab',
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------ Anhänge ---
|
||||
test('K15 ohne Anhänge: die Termine-Bestellung ruft keinen Registerhaken', async () => {
|
||||
// Die Vertragsunterlagen des Terminplaners hängen seit F8 an K15 (legalDocs.featureLegalAttachments, Tests in funktionenRecht.test.js).
|
||||
// Diese Probe hält fest, dass das nicht über einen Registerhaken läuft: kein mails.anhaenge, und die Haken des Eintrags werden bei der
|
||||
// Zahlung nicht gerufen.
|
||||
test('K15 mit Anhängen aus legalDocs: die Termine-Bestellung ruft keinen Registerhaken, mails.anhaenge gibt es nicht', async () => {
|
||||
const termine = config.features.termine
|
||||
assert.equal(Object.hasOwn(termine.mails, 'anhaenge'), false)
|
||||
const aufrufe = []
|
||||
const alt = {}
|
||||
const spion = (name) => (...args) => {
|
||||
@@ -562,7 +682,7 @@ test('K15 ohne Anhänge: die Termine-Bestellung ruft keinen Registerhaken', asyn
|
||||
termine.mails.anhaenge = spion('mails.anhaenge')
|
||||
try {
|
||||
const { mail } = await bezahle('termine', 'Anhang-Test')
|
||||
assert.ok(!mail.attachments || mail.attachments.length === 0, 'K15 trägt keinen Anhang')
|
||||
assert.equal(mail.attachments.length, 3, 'K15 trägt die drei PDFs der Vertragsunterlagen')
|
||||
assert.deepEqual(aufrufe, [])
|
||||
} finally {
|
||||
for (const name of Object.keys(alt)) {
|
||||
|
||||
@@ -15,10 +15,11 @@ const reg = await import('../server/services/featureRegistry.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
const { createFeatureTicket } = await import('../server/services/ticketAdmin.js')
|
||||
|
||||
// Wortlaut-Pin des News-Satzes und der Fassung (der einzige Literal-Pin auf Texte, die Block F2 ändert)
|
||||
// Wortlaut-Pin des News-Satzes und der Fassung (der einzige Literal-Pin auf Texte, die Block F2 ändert); die Fassung ist der
|
||||
// Tag <F> = config.termine.avvVersion, beim Anheben von <F> ändert sich auch dieser Pin
|
||||
const NEWS_SATZ =
|
||||
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).'
|
||||
const NEWS_FASSUNG = '2026-09-30'
|
||||
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus 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 NEWS_FASSUNG = '2026-10-05'
|
||||
|
||||
const SITE_KEY = 'abcdefghijklmnop'
|
||||
|
||||
@@ -234,7 +235,9 @@ test('Termine: Preis und Struktur', async () => {
|
||||
assert.ok(item.text.length > 0)
|
||||
assert.equal(item.url, undefined, 'ohne url')
|
||||
}
|
||||
assert.equal(t.widerruf, null)
|
||||
// Task F7: die Widerrufsbelehrung der Funktionen trägt die Fassung des Registers und die versionierte Adresse
|
||||
assert.deepEqual(t.widerruf, { version: config.termine.avvVersion, url: `/rechtliches/widerrufsbelehrung-funktionen-${config.termine.avvVersion}.html` })
|
||||
assert.equal(t.consent.version, config.termine.avvVersion)
|
||||
assert.equal(t.returnHash, 'termine')
|
||||
assert.equal(t.returnProjekt, true)
|
||||
assert.equal(t.requiresWartungsAbo, true)
|
||||
@@ -280,7 +283,7 @@ test('Termine-Ticketdetails werden erst beim Aufruf geladen', async () => {
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------- Block F ---
|
||||
test('Bestelltexte ohne ENTWURF', { todo: 'Block F ersetzt die Platzhaltertexte' }, async () => {
|
||||
test('Bestelltexte ohne ENTWURF', async () => {
|
||||
const t = config.features.termine
|
||||
const frei = (wert, was) => assert.doesNotMatch(JSON.stringify(wert), /ENTWURF/, `${was} enthält ENTWURF`)
|
||||
frei(t.consent.version, 'consent.version')
|
||||
@@ -289,7 +292,7 @@ test('Bestelltexte ohne ENTWURF', { todo: 'Block F ersetzt die Platzhaltertexte'
|
||||
frei(t.avv, 'avv')
|
||||
frei(t.widerruf, 'widerruf')
|
||||
// mails.danach und mails.bereit schreibt der Inhaber in seiner Mailrunde
|
||||
// ab B7 gibt es den Haken belehrung (Platzhaltertext bis F7)
|
||||
// der Haken belehrung liefert seit F7 die Widerrufsbelehrung der Funktionen
|
||||
const belehrung = reg.featureHaken('termine').belehrung
|
||||
if (belehrung) frei(await belehrung(), 'belehrung')
|
||||
})
|
||||
@@ -210,24 +210,15 @@ test('Termine-Ticket aus dem Register', async () => {
|
||||
assert.ok(ticket.details.includes(`?tab=funktionen&anleitung=${projectId}`))
|
||||
assert.ok(ticket.details.includes('Kunde: Kunde Eins'))
|
||||
assert.ok(ticket.details.includes('Domain: haarlinie.de'))
|
||||
assert.match(ticket.details, /Einrichtung bezahlt \(149 €\), Zeit nicht auf das Stundenkontingent anrechnen\.$/)
|
||||
// Task D4: die Kurzfassung der Einbauanleitung (ohne Einstellungen: Kopf, Snippet, Schritte, Verweis); Inhalt in test/termineTicket.test.js
|
||||
assert.ok(ticket.details.includes('Funktion: Terminplaner\nProjekt: Salon Haarlinie\nDomain: haarlinie.de\nKunde: Kunde Eins\n'), ticket.details)
|
||||
assert.ok(ticket.details.includes('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'))
|
||||
assert.ok(ticket.details.includes(`data-webklar-termine="${doc.siteKey}"`))
|
||||
assert.ok(ticket.details.includes('Nicht auf das Stundenkontingent anrechnen'))
|
||||
assert.ok(ticket.details.endsWith(`Verweis: ${reg.anleitungsLink('c1', projectId)}`))
|
||||
assert.equal(ticket.customerId, 'c1')
|
||||
})
|
||||
|
||||
test('Stub termineTicketDetails: Minimal-Details plus Hinweis zum Stundenkontingent', async () => {
|
||||
const { termineTicketDetails } = await import('../server/services/termine/ticketDetails.js')
|
||||
const ctx = {
|
||||
feature: reg.featureConfig('termine'),
|
||||
doc: { customerId: 'c1', projectId: 'p9', siteKey: 'abcdefghijklmnop' },
|
||||
project: { projectName: 'Salon' },
|
||||
customer: null,
|
||||
}
|
||||
assert.equal(
|
||||
await termineTicketDetails(ctx),
|
||||
`${reg.minimaleTicketDetails(ctx)}\nEinrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.`
|
||||
)
|
||||
})
|
||||
|
||||
// ----------------------------------------------------------------------------------- Deckel ---
|
||||
test('Deckel: Projektname mit 255 Zeichen', async () => {
|
||||
const { sessionId } = await bestelle('termine', { projectName: 'P'.repeat(255) })
|
||||
|
||||
@@ -33,6 +33,7 @@ const reg = await import('../server/services/featureRegistry.js')
|
||||
const order = await import('../server/services/featureOrder.js')
|
||||
const consents = await import('../server/services/featureConsents.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
const { widerrufsbelehrungFunktion } = await import('../server/services/termine/rechtstexte.js')
|
||||
|
||||
const app = await startTestApp({ '/api/funktionen': funktionenRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
@@ -661,15 +662,18 @@ test('Nachweis steht vor der Weiterleitung: Serverfassungen und -texte in Reihen
|
||||
|
||||
test('Belehrung im Nachweis: Eintrag belehrung (haken false) direkt vor dem Knopf, News hat keinen', async () => {
|
||||
bereit()
|
||||
assert.equal(reg.BELEHRUNG_PLATZHALTER, 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.')
|
||||
assert.equal(await reg.featureHaken('termine').belehrung(), reg.BELEHRUNG_PLATZHALTER)
|
||||
// Task F7: der Haken liefert die Widerrufsbelehrung der Funktionen samt Muster-Widerrufsformular (Task F3)
|
||||
const belehrung = widerrufsbelehrungFunktion()
|
||||
assert.equal(await reg.featureHaken('termine').belehrung(), belehrung)
|
||||
assert.match(belehrung, /^Widerrufsbelehrung\n\nWiderrufsrecht\n\nSie haben das Recht/)
|
||||
assert.equal(reg.featureHaken('news').belehrung, undefined)
|
||||
await checkout('termine', bodyT())
|
||||
const texte = JSON.parse(nachweise()[0].texte)
|
||||
assert.deepEqual(texte.slice(-2), [
|
||||
{ id: 'belehrung', text: reg.BELEHRUNG_PLATZHALTER, haken: false },
|
||||
{ id: 'belehrung', text: belehrung, haken: false },
|
||||
{ id: 'knopf', text: T.consent.knopf, haken: false },
|
||||
])
|
||||
assert.equal(texte.length, 4 + 2, 'vier Haken, Belehrung, Knopf')
|
||||
assert.deepEqual(texte.map((x) => x.id), [...T.consent.items.map((x) => x.id), 'belehrung', 'knopf'])
|
||||
|
||||
const news = await checkout('news', { projectId: 'p3', consent: true }, customerCookie('c3'))
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, copyFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { test, after, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
|
||||
// Rechtstext-Korrekturen der News-Pinnwand (Task F2): Zitat § 356 Abs. 5 Nr. 2, Wertersatz-Satz, § 19 UStG statt "brutto".
|
||||
// Die Wortlaute sind hier als Literale eingefroren (Wortlaut-Pin); die Fassung folgt config.termine.avvVersion.
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents') },
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, get, post } = await import('./support/testApp.js')
|
||||
const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
|
||||
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { mailSink } = await import('./support/mailSink.js')
|
||||
|
||||
const app = await startTestApp({ '/api/funktionen': funktionenRoutes })
|
||||
const mails = mailSink()
|
||||
setMailTransportForTests(mails)
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([app.close(), aw.close(), st.close()])
|
||||
})
|
||||
seedFixtures(aw)
|
||||
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' })
|
||||
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café' })
|
||||
const cookie = customerCookie('c1')
|
||||
|
||||
const root = (rel) => new URL(`../${rel}`, import.meta.url)
|
||||
const lesen = (rel) => readFileSync(root(rel), 'utf8')
|
||||
|
||||
const NEWS_SATZ =
|
||||
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus 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 ALTER_SATZ =
|
||||
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).'
|
||||
const USTHINWEIS = 'ohne Umsatzsteuer gemäß § 19 UStG'
|
||||
|
||||
test('news_zustimmung_wortlaut: Register = Alias = exakter Text mit Abs. 5 Nr. 2 und § 357a Abs. 2, Fassung = <F>', () => {
|
||||
const news = config.features.news
|
||||
assert.deepEqual(news.consent.items, [{ id: 'beginn', haken: true, text: NEWS_SATZ }])
|
||||
assert.equal(config.funktionen.consentText, NEWS_SATZ)
|
||||
assert.equal(news.consent.items[0].text, config.funktionen.consentText)
|
||||
assert.ok(NEWS_SATZ.includes('Abs. 5 Nr. 2') && NEWS_SATZ.includes('§ 357a Abs. 2'))
|
||||
assert.ok(!NEWS_SATZ.includes('Abs. 4'))
|
||||
assert.equal(news.consent.knopf, 'Zahlungspflichtig bestellen · 30 €')
|
||||
// die Fassung folgt der Konstante <F> (zur Laufzeit), auch die der Leistungsbeschreibung, deren Stand sich mit dem Text ändert
|
||||
assert.equal(news.consent.version, config.termine.avvVersion)
|
||||
assert.equal(news.leistungsbeschreibung.version, config.termine.avvVersion)
|
||||
assert.ok(news.consent.version > '2026-10-02')
|
||||
const alt = config.termine.avvVersion
|
||||
config.termine.avvVersion = '2099-01-02'
|
||||
try {
|
||||
assert.equal(news.consent.version, '2099-01-02')
|
||||
assert.equal(news.leistungsbeschreibung.version, '2099-01-02')
|
||||
} finally {
|
||||
config.termine.avvVersion = alt
|
||||
}
|
||||
assert.equal(news.leistungsbeschreibung.url, '/vertragsunterlagen/funktionen-leistungsbeschreibung.html')
|
||||
assert.equal(news.mails.preisHinweis, `einmalig, ${USTHINWEIS}; die Nutzung ist kostenlos, es läuft kein Abo`)
|
||||
})
|
||||
|
||||
test('waechter_zitat_und_preistext: kein altes Zitat, keine Mehrwertsteuer-Angabe, kein "brutto" in den Kundentexten', () => {
|
||||
const dateien = [
|
||||
'server/config.js',
|
||||
'public/vertragsunterlagen/funktionen-leistungsbeschreibung.html',
|
||||
'public/funktionen.js',
|
||||
'server/mail-templates/K15 Bestellung Funktion.html',
|
||||
'server/services/stripeBilling.js',
|
||||
]
|
||||
for (const datei of dateien) {
|
||||
const text = lesen(datei)
|
||||
assert.doesNotMatch(text, /§ 356 Abs\. 4/, `${datei}: altes Zitat`)
|
||||
assert.doesNotMatch(text, /inklusive (Mehr|Umsatz)steuer/i, `${datei}: inklusive Steuer`)
|
||||
assert.doesNotMatch(text, /inklusive Mehrwertsteuer/i, `${datei}: inklusive Mehrwertsteuer`)
|
||||
assert.doesNotMatch(text, /\bbrutto\b/i, `${datei}: brutto`)
|
||||
}
|
||||
const lb = lesen('public/vertragsunterlagen/funktionen-leistungsbeschreibung.html')
|
||||
assert.equal(lb.split(USTHINWEIS).length - 1, 2, 'LB nennt den Hinweis in Kopf-Tabelle und Abschnitt 2')
|
||||
assert.ok(lb.includes('<td>30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), keine laufenden Kosten</td>'))
|
||||
assert.ok(lb.includes('Der Einbau kostet 30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG).'))
|
||||
assert.ok(lb.includes('erlischt nach § 356 Abs. 5 Nr. 2 BGB'))
|
||||
assert.ok(lb.includes('(§ 357a Abs. 2 BGB)'))
|
||||
assert.ok(lb.includes('<!-- ENTWURF, Freigabe Nutzer ausstehend -->'), 'die Freigabe bleibt Sache des Inhabers')
|
||||
// der Stand der LB ist der Tag der Fassung <F>
|
||||
const [j, m, t] = config.termine.avvVersion.split('-')
|
||||
assert.ok(lb.includes(`<p class="meta">Stand ${t}.${m}.${j}</p>`), 'Stand der LB = config.termine.avvVersion')
|
||||
})
|
||||
|
||||
test('bestellseite_hinweis: GET /api/funktionen liefert ustHinweis', async () => {
|
||||
const body = await get('/api/funktionen', { cookie })
|
||||
assert.equal(body.ustHinweis, config.ustHinweis)
|
||||
assert.equal(config.ustHinweis, USTHINWEIS)
|
||||
assert.equal(body.consentText, NEWS_SATZ)
|
||||
assert.equal(body.features.news.consent.items[0].text, NEWS_SATZ)
|
||||
assert.equal(body.features.news.consent.version, config.termine.avvVersion)
|
||||
})
|
||||
|
||||
// ---- Client: public/funktionen.js wirklich laden (absolute Importe auf die Dateien umleiten) und die Buchungsseite rendern
|
||||
const dir = mkdtempSync(join(tmpdir(), 'fn-recht-'))
|
||||
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
|
||||
const clientQuelle = lesen('public/funktionen.js')
|
||||
.replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`)
|
||||
.replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`)
|
||||
.replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`)
|
||||
const clientDatei = join(dir, 'funktionen.mjs')
|
||||
writeFileSync(clientDatei, clientQuelle)
|
||||
const { initNews } = await import(pathToFileURL(clientDatei).href)
|
||||
|
||||
function fakeEl() {
|
||||
const handlers = {}
|
||||
return { innerHTML: '', textContent: '', disabled: false, checked: false, value: '', dataset: {}, classList: { add() {}, remove() {}, toggle() {} }, handlers, addEventListener(ev, fn) { handlers[ev] = fn }, querySelector: () => null, querySelectorAll: () => [] }
|
||||
}
|
||||
async function renderBuchung(antwort) {
|
||||
const echtesFetch = globalThis.fetch
|
||||
globalThis.window = { location: { hash: '#news', pathname: '/dashboard.html', search: '', href: '' }, history: { replaceState() {} } }
|
||||
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => antwort })
|
||||
const els = {}
|
||||
const stage = { innerHTML: '', els, querySelector: (sel) => (els[sel] ||= fakeEl()), querySelectorAll: () => [] }
|
||||
const rootEl = fakeEl()
|
||||
rootEl.querySelector = (sel) => (sel === '#news-stage' ? stage : null)
|
||||
try {
|
||||
await initNews(rootEl, { projectId: 'p1' })
|
||||
return stage
|
||||
} finally {
|
||||
delete globalThis.window
|
||||
globalThis.fetch = echtesFetch
|
||||
}
|
||||
}
|
||||
const frei = { projectId: 'p1', projectName: 'Muster', features: { news: { state: 'off' } } }
|
||||
|
||||
test('bestellseite_hinweis (Client): Preis mit Hinweis aus ustHinweis, per textContent; Rückfall = config.ustHinweis; kein "inklusive Mehrwertsteuer"', async () => {
|
||||
const stage = await renderBuchung({ projects: [frei], consentText: NEWS_SATZ, ustHinweis: 'Testhinweis § 19' })
|
||||
assert.match(stage.innerHTML, /<strong>30 €<\/strong>/)
|
||||
assert.doesNotMatch(stage.innerHTML, /Mehrwertsteuer|inklusive|brutto/i)
|
||||
assert.doesNotMatch(stage.innerHTML, /Testhinweis/, 'der Hinweis steht nie im HTML-Text, nur als textContent')
|
||||
assert.equal(stage.els['#news-ust-hinweis'].textContent, 'Testhinweis § 19')
|
||||
assert.match(stage.innerHTML, /einmalig, <span id="news-ust-hinweis"><\/span>/)
|
||||
|
||||
// ein Hinweis mit Markup bleibt Text
|
||||
const bose = await renderBuchung({ projects: [frei], ustHinweis: '<img src=x onerror=1>' })
|
||||
assert.doesNotMatch(bose.innerHTML, /<img/)
|
||||
assert.equal(bose.els['#news-ust-hinweis'].textContent, '<img src=x onerror=1>')
|
||||
|
||||
// fehlt der Hinweis in der Antwort, gilt der Rückfall im Client: derselbe Wortlaut wie config.ustHinweis
|
||||
const rueck = await renderBuchung({ projects: [frei], consentText: NEWS_SATZ })
|
||||
assert.equal(rueck.els['#news-ust-hinweis'].textContent, config.ustHinweis)
|
||||
for (const leer of ['', ' ', 5, null]) {
|
||||
const r = await renderBuchung({ projects: [frei], ustHinweis: leer })
|
||||
assert.equal(r.els['#news-ust-hinweis'].textContent, config.ustHinweis, String(leer))
|
||||
}
|
||||
})
|
||||
|
||||
// ---- K15 (Bestellbestätigung) der News
|
||||
const paidEvent = (sessionId, projectId, featureKey = 'news', customerId = 'c1') => ({
|
||||
id: `evt_${sessionId}`,
|
||||
object: 'event',
|
||||
type: 'checkout.session.completed',
|
||||
data: { object: { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', metadata: { customerId, projectId, featureKey } } },
|
||||
})
|
||||
|
||||
test('k15_news: Preiszeile mit § 19 UStG statt "brutto"', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Bistro' })
|
||||
await sf.markOrdered({ customerId: 'c1', projectId: 'p3', key: 'news', sessionId: 'cs_k15', consent: {} })
|
||||
const vor = mails.sent.length
|
||||
await sf.handleFeatureCheckoutEvent(paidEvent('cs_k15', 'p3'))
|
||||
const k15 = mails.sent.slice(vor).filter((m) => m.subject.startsWith('Deine Bestellung'))
|
||||
assert.equal(k15.length, 1)
|
||||
assert.ok(k15[0].text.includes(`Einmalig bezahlt: 30,00 € (einmalig, ${USTHINWEIS}; die Nutzung ist kostenlos, es läuft kein Abo)`), k15[0].text)
|
||||
assert.doesNotMatch(k15[0].text, /brutto/i)
|
||||
assert.doesNotMatch(k15[0].html, /brutto/i)
|
||||
assert.ok(k15[0].html.includes(USTHINWEIS))
|
||||
})
|
||||
|
||||
// ---- bestehende Bestellungen behalten Text und Fassung; neue speichern die Register-Werte
|
||||
test('alte_bestellung_unveraendert: altes Dokument bleibt, neue Bestellung speichert Register-Text und Fassung', async () => {
|
||||
aw.seed('portalFeatures', {
|
||||
$id: 'f_p2_news', // feste ID je (Projekt, Funktion)
|
||||
enabled: true,
|
||||
unlockedByPurchase: true,
|
||||
customerId: 'c1',
|
||||
projectId: 'p2',
|
||||
featureKey: 'news',
|
||||
state: 'active',
|
||||
purchaseStatus: 'paid',
|
||||
consentText: ALTER_SATZ,
|
||||
consentAt: '2026-09-30T10:00:00.000Z',
|
||||
orderedAt: '2026-09-30T10:00:00.000Z',
|
||||
})
|
||||
aw.seed('featureConsents', {
|
||||
$id: 'fc_alt',
|
||||
customerId: 'c1',
|
||||
projectId: 'p2',
|
||||
featureKey: 'news',
|
||||
stripeSessionId: 'cs_alt',
|
||||
acceptedAt: '2026-09-30T10:00:00.000Z',
|
||||
fassungen: JSON.stringify({ zustimmung: '2026-09-30', leistungsbeschreibung: '2026-09-30' }),
|
||||
texte: JSON.stringify([{ id: 'beginn', text: ALTER_SATZ, haken: true }]),
|
||||
})
|
||||
const alt = await sf.getFeatureDoc('c1', 'p2', 'news')
|
||||
assert.equal(alt.consentText, ALTER_SATZ)
|
||||
const liste = await get('/api/funktionen', { cookie })
|
||||
assert.equal(liste.projects.find((p) => p.projectId === 'p2').features.news.state, 'active')
|
||||
|
||||
const res = await post('/api/funktionen/news/checkout', { projectId: 'p1', consent: true }, { cookie })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const neu = await sf.getFeatureDoc('c1', 'p1', 'news')
|
||||
assert.equal(neu.consentText, NEWS_SATZ)
|
||||
const nachweise = [...aw.docs('featureConsents').values()].filter((d) => d.projectId === 'p1')
|
||||
assert.equal(nachweise.length, 1)
|
||||
assert.deepEqual(JSON.parse(nachweise[0].fassungen), { zustimmung: config.termine.avvVersion, leistungsbeschreibung: config.termine.avvVersion })
|
||||
const texte = JSON.parse(nachweise[0].texte)
|
||||
assert.equal(texte[0].text, NEWS_SATZ)
|
||||
|
||||
// das alte Dokument und sein Nachweis sind unverändert
|
||||
assert.equal((await sf.getFeatureDoc('c1', 'p2', 'news')).consentText, ALTER_SATZ)
|
||||
assert.deepEqual(JSON.parse(aw.docs('featureConsents').get('fc_alt').fassungen), { zustimmung: '2026-09-30', leistungsbeschreibung: '2026-09-30' })
|
||||
assert.equal(JSON.parse(aw.docs('featureConsents').get('fc_alt').texte)[0].text, ALTER_SATZ)
|
||||
})
|
||||
|
||||
|
||||
// ======================================================================================== Vertragsunterlagen (Task F8)
|
||||
// K15 des Terminplaners trägt Leistungsbeschreibung, AVV und Widerrufsbelehrung als PDF (dauerhafter Datenträger, § 312f BGB);
|
||||
// der Checkout fragt vorher, ob die Unterlagen abrufbar sind; die Bestellseite zeigt Belehrung und Links ohne Aufklappen.
|
||||
const consents = await import('../server/services/featureConsents.js')
|
||||
const { widerrufsbelehrungFunktion } = await import('../server/services/termine/rechtstexte.js')
|
||||
const { featureConfig } = await import('../server/services/featureRegistry.js')
|
||||
const { legalPaths, legalPdfFiles, setLegalDirForTests } = await import('../server/services/legalDocs.js')
|
||||
const { subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
|
||||
const T = config.features.termine
|
||||
const V = config.termine.avvVersion
|
||||
const BASE = config.publicBaseUrl
|
||||
const KLICK = '2026-10-05T08:15:00.000Z' // 10:15 Uhr in Berlin
|
||||
const stumm = () => mock.method(console, 'error', () => {})
|
||||
|
||||
let zaehler = 0
|
||||
/**
|
||||
* Terminplaner bestellen (Nachweis wie ihn der Checkout speichert) und bezahlen: liefert K15 an den Kunden, T16 an das Team und das Dokument.
|
||||
* nachweis false: ohne Zustimmungsnachweis; fassungen: andere Fassungen im Nachweis; customerId: anderer Kunde.
|
||||
*/
|
||||
async function bezahleTermine({ nachweis = true, fassungen = null, customerId = 'c1' } = {}) {
|
||||
zaehler += 1
|
||||
const projectId = `ptermine${zaehler}`
|
||||
const sessionId = `cs_ptermine${zaehler}`
|
||||
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Salon ${zaehler}` })
|
||||
await sf.markOrdered({ customerId, projectId, key: 'termine', sessionId, consent: {} })
|
||||
if (nachweis) {
|
||||
await consents.speichereZustimmung({
|
||||
customerId,
|
||||
projectId,
|
||||
featureKey: 'termine',
|
||||
stripeSessionId: sessionId,
|
||||
fassungen: fassungen ?? consents.zustimmungsfassungen(T),
|
||||
texte: consents.nachweisTexte(T, { belehrung: widerrufsbelehrungFunktion() }),
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'test',
|
||||
konto: 'Kunde Eins <k@example.invalid>',
|
||||
at: KLICK,
|
||||
})
|
||||
}
|
||||
const vor = mails.sent.length
|
||||
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine', customerId))
|
||||
const neu = mails.sent.slice(vor)
|
||||
const k15 = neu.filter((m) => m.subject.startsWith('Deine Bestellung'))
|
||||
const t16 = neu.filter((m) => m.subject.startsWith('Neue Funktion bestellt'))
|
||||
return { projectId, sessionId, k15: k15[0] ?? null, k15Anzahl: k15.length, t16: t16[0] ?? null, doc: await sf.getFeatureDoc(customerId, projectId, 'termine') }
|
||||
}
|
||||
|
||||
const anhangDateien = () => {
|
||||
const d = legalPdfFiles('termine', V)
|
||||
return [d.lb, d.avv, d.widerruf]
|
||||
}
|
||||
|
||||
test('k15_termine_anhaenge: drei PDFs in der Reihenfolge lb, avv, widerruf, Namen aus legalPdfFiles; kein Registerhaken mails.anhaenge', async () => {
|
||||
assert.equal(Object.hasOwn(featureConfig('termine').mails, 'anhaenge'), false)
|
||||
const { k15, k15Anzahl, t16 } = await bezahleTermine()
|
||||
assert.equal(k15Anzahl, 1)
|
||||
assert.deepEqual(k15.attachments.map((a) => a.filename), anhangDateien().map((f) => f.filename))
|
||||
assert.deepEqual(k15.attachments.map((a) => a.filename), [`Leistungsbeschreibung-Terminplaner-${V}.pdf`, `AVV-Terminplaner-${V}.pdf`, `Widerrufsbelehrung-Muster-Widerrufsformular-${V}.pdf`])
|
||||
for (const [i, datei] of anhangDateien().entries()) {
|
||||
assert.equal(k15.attachments[i].contentType, 'application/pdf')
|
||||
const bytes = Buffer.from(k15.attachments[i].content, 'base64')
|
||||
assert.equal(bytes.subarray(0, 5).toString('latin1'), '%PDF-')
|
||||
assert.deepEqual(bytes, readFileSync(datei.path), 'genau die ausgelieferte Datei')
|
||||
}
|
||||
// Anlagen-Block mit dem Satz zu den PDFs, T16 ohne Hinweis auf fehlende Unterlagen
|
||||
assert.match(k15.text, /Deine Vertragsunterlagen/)
|
||||
assert.match(k15.text, /Alle drei hängen als PDF an dieser E-Mail\./)
|
||||
assert.doesNotMatch(t16.text, /Unterlagen von Hand nachsenden/)
|
||||
})
|
||||
|
||||
test('k15_news_ohne_anhaenge: die News-Bestätigung bleibt unverändert, ohne Anhang und ohne Blöcke für Vertragsunterlagen', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'pnews8', customerId: 'c1', projectName: 'Bistro Acht' })
|
||||
await sf.markOrdered({ customerId: 'c1', projectId: 'pnews8', key: 'news', sessionId: 'cs_news8', consent: {} })
|
||||
const vor = mails.sent.length
|
||||
await sf.handleFeatureCheckoutEvent(paidEvent('cs_news8', 'pnews8'))
|
||||
const neu = mails.sent.slice(vor)
|
||||
const k15 = neu.find((m) => m.subject.startsWith('Deine Bestellung'))
|
||||
assert.ok(k15)
|
||||
assert.ok(!k15.attachments || k15.attachments.length === 0)
|
||||
assert.doesNotMatch(k15.text, /Vertragsunterlagen|Deine Zustimmungen|hängen als PDF|\/rechtliches\//)
|
||||
assert.doesNotMatch(k15.html, /\/rechtliches\//)
|
||||
const t16 = neu.find((m) => m.subject.startsWith('Neue Funktion bestellt'))
|
||||
assert.doesNotMatch(t16.text, /Unterlagen von Hand nachsenden/)
|
||||
})
|
||||
|
||||
test('k15_links_versioniert: die Adressen der drei Unterlagen tragen die Fassung, keine Adresse ohne Fassung', async () => {
|
||||
const { k15 } = await bezahleTermine()
|
||||
const adressen = legalPaths('termine', V)
|
||||
for (const adresse of Object.values(adressen)) {
|
||||
assert.ok(adresse.endsWith(`-${V}.html`))
|
||||
assert.ok(k15.text.includes(`${BASE}${adresse}`), `Text enthält ${adresse}`)
|
||||
assert.ok(k15.html.includes(`href="${BASE}${adresse}"`), `HTML verlinkt ${adresse}`)
|
||||
}
|
||||
assert.ok(k15.text.includes(`Fassung ${V}: Leistungsbeschreibung`))
|
||||
for (const slug of ['leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'widerrufsbelehrung-funktionen']) {
|
||||
assert.doesNotMatch(k15.text, new RegExp(`/rechtliches/${slug}\\.html`), `${slug}: Adresse ohne Fassung`)
|
||||
assert.doesNotMatch(k15.html, new RegExp(`/rechtliches/${slug}\\.html`))
|
||||
}
|
||||
})
|
||||
|
||||
test('k15_zustimmungen_text: Wortlaut der vier Haken, Zeitpunkt (Berlin) und Knopf aus dem Nachweis zur bezahlten Sitzung', async () => {
|
||||
const { k15 } = await bezahleTermine()
|
||||
assert.ok(k15.text.includes('Deine Zustimmungen'))
|
||||
assert.ok(k15.text.includes('Du hast am 05.10.2026 um 10:15 Uhr im Kundenportal zugestimmt und mit „Zahlungspflichtig bestellen · 149 €“ bestellt:'), k15.text)
|
||||
for (const item of T.consent.items) assert.ok(k15.text.includes(`– ${item.text}`), `Haken ${item.id} im Text`)
|
||||
assert.ok(k15.text.includes('(§ 356 Abs. 5 Nr. 2 BGB)'))
|
||||
assert.doesNotMatch(k15.text, /Widerrufsrecht\n\nSie haben das Recht/, 'die Belehrung selbst steht als PDF im Anhang, nicht im Text')
|
||||
// ohne Nachweis (Altbestand) entfällt nur der Block
|
||||
const ohne = await bezahleTermine({ nachweis: false })
|
||||
assert.doesNotMatch(ohne.k15.text, /Deine Zustimmungen/)
|
||||
assert.match(ohne.k15.text, /Deine Vertragsunterlagen/)
|
||||
assert.equal(ohne.k15.attachments.length, 3)
|
||||
})
|
||||
|
||||
test('k15_anhang_nicht_lesbar: nicht lesbare PDFs => K15 ohne Anhang, T16 mit Block anhang_fehlt, Dokument setup mit Ticket', async () => {
|
||||
const leer = mkdtempSync(join(tmpdir(), 'k15-leer-'))
|
||||
const log = stumm()
|
||||
try {
|
||||
setLegalDirForTests(leer)
|
||||
const { k15, k15Anzahl, t16, doc } = await bezahleTermine()
|
||||
assert.equal(k15Anzahl, 1, 'die Bestätigung geht vor dem Anhang raus')
|
||||
assert.ok(!k15.attachments || k15.attachments.length === 0)
|
||||
assert.match(k15.text, /Deine Vertragsunterlagen/, 'die versionierten Links stehen trotzdem in der Mail')
|
||||
assert.doesNotMatch(k15.text, /hängen als PDF/, 'kein Satz über Anhänge, die es nicht gibt')
|
||||
assert.match(k15.text, /Deine Zustimmungen/)
|
||||
assert.ok(t16.text.includes('Unterlagen von Hand nachsenden'), t16.text)
|
||||
// die Zahlung hat Spur: Dokument in Einrichtung, Ticket angelegt, kein Wurf (der Webhook antwortet 200)
|
||||
assert.equal(doc.state, 'setup')
|
||||
assert.ok(doc.ticketId)
|
||||
assert.ok(aw.docs('workorders').has(doc.ticketId))
|
||||
assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('K15-Anhaenge') || String(c.arguments).includes('nicht lesbar')))
|
||||
} finally {
|
||||
setLegalDirForTests(null)
|
||||
log.mock.restore()
|
||||
rmSync(leer, { recursive: true, force: true })
|
||||
}
|
||||
// Fassung zwischen Klick und Zahlung angehoben: andere Unterlagen als die bestellten gehen nie raus
|
||||
const log2 = stumm()
|
||||
try {
|
||||
const alt = await bezahleTermine({ fassungen: { ...consents.zustimmungsfassungen(T), zustimmung: '2026-10-02' } })
|
||||
assert.ok(!alt.k15.attachments || alt.k15.attachments.length === 0)
|
||||
assert.doesNotMatch(alt.k15.text, /Deine Vertragsunterlagen|\/rechtliches\//)
|
||||
assert.match(alt.k15.text, /Deine Zustimmungen/)
|
||||
assert.ok(alt.t16.text.includes('Unterlagen von Hand nachsenden'))
|
||||
assert.equal(alt.doc.state, 'setup')
|
||||
} finally {
|
||||
log2.mock.restore()
|
||||
}
|
||||
// Kunde ohne E-Mail-Adresse: keine K15, T16 sagt es dem Team, und das Log nennt den Grund (T16: "Der Grund steht im Log des Portals")
|
||||
aw.seed('customers', { $id: 'c9', name: 'Ohne Adresse', email: '' })
|
||||
const log3 = stumm()
|
||||
try {
|
||||
const ohneAdresse = await bezahleTermine({ customerId: 'c9' })
|
||||
assert.equal(ohneAdresse.k15Anzahl, 0)
|
||||
assert.ok(ohneAdresse.t16.text.includes('Unterlagen von Hand nachsenden'))
|
||||
const zeilen = log3.mock.calls.map((c) => c.arguments.join(' '))
|
||||
assert.ok(zeilen.some((z) => z.includes(`K15 (Funktion ${ohneAdresse.doc.$id}) nicht gesendet: beim Kunden ist keine E-Mail-Adresse hinterlegt`)), zeilen.join('\n'))
|
||||
assert.doesNotMatch(zeilen.join('\n'), /Ohne Adresse/, 'keine Personendaten im Log')
|
||||
} finally {
|
||||
log3.mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
/** Verzeichnis mit Kopien der ausgelieferten Dateien unter dem Namen einer anderen Fassung (HTML und PDF je Dokument). */
|
||||
function dateienDerFassung(fassung) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'k15-fassung-'))
|
||||
mkdirSync(join(dir, 'rechtliches'))
|
||||
const echt = legalPdfFiles('termine', V)
|
||||
for (const f of Object.values(echt)) {
|
||||
const slug = f.path.split('/').at(-1).replace(`-${V}.pdf`, '')
|
||||
copyFileSync(f.path, join(dir, 'rechtliches', `${slug}-${fassung}.pdf`))
|
||||
copyFileSync(f.path.replace(/\.pdf$/, '.html'), join(dir, 'rechtliches', `${slug}-${fassung}.html`))
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
test('k15_vereinbarte_fassung: wurde die Fassung zwischen Klick und Zahlung angehoben, gehen PDFs und Links der vereinbarten Fassung raus, wenn es sie gibt', async () => {
|
||||
const alt = '2026-10-01'
|
||||
const dir = dateienDerFassung(alt)
|
||||
const log = stumm()
|
||||
try {
|
||||
setLegalDirForTests(dir)
|
||||
const vereinbart = Object.fromEntries(Object.keys(consents.zustimmungsfassungen(T)).map((k) => [k, alt]))
|
||||
const { k15, t16, doc } = await bezahleTermine({ fassungen: vereinbart })
|
||||
assert.deepEqual(k15.attachments.map((a) => a.filename), [`Leistungsbeschreibung-Terminplaner-${alt}.pdf`, `AVV-Terminplaner-${alt}.pdf`, `Widerrufsbelehrung-Muster-Widerrufsformular-${alt}.pdf`])
|
||||
for (const a of k15.attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 5).toString('latin1'), '%PDF-')
|
||||
for (const slug of ['leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'widerrufsbelehrung-funktionen']) {
|
||||
assert.ok(k15.text.includes(`${BASE}/rechtliches/${slug}-${alt}.html`), `${slug}: Link der vereinbarten Fassung`)
|
||||
assert.ok(!k15.text.includes(`${slug}-${V}.html`), `${slug}: kein Link der aktuellen Fassung`)
|
||||
}
|
||||
assert.ok(k15.text.includes(`Fassung ${alt}: Leistungsbeschreibung`))
|
||||
assert.match(k15.text, /Alle drei hängen als PDF an dieser E-Mail\./)
|
||||
assert.doesNotMatch(t16.text, /Unterlagen von Hand nachsenden/)
|
||||
assert.equal(doc.state, 'setup')
|
||||
assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes(`zugestimmt, aktuell ist ${V}`)), 'das Log nennt den Fassungswechsel')
|
||||
} finally {
|
||||
setLegalDirForTests(null)
|
||||
log.mock.restore()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
// gibt es die Dateien der vereinbarten Fassung nicht, geht K15 ohne PDFs und ohne Links raus (Dateien der aktuellen Fassung bleiben ungenutzt)
|
||||
const log2 = stumm()
|
||||
try {
|
||||
const fehlend = await bezahleTermine({ fassungen: { ...consents.zustimmungsfassungen(T), zustimmung: '2026-09-30' } })
|
||||
assert.ok(!fehlend.k15.attachments || fehlend.k15.attachments.length === 0)
|
||||
assert.doesNotMatch(fehlend.k15.text, /Deine Vertragsunterlagen|\/rechtliches\//)
|
||||
assert.match(fehlend.k15.text, /Deine Zustimmungen/)
|
||||
assert.ok(fehlend.t16.text.includes('Unterlagen von Hand nachsenden'))
|
||||
} finally {
|
||||
log2.mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('k15_nachweis_nicht_lesbar: ohne lesbaren Nachweis ist die vereinbarte Fassung unbekannt: keine PDFs, keine Links, T16 anhang_fehlt, Log', async () => {
|
||||
// (a) Lesefehler beim Nachweis
|
||||
zaehler += 1
|
||||
const projectId = `ptermine${zaehler}`
|
||||
const sessionId = `cs_ptermine${zaehler}`
|
||||
aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: `Salon ${zaehler}` })
|
||||
await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} })
|
||||
await consents.speichereZustimmung({
|
||||
customerId: 'c1', projectId, featureKey: 'termine', stripeSessionId: sessionId, fassungen: consents.zustimmungsfassungen(T),
|
||||
texte: consents.nachweisTexte(T, { belehrung: widerrufsbelehrungFunktion() }), at: KLICK,
|
||||
})
|
||||
const log = stumm()
|
||||
try {
|
||||
aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500, message: 'GEHEIMER-ROHTEXT' })
|
||||
const vor = mails.sent.length
|
||||
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine'))
|
||||
const neu = mails.sent.slice(vor)
|
||||
const k15 = neu.find((m) => m.subject.startsWith('Deine Bestellung'))
|
||||
const t16 = neu.find((m) => m.subject.startsWith('Neue Funktion bestellt'))
|
||||
assert.ok(k15, 'die Bestätigung geht trotzdem raus')
|
||||
assert.ok(!k15.attachments || k15.attachments.length === 0, 'nie die aktuelle Fassung ungeprüft anhängen')
|
||||
assert.doesNotMatch(k15.text, /Deine Vertragsunterlagen|Deine Zustimmungen|\/rechtliches\//)
|
||||
assert.ok(t16.text.includes('Unterlagen von Hand nachsenden'))
|
||||
assert.equal((await sf.getFeatureDoc('c1', projectId, 'termine')).state, 'setup')
|
||||
assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('die vereinbarte Fassung ist unbekannt')), 'das Log nennt den Grund')
|
||||
} finally {
|
||||
log.mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('k15_nachweis_ohne_fassung: ein Nachweis ohne lesbare Fassung zählt wie ein unlesbarer', async () => {
|
||||
zaehler += 1
|
||||
const projectId = `ptermine${zaehler}`
|
||||
const sessionId = `cs_ptermine${zaehler}`
|
||||
aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: `Salon ${zaehler}` })
|
||||
await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} })
|
||||
aw.seed('featureConsents', {
|
||||
$id: `fc_kaputt_${zaehler}`, customerId: 'c1', projectId, featureKey: 'termine', stripeSessionId: sessionId, acceptedAt: KLICK,
|
||||
fassungen: 'kein json', texte: JSON.stringify(consents.nachweisTexte(T, { belehrung: widerrufsbelehrungFunktion() })), textHash: 'x',
|
||||
})
|
||||
const log = stumm()
|
||||
try {
|
||||
const vor = mails.sent.length
|
||||
await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine'))
|
||||
const neu = mails.sent.slice(vor)
|
||||
const k15 = neu.find((m) => m.subject.startsWith('Deine Bestellung'))
|
||||
assert.ok(!k15.attachments || k15.attachments.length === 0)
|
||||
assert.doesNotMatch(k15.text, /Deine Vertragsunterlagen|\/rechtliches\//)
|
||||
assert.match(k15.text, /Deine Zustimmungen/, 'der Wortlaut der Zustimmungen steht trotzdem in der Mail')
|
||||
assert.ok(neu.find((m) => m.subject.startsWith('Neue Funktion bestellt')).text.includes('Unterlagen von Hand nachsenden'))
|
||||
assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('Fassung im Zustimmungsnachweis nicht bestimmbar')))
|
||||
} finally {
|
||||
log.mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('k15_frisches_array: der Mailer bekommt für attachments immer ein eigenes, nicht eingefrorenes Array', async () => {
|
||||
const roh = []
|
||||
setMailTransportForTests({
|
||||
async sendMail(mail) {
|
||||
roh.push(mail)
|
||||
return mails.sendMail(mail)
|
||||
},
|
||||
})
|
||||
try {
|
||||
aw.seed('websiteProjects', { $id: 'pnews12', customerId: 'c1', projectName: 'Frisch' })
|
||||
await sf.markOrdered({ customerId: 'c1', projectId: 'pnews12', key: 'news', sessionId: 'cs_news12', consent: {} })
|
||||
await sf.handleFeatureCheckoutEvent(paidEvent('cs_news12', 'pnews12'))
|
||||
await bezahleTermine()
|
||||
} finally {
|
||||
setMailTransportForTests(mails)
|
||||
}
|
||||
const k15 = roh.filter((m) => String(m.subject).startsWith('Deine Bestellung'))
|
||||
assert.equal(k15.length, 2)
|
||||
for (const mail of k15) {
|
||||
assert.ok(Array.isArray(mail.attachments))
|
||||
assert.equal(Object.isFrozen(mail.attachments), false)
|
||||
}
|
||||
assert.notEqual(k15[0].attachments, k15[1].attachments)
|
||||
k15[0].attachments.push('x') // ein Mailer darf das Array verändern, ohne das gemeinsame Muster zu treffen
|
||||
const nochmal = await bezahleTermine()
|
||||
assert.equal(nochmal.k15.attachments.length, 3)
|
||||
})
|
||||
|
||||
test('checkout_503_ohne_unterlagen: fehlen die PDFs der aktuellen Fassung, 503 unterlagen_fehlen vor jeder Stripe-Sitzung, ohne ordered-Dokument', async () => {
|
||||
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))
|
||||
const modus = { mode: config.termine.mode, pilotEmails: config.termine.pilotEmails }
|
||||
config.termine.mode = 'pilot'
|
||||
config.termine.pilotEmails = ['*']
|
||||
T.bestellpruefung = async () => []
|
||||
const body = () => ({ projectId: 'p1', zustimmung: { version: T.consent.version, haken: T.consent.items.map((i) => i.id) } })
|
||||
const sitzungen = () => st.checkoutSessions.size
|
||||
const termineDokumente = () => [...aw.docs('portalFeatures').values()].filter((d) => d.featureKey === 'termine')
|
||||
const nachweise = () => [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'termine')
|
||||
try {
|
||||
const vorher = { sitzungen: sitzungen(), docs: termineDokumente().length, nachweise: nachweise().length }
|
||||
// (a) neue Fassung ohne Dateien
|
||||
const alt = config.termine.avvVersion
|
||||
config.termine.avvVersion = '2099-01-01'
|
||||
try {
|
||||
const res = await post('/api/funktionen/termine/checkout', body(), { cookie })
|
||||
assert.equal(res.status, 503, JSON.stringify(res.body))
|
||||
assert.equal(res.body.code, 'unterlagen_fehlen')
|
||||
assert.equal(res.body.error, 'Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.')
|
||||
assert.equal(res.body.url, undefined)
|
||||
} finally {
|
||||
config.termine.avvVersion = alt
|
||||
}
|
||||
// (b) Verzeichnis ohne Dateien
|
||||
const leer = mkdtempSync(join(tmpdir(), 'checkout-leer-'))
|
||||
try {
|
||||
setLegalDirForTests(leer)
|
||||
const res = await post('/api/funktionen/termine/checkout', body(), { cookie })
|
||||
assert.equal(res.status, 503)
|
||||
assert.equal(res.body.code, 'unterlagen_fehlen')
|
||||
} finally {
|
||||
setLegalDirForTests(null)
|
||||
rmSync(leer, { recursive: true, force: true })
|
||||
}
|
||||
assert.deepEqual({ sitzungen: sitzungen(), docs: termineDokumente().length, nachweise: nachweise().length }, vorher, 'keine Stripe-Sitzung, kein Dokument, kein Nachweis')
|
||||
assert.equal(termineDokumente().filter((d) => d.state === 'ordered').length, 0)
|
||||
// (c) mit den ausgelieferten Dateien geht dieselbe Bestellung durch
|
||||
const ok = await post('/api/funktionen/termine/checkout', body(), { cookie })
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body))
|
||||
assert.equal(sitzungen(), vorher.sitzungen + 1)
|
||||
assert.equal(termineDokumente().filter((d) => d.state === 'ordered').length, 1)
|
||||
} finally {
|
||||
delete T.bestellpruefung
|
||||
config.termine.mode = modus.mode
|
||||
config.termine.pilotEmails = modus.pilotEmails
|
||||
}
|
||||
})
|
||||
|
||||
test('bestellseite_statisch: Belehrung als Text im nicht einklappbaren Bereich, Links mit target="_blank" und rel="noopener noreferrer"', () => {
|
||||
const quelle = lesen('public/funktion-bestellen.js')
|
||||
const code = quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
|
||||
assert.doesNotMatch(code, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|createContextualFragment/, 'kein Markup aus Text')
|
||||
assert.doesNotMatch(code, /<details|<summary|'details'|"details"|'summary'/, 'kein Aufklappen')
|
||||
// die Belehrung kommt als Kind des Textbereichs (h hängt Zeichenketten als Text an), nie als Markup
|
||||
assert.match(code, /h\('div', \{ class: 'tm-rechtstext', tabindex: '0', role: 'region', 'aria-label': 'Widerrufsbelehrung mit Muster-Widerrufsformular' \}, bestellung\.belehrung\)/)
|
||||
assert.match(lesen('public/termine-dom.js'), /el\.append\(kind\)/, 'h hängt Kinder mit append an (Text bleibt Text)')
|
||||
// Links der Unterlagen
|
||||
assert.match(code, /h\('a', \{ href: sicherLink\(dokument\.url\), target: '_blank', rel: 'noopener noreferrer' \}, dokument\.titel\)/)
|
||||
for (const treffer of code.matchAll(/target: '_blank'/g)) {
|
||||
assert.ok(code.slice(treffer.index, treffer.index + 80).includes("rel: 'noopener noreferrer'"), 'jeder Link mit target _blank trägt rel')
|
||||
}
|
||||
})
|
||||
@@ -109,6 +109,33 @@ test('liste_zustaende', async () => {
|
||||
assert.equal(body.consentText, body.features.news.consent.items[0].text)
|
||||
})
|
||||
|
||||
// Vertragsunterlagen in der Liste (Task F8): Fassung, absolute versionierte Links und die Widerrufsbelehrung des Terminplaners
|
||||
test('liste_legal: legal.termine mit Fassung, https-Links mit Fassung und der Belehrung der Funktionen; News null', async () => {
|
||||
const { widerrufsbelehrungFunktion } = await import('../server/services/termine/rechtstexte.js')
|
||||
const { legalPaths } = await import('../server/services/legalDocs.js')
|
||||
const V = config.termine.avvVersion
|
||||
const body = await get('/api/funktionen', { cookie })
|
||||
assert.deepEqual(Object.keys(body.legal).sort(), ['news', 'termine'])
|
||||
assert.equal(body.legal.news, null)
|
||||
const termine = body.legal.termine
|
||||
assert.deepEqual(Object.keys(termine).sort(), ['belehrung', 'links', 'version'])
|
||||
assert.equal(termine.version, V)
|
||||
assert.deepEqual(Object.keys(termine.links), ['lb', 'avv', 'widerruf'])
|
||||
const adressen = legalPaths('termine', V)
|
||||
for (const [doc, link] of Object.entries(termine.links)) {
|
||||
assert.match(link, /^https:\/\//, `${doc}: https`)
|
||||
assert.ok(link.endsWith(`-${V}.html`), `${doc}: versioniert`)
|
||||
assert.equal(link, `${config.publicBaseUrl}${adressen[doc]}`)
|
||||
}
|
||||
assert.equal(termine.belehrung, widerrufsbelehrungFunktion())
|
||||
assert.match(termine.belehrung, /Muster-Widerrufsformular/)
|
||||
// dieselben Werte wie die Register-Ansicht der Bestellseite: Fassung der Widerrufsbelehrung und Adresse
|
||||
assert.deepEqual(body.features.termine.widerruf, { version: V, url: adressen.widerruf })
|
||||
assert.deepEqual(body.features.termine.avv, { version: V, url: adressen.avv })
|
||||
assert.deepEqual(body.features.termine.leistungsbeschreibung, { version: V, url: adressen.lb })
|
||||
assert.deepEqual(body.features.news.widerruf, null)
|
||||
})
|
||||
|
||||
test('liste_register_ohne_freigabe: features.termine steht auch bei Schalter off und Pilot ohne Teilnahme', async () => {
|
||||
const vorher = { mode: config.termine.mode, pilotEmails: config.termine.pilotEmails }
|
||||
try {
|
||||
|
||||
@@ -20,9 +20,21 @@ const { default: teamRoutes } = await import('../server/routes/team/index.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
const { loeschTag } = await import('../server/services/termine/frist.js')
|
||||
const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js')
|
||||
const { aboMeldung } = await import('../server/services/featureOrder.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { mailSink } = await import('./support/mailSink.js')
|
||||
const { ausVorlage } = await import('../server/services/termine/vorlagen.js')
|
||||
const { neuerFeedToken } = await import('../server/services/termine/einstellungen.js')
|
||||
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
|
||||
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/team': teamRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
// Mails (K16 der Abnahme, K15/T16 des nachgelegten Tickets) landen in einer Senke
|
||||
const mails = mailSink()
|
||||
setMailTransportForTests(mails)
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([app.close(), aw.close(), st.close()])
|
||||
})
|
||||
seedFixtures(aw)
|
||||
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
|
||||
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' })
|
||||
@@ -165,7 +177,8 @@ test('GET /api/funktionen: termine.verfuegbar je Modus und Pilot-E-Mail; false b
|
||||
assert.equal(await verfuegbar(customerCookie('c2')), true)
|
||||
})
|
||||
|
||||
// ---- Abo-Felder der Kachel (C10a): bestellbar, grund, preis, einrichtung nur bei verfuegbar: true
|
||||
// ---- Abo-Felder der Kachel (C10a): bestellbar, grund, preis, einrichtung, hinweis nur bei verfuegbar: true
|
||||
// hinweis (R111) = aboMeldung des Registers: der Browser zeigt den Text nur an und hat keine Kopie; bei bestellbar true null
|
||||
const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch }))
|
||||
const angebot = (bestellbar, grund) => ({
|
||||
verfuegbar: true,
|
||||
@@ -173,6 +186,7 @@ const angebot = (bestellbar, grund) => ({
|
||||
grund,
|
||||
preis: config.features.termine.priceLabel,
|
||||
einrichtung: config.features.termine.setupEstimate,
|
||||
hinweis: bestellbar === true ? null : aboMeldung(config.features.termine, grund ?? 'unbekannt'),
|
||||
})
|
||||
|
||||
test('GET /api/funktionen: Kunde mit Wartungs-Abo => bestellbar true, grund null, Preis und Einrichtung aus dem Register', async (t) => {
|
||||
@@ -182,7 +196,38 @@ test('GET /api/funktionen: Kunde mit Wartungs-Abo => bestellbar true, grund null
|
||||
const termine = await termineAntwort()
|
||||
assert.deepEqual(termine, angebot(true, null))
|
||||
assert.ok(termine.preis && termine.einrichtung, 'das Register liefert beide Werte')
|
||||
assert.deepEqual(Object.keys(termine), ['verfuegbar', 'bestellbar', 'grund', 'preis', 'einrichtung'])
|
||||
assert.deepEqual(Object.keys(termine), ['verfuegbar', 'bestellbar', 'grund', 'preis', 'einrichtung', 'hinweis'])
|
||||
assert.equal(termine.hinweis, null, 'bestellbar: kein Hinweis (das Feld steht da, ist aber null)')
|
||||
})
|
||||
|
||||
test('GET /api/funktionen: hinweis ist der Text von aboMeldung mit dem Register des Terminplaners, je Grund', async (t) => {
|
||||
t.after(() => aw.seed('subscriptions', hostingDoc))
|
||||
termineMit('pilot', ['*'])
|
||||
const faelle = [
|
||||
['kein_abo', () => {}, 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.'],
|
||||
['gekuendigt', () => wartung({ cancelAtPeriodEnd: true }), 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.'],
|
||||
[
|
||||
'nicht_aktiv',
|
||||
() => wartung({ status: 'past_due' }),
|
||||
'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.',
|
||||
],
|
||||
]
|
||||
for (const [grund, richteEin, text] of faelle) {
|
||||
aw.seed('subscriptions', hostingDoc)
|
||||
richteEin()
|
||||
const termine = await termineAntwort()
|
||||
assert.equal(termine.bestellbar, false, grund)
|
||||
assert.equal(termine.grund, grund)
|
||||
assert.equal(termine.hinweis, text, grund)
|
||||
assert.equal(termine.hinweis, aboMeldung(config.features.termine, grund), grund)
|
||||
}
|
||||
// ein Lesefehler: bestellbar null, grund null => Text zu 'unbekannt'
|
||||
aw.seed('subscriptions', hostingDoc)
|
||||
wartung()
|
||||
t.mock.method(console, 'error', () => {})
|
||||
aw.failNext({ collection: 'subscriptions', status: 500, message: 'x' })
|
||||
const unbekannt = await termineAntwort()
|
||||
assert.deepEqual([unbekannt.bestellbar, unbekannt.grund, unbekannt.hinweis], [null, null, 'Das Abo konnte gerade nicht geprüft werden.'])
|
||||
})
|
||||
|
||||
test('GET /api/funktionen: ohne Wartungs-Abo (nur Hosting, gar kein Abo, beendet) => bestellbar false, grund kein_abo', async (t) => {
|
||||
@@ -212,11 +257,15 @@ test('GET /api/funktionen: Lesefehler im Abo => bestellbar null, grund null, die
|
||||
const fehler = t.mock.method(console, 'error', () => {})
|
||||
termineMit('pilot', ['*'])
|
||||
wartung()
|
||||
// eigenes Projekt mit aktivem Terminplaner: der Test haengt nicht von den Zustaenden aus frueheren Tests ab
|
||||
aw.seed('websiteProjects', { $id: 'pt_lesefehler', customerId: 'c1', projectName: 'Lesefehler Salon' })
|
||||
aw.seed('portalFeatures', { $id: 'f_pt_lesefehler_termine', customerId: 'c1', projectId: 'pt_lesefehler', featureKey: 'termine', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true })
|
||||
aw.failNext({ collection: 'subscriptions', status: 500, message: 'geheimer Appwrite-Text' })
|
||||
const res = await get('/api/funktionen', { cookie })
|
||||
assert.deepEqual(res.termine, angebot(null, null))
|
||||
for (const id of ['pt1', 'pt2']) assert.ok(res.projects.some((p) => p.projectId === id), `Projekt ${id} ist in der Liste`)
|
||||
assert.equal(featuresOf({ body: res }, 'pt1').termine.state, 'active', 'die Zustaende der Projekte bleiben')
|
||||
assert.equal(res.termine.hinweis, 'Das Abo konnte gerade nicht geprüft werden.')
|
||||
assert.ok(res.projects.some((p) => p.projectId === 'pt_lesefehler'), 'das Projekt ist in der Liste')
|
||||
assert.equal(featuresOf({ body: res }, 'pt_lesefehler').termine.state, 'active', 'die Zustaende der Projekte bleiben')
|
||||
assert.ok(!JSON.stringify(res).includes('geheimer'))
|
||||
assert.ok(fehler.mock.calls.every((aufruf) => !String(aufruf.arguments[0]).includes('geheimer')), 'auch nicht im Log')
|
||||
assert.ok(fehler.mock.calls.length >= 1, 'der Fehler steht im Log')
|
||||
@@ -508,3 +557,809 @@ test('adminActivate (Dienst): bezahlter Terminplaner ohne Abo => Fehler 409, bei
|
||||
const pilot = legeKunde({ over: { unlockedByPurchase: false } })
|
||||
assert.equal((await sf.adminActivate(pilot.customerId, pilot.projectId, 'termine', { by: 'test' })).state, 'active')
|
||||
})
|
||||
|
||||
// ------------------------------------------ Team-Aktionen Freischalten (Abnahme) und Ticket nachlegen (D7) ---
|
||||
// abnahme: Terminplaner in setup freischalten, ohne dass das Einrichtungs-Ticket geschlossen wird (Ticket und Kauf bleiben, K16 einmal).
|
||||
// ticket: ein fehlendes Einrichtungs-Ticket nachlegen - (a) Ticket nie angelegt: Zahlung bei Stripe pruefen und den Weg des Webhooks gehen
|
||||
// (Ticket, K15, T16 genau einmal), (b) Ticket geloescht: neues Ticket, keine Mails, (c) Ticket da: nichts.
|
||||
const K16_BETREFF = { termine: 'Dein Terminplaner ist bereit', news: 'Deine News-Pinnwand ist bereit' }
|
||||
const K15_BETREFF = 'Deine Bestellung: Terminplaner'
|
||||
const T16_BETREFF = 'Neue Funktion bestellt: Terminplaner'
|
||||
const NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.'
|
||||
const NUR_SETUP_TICKET = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.'
|
||||
const OHNE_ABO = 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.'
|
||||
const OHNE_EINSTELLUNG = 'Es sind noch keine Einstellungen gespeichert.'
|
||||
const ABO_UNKLAR = 'Das Abo konnte gerade nicht geprüft werden.'
|
||||
const NICHT_BEZAHLT = 'Die Zahlung ist noch nicht eingegangen.'
|
||||
const KEINE_AKTION = 'Diese Aktion gibt es für diese Funktion nicht.'
|
||||
const mailsAn = (email, subject) => mails.sent.filter((m) => m.subject === subject && m.to.some((t) => t.address === email))
|
||||
const seit = (n) => mails.sent.slice(n)
|
||||
|
||||
let d7Nr = 0
|
||||
/**
|
||||
* Kunde mit Projekt, Funktionsdokument (Zustand state), Wartungs-Abo und gespeicherter Einstellung.
|
||||
* ticket: 'Open' | 'Closed' (Ticket da) | 'geloescht' (ticketId gesetzt, Ticket fehlt) | null (nie angelegt).
|
||||
* session: null | 'paid' | 'unpaid' legt die Stripe-Sitzung samt Rechnung im Nachbau an und traegt sie am Dokument ein.
|
||||
*/
|
||||
function legeEinrichtung({ key = 'termine', abo = 'wartung', einstellung = true, ticket = 'Open', session = null, state = 'setup', over = {} } = {}) {
|
||||
d7Nr += 1
|
||||
const n = d7Nr
|
||||
const customerId = `cd${n}`
|
||||
const projectId = `cdp${n}`
|
||||
const docId = `f_${projectId}_${key}`
|
||||
const ticketId = `t_cd${n}`
|
||||
const sessionId = session ? `cs_cd${n}` : ''
|
||||
const email = `cd${n}@example.invalid`
|
||||
aw.seed('customers', { $id: customerId, name: `Salon ${n}`, email, status: 'active' })
|
||||
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Salon ${n}` })
|
||||
if (abo) aw.seed('subscriptions', subscriptionDoc(`sub_cd${n}`, { ...(abo === 'wartung' ? wartungDoc : hostingDoc), customerId }))
|
||||
if (ticket === 'Open' || ticket === 'Closed') {
|
||||
aw.seed('workorders', { $id: ticketId, woid: 7000 + n, status: ticket, topic: `Terminplaner einbauen – Salon ${n}`, details: 'Original-Ticket' })
|
||||
}
|
||||
if (session) {
|
||||
st.invoices.set(`in_cd${n}`, { id: `in_cd${n}`, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/in_cd${n}` })
|
||||
st.checkoutSessions.set(sessionId, {
|
||||
id: sessionId, object: 'checkout.session', mode: 'payment', status: 'complete', payment_status: session, invoice: `in_cd${n}`,
|
||||
metadata: { customerId, projectId, featureKey: key },
|
||||
})
|
||||
}
|
||||
aw.seed('portalFeatures', {
|
||||
$id: docId, customerId, projectId, featureKey: key, siteKey: 'abcdefghijklmnop', state, enabled: state === 'active',
|
||||
unlockedByPurchase: true, purchaseStatus: 'paid', orderedAt: '2026-10-01T10:00:00.000Z', stripeSessionId: sessionId,
|
||||
...(ticket ? { ticketId } : {}), adminNote: '', ...over,
|
||||
})
|
||||
if (einstellung) {
|
||||
aw.seed('termineConfig', {
|
||||
$id: docId, customerId, projectId, einstellung: JSON.stringify({ betrieb: { name: `Salon ${n}` } }), version: 1, updatedAt: '2026-10-03T08:00:00.000Z', updatedBy: 'test',
|
||||
})
|
||||
}
|
||||
return { customerId, projectId, docId, ticketId, sessionId, email }
|
||||
}
|
||||
const abnahme = (k, key = 'termine', opts) => team(k.customerId, key, { projectId: k.projectId, action: 'abnahme' }, opts)
|
||||
const nachlegen = (k, key = 'termine', opts) => team(k.customerId, key, { projectId: k.projectId, action: 'ticket' }, opts)
|
||||
const ticketDoc = (id) => aw.docs('workorders').get(id)
|
||||
const dienst = (k, fn, key = 'termine') => sf[fn]({ customerId: k.customerId, projectId: k.projectId, key, by: 'Andrej', adminNote: `x Andrej: ${fn}` })
|
||||
|
||||
// ----------------------------------------------------------------------------- abnahme ---
|
||||
test('abnahme auf setup mit Einstellung und Abo: active, Ticket (Open) und Kauf bleiben, adminNote, K16 genau einmal', async () => {
|
||||
const k = legeEinrichtung()
|
||||
const res = await abnahme(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(res.body.projects.map((p) => p.projectId), [k.projectId])
|
||||
const doc = docById(k.docId)
|
||||
assert.deepEqual([doc.state, doc.enabled], ['active', true])
|
||||
assert.ok(Math.abs(Date.parse(doc.activatedAt) - Date.now()) < 5000, 'activatedAt gesetzt')
|
||||
assert.equal(featuresOf(res, k.projectId).termine.state, 'active')
|
||||
assert.equal(featuresOf(res, k.projectId).termine.activatedAt, doc.activatedAt)
|
||||
assert.equal(doc.ticketId, k.ticketId, 'ticketId bleibt')
|
||||
assert.equal(ticketDoc(k.ticketId).status, 'Open', 'das Ticket wird nicht geschlossen')
|
||||
assert.equal(doc.unlockedByPurchase, true)
|
||||
assert.equal(doc.purchaseStatus, 'paid')
|
||||
assert.match(doc.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: abnahme$/)
|
||||
const k16 = mailsAn(k.email, K16_BETREFF.termine)
|
||||
assert.equal(k16.length, 1, 'K16 genau einmal')
|
||||
assert.match(k16[0].text, /dashboard\.html#termine/)
|
||||
})
|
||||
|
||||
test('abnahme: zweiter Klick => 200 ohne zweite Mail und ohne Schreibzugriff; Dienst meldet activated true, dann false', async () => {
|
||||
const k = legeEinrichtung()
|
||||
assert.equal((await abnahme(k)).status, 200)
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const nochmal = await abnahme(k)
|
||||
assert.equal(nochmal.status, 200)
|
||||
assert.equal(featuresOf(nochmal, k.projectId).termine.state, 'active')
|
||||
assert.deepEqual(docById(k.docId), vorher, 'nichts geschrieben: activatedAt und Notiz bleiben')
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1)
|
||||
|
||||
const k2 = legeEinrichtung()
|
||||
const erster = await dienst(k2, 'freischaltenNachAbnahme')
|
||||
assert.equal(erster.activated, true)
|
||||
assert.equal(erster.doc.state, 'active')
|
||||
const zweiter = await dienst(k2, 'freischaltenNachAbnahme')
|
||||
assert.equal(zweiter.activated, false)
|
||||
assert.equal(zweiter.doc.state, 'active')
|
||||
assert.equal(mailsAn(k2.email, K16_BETREFF.termine).length, 1)
|
||||
})
|
||||
|
||||
test('abnahme: zwei parallele Klicks => eine Mail, ein Zustandswechsel', async () => {
|
||||
const k = legeEinrichtung()
|
||||
const [a, b] = await Promise.all([abnahme(k), abnahme(k)])
|
||||
assert.deepEqual([a.status, b.status], [200, 200])
|
||||
assert.equal(docById(k.docId).state, 'active')
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1)
|
||||
// auch ohne die Sperre der Route: zwei gleichzeitige Dienstaufrufe aktivieren nur einmal
|
||||
const k2 = legeEinrichtung()
|
||||
const [x, y] = await Promise.all([dienst(k2, 'freischaltenNachAbnahme'), dienst(k2, 'freischaltenNachAbnahme')])
|
||||
assert.deepEqual([x.activated, y.activated].sort(), [false, true])
|
||||
assert.equal(mailsAn(k2.email, K16_BETREFF.termine).length, 1)
|
||||
})
|
||||
|
||||
test('abnahme und der Freischalt-Job (runActivation) bei geschlossenem Ticket gleichzeitig => eine Mail insgesamt, in beiden Reihenfolgen', async () => {
|
||||
for (const abnahmeZuerst of [true, false]) {
|
||||
const k = legeEinrichtung({ ticket: 'Closed' })
|
||||
const start = [() => abnahme(k), () => sf.runActivation({ customerId: k.customerId })]
|
||||
if (!abnahmeZuerst) start.reverse()
|
||||
await Promise.all(start.map((f) => f()))
|
||||
assert.equal(docById(k.docId).state, 'active', `abnahmeZuerst ${abnahmeZuerst}`)
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1, `abnahmeZuerst ${abnahmeZuerst}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('abnahme: bereits aktive Funktion (Pilot, Kulanz) => 200 ohne Mail und ohne Schreibzugriff', async () => {
|
||||
const k = legeEinrichtung({ state: 'active', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '', activatedAt: '2026-10-02T10:00:00.000Z' } })
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const res = await abnahme(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(docById(k.docId), vorher)
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
|
||||
})
|
||||
|
||||
test('abnahme: off, ordered und disabled => 409 mit Text, nichts geschrieben, keine Mail', async () => {
|
||||
const faelle = [
|
||||
legeEinrichtung({ state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } }),
|
||||
legeEinrichtung({ state: 'ordered', ticket: null, over: { purchaseStatus: 'ordered', unlockedByPurchase: false } }),
|
||||
legeEinrichtung({ state: 'disabled', over: { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' } }),
|
||||
]
|
||||
for (const k of faelle) {
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const res = await abnahme(k)
|
||||
assert.equal(res.status, 409, vorher.state)
|
||||
assert.equal(res.body.error, NUR_SETUP, vorher.state)
|
||||
assert.deepEqual(docById(k.docId), vorher, vorher.state)
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0, vorher.state)
|
||||
}
|
||||
})
|
||||
|
||||
test('abnahme ohne laufendes Wartungs-Abo (kein Abo, nur Hosting, beendet) => 409, setup bleibt, keine Mail', async () => {
|
||||
const faelle = [
|
||||
legeEinrichtung({ abo: null }),
|
||||
legeEinrichtung({ abo: 'hosting' }),
|
||||
legeEinrichtung({ abo: 'wartung' }),
|
||||
]
|
||||
// beendetes Wartungs-Abo: das dritte Abo auf canceled stellen
|
||||
aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: faelle[2].customerId, status: 'canceled', endedAt: '2026-10-01T10:00:00.000Z' }))
|
||||
for (const k of faelle) {
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const res = await abnahme(k)
|
||||
assert.equal(res.status, 409, k.customerId)
|
||||
assert.equal(res.body.error, OHNE_ABO)
|
||||
assert.deepEqual(docById(k.docId), vorher, 'setup bleibt, nichts geschrieben')
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('abnahme: eine vorgemerkte Kuendigung und past_due laufen noch => 200', async () => {
|
||||
for (const aboOver of [{ cancelAtPeriodEnd: true }, { status: 'past_due' }]) {
|
||||
const k = legeEinrichtung()
|
||||
aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: k.customerId, ...aboOver }))
|
||||
assert.equal((await abnahme(k)).status, 200, JSON.stringify(aboOver))
|
||||
assert.equal(docById(k.docId).state, 'active')
|
||||
}
|
||||
})
|
||||
|
||||
test('abnahme ohne gespeicherte Einstellung => 409, setup bleibt', async () => {
|
||||
const k = legeEinrichtung({ einstellung: false })
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const res = await abnahme(k)
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, OHNE_EINSTELLUNG)
|
||||
assert.deepEqual(docById(k.docId), vorher)
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
|
||||
})
|
||||
|
||||
test('abnahme: Abo nicht lesbar => 503 mit Text (geloggt), setup bleibt; danach geht es', async (t) => {
|
||||
const log = t.mock.method(console, 'error', () => {})
|
||||
const k = legeEinrichtung()
|
||||
const vorher = { ...docById(k.docId) }
|
||||
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Nachbau: Abo kaputt' })
|
||||
const res = await abnahme(k)
|
||||
assert.equal(res.status, 503)
|
||||
assert.equal(res.body.error, ABO_UNKLAR)
|
||||
assert.deepEqual(docById(k.docId), vorher, 'nie freischalten, wenn etwas nicht lesbar war')
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
|
||||
assert.ok(log.mock.calls.some((c) => /nicht prüfbar/.test(c.arguments.join(' '))), 'der Fehler steht im Log')
|
||||
assert.equal((await abnahme(k)).status, 200)
|
||||
assert.equal(docById(k.docId).state, 'active')
|
||||
})
|
||||
|
||||
test('abnahme: Projekt eines anderen Kunden => 403, Kundensitzung statt JWT => 401/403, ohne Dokument => 409 und nichts angelegt', async () => {
|
||||
const k = legeEinrichtung()
|
||||
const vorher = { ...docById(k.docId) }
|
||||
assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'abnahme' })).status, 403, 'pt3 gehoert c2')
|
||||
assert.equal((await team(k.customerId, 'termine', { projectId: 'pt3', action: 'abnahme' })).status, 403)
|
||||
assert.equal((await team('c1', 'termine', { projectId: 'gibtsnicht', action: 'abnahme' })).status, 403)
|
||||
const alsKunde = await abnahme(k, 'termine', { cookie: customerCookie(k.customerId) })
|
||||
assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`)
|
||||
assert.deepEqual(docById(k.docId), vorher)
|
||||
const docs = aw.docs('portalFeatures').size
|
||||
aw.seed('websiteProjects', { $id: 'pt_d7_leer', customerId: 'c1', projectName: 'Ohne Dokument D7' })
|
||||
const ohneDoc = await team('c1', 'termine', { projectId: 'pt_d7_leer', action: 'abnahme' })
|
||||
assert.equal(ohneDoc.status, 409)
|
||||
assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
|
||||
assert.equal(aw.docs('portalFeatures').size, docs, 'kein Dokument entstanden')
|
||||
assert.equal(mails.sent.filter((m) => m.subject === K16_BETREFF.termine && m.to.some((to) => to.address === 'k@example.invalid')).length, 0)
|
||||
})
|
||||
|
||||
test('abnahme bei News funktioniert ohne Abo-Pruefung (es gibt keinen Haken freischaltbar): active, K16 News, Abo nicht gelesen', async () => {
|
||||
const k = legeEinrichtung({ key: 'news', abo: null, einstellung: false })
|
||||
const abfragen = aw.listCalls('subscriptions').length
|
||||
const res = await abnahme(k, 'news')
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(featuresOf(res, k.projectId).news.state, 'active')
|
||||
assert.equal(docById(k.docId).state, 'active')
|
||||
assert.equal(docById(k.docId).ticketId, k.ticketId)
|
||||
assert.equal(aw.listCalls('subscriptions').length, abfragen, 'keine Abo-Abfrage')
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.news).length, 1)
|
||||
})
|
||||
|
||||
test('abnahme und ticket bei analyse und unbekannten Schluesseln => 404, nichts angelegt', async () => {
|
||||
const k = legeEinrichtung()
|
||||
const docs = aw.docs('portalFeatures').size
|
||||
for (const action of ['abnahme', 'ticket']) {
|
||||
for (const key of ['analyse', 'kalender']) {
|
||||
const res = await team(k.customerId, key, { projectId: k.projectId, action })
|
||||
assert.equal(res.status, 404, `${key} ${action}`)
|
||||
}
|
||||
}
|
||||
assert.equal(aw.docs('portalFeatures').size, docs)
|
||||
})
|
||||
|
||||
test('abnahme und ticket brauchen ticketFreischaltung im Register, sonst 409 ohne Schreibzugriff', async () => {
|
||||
const k = legeEinrichtung({ key: 'news' })
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const alt = config.features.news.ticketFreischaltung
|
||||
config.features.news.ticketFreischaltung = false
|
||||
try {
|
||||
for (const action of ['abnahme', 'ticket']) {
|
||||
const res = await team(k.customerId, 'news', { projectId: k.projectId, action })
|
||||
assert.equal(res.status, 409, action)
|
||||
assert.equal(res.body.error, KEINE_AKTION, action)
|
||||
}
|
||||
} finally {
|
||||
config.features.news.ticketFreischaltung = alt
|
||||
}
|
||||
assert.deepEqual(docById(k.docId), vorher)
|
||||
assert.equal(mailsAn(k.email, K16_BETREFF.news).length, 0)
|
||||
// activate und disable gehoeren nicht dazu und gehen weiter
|
||||
assert.equal((await team(k.customerId, 'news', { projectId: k.projectId, action: 'disable' })).status, 200)
|
||||
})
|
||||
|
||||
test('abnahme: scheitert der Versand von K16, ist die Funktion trotzdem freigeschaltet (Fehler im Log)', async (t) => {
|
||||
const log = t.mock.method(console, 'error', () => {})
|
||||
const k = legeEinrichtung()
|
||||
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } })
|
||||
t.after(() => setMailTransportForTests(mails))
|
||||
const res = await abnahme(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(docById(k.docId).state, 'active')
|
||||
assert.ok(log.mock.calls.some((c) => /K16 an Kunde .* fehlgeschlagen/.test(c.arguments.join(' '))))
|
||||
})
|
||||
|
||||
// ----------------------------------------------------------------------------- ticket ---
|
||||
test('ticket (a): setup ohne ticketId, Sitzung bezahlt => Ticket mit Kurzfassung, K15 und T16 je einmal; zweiter Klick und Webhook-Wiederholung aendern nichts', async () => {
|
||||
const k = legeEinrichtung({ ticket: null, session: 'paid' })
|
||||
const tickets = workorders()
|
||||
const mailsVorher = mails.sent.length
|
||||
const res = await nachlegen(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const doc = docById(k.docId)
|
||||
assert.ok(doc.ticketId, 'ticketId gesetzt')
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
const ticket = ticketDoc(doc.ticketId)
|
||||
assert.match(ticket.topic, /^Terminplaner einbauen – Salon \d+$/)
|
||||
assert.ok(ticket.details.includes('Konfigurationsstand: Version 1'), ticket.details)
|
||||
assert.equal(ticket.status, 'Open')
|
||||
assert.deepEqual([doc.state, doc.unlockedByPurchase], ['setup', true])
|
||||
assert.match(doc.adminNote, /Andrej: ticket/)
|
||||
assert.equal(featuresOf(res, k.projectId).termine.ticketMissing, false)
|
||||
// K15 an den Kunden, T16 ans Team, je einmal
|
||||
assert.equal(mailsAn(k.email, K15_BETREFF).length, 1)
|
||||
const neu = seit(mailsVorher)
|
||||
assert.equal(neu.filter((m) => m.subject === T16_BETREFF).length, 1)
|
||||
assert.match(neu.find((m) => m.subject === T16_BETREFF).text, new RegExp(String(ticket.woid)))
|
||||
assert.equal(neu.length, 2, 'sonst nichts')
|
||||
|
||||
// zweiter Klick: Ticket vorhanden => nichts Neues
|
||||
const nochmal = await nachlegen(k)
|
||||
assert.equal(nochmal.status, 200)
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
assert.equal(docById(k.docId).ticketId, doc.ticketId)
|
||||
assert.equal(seit(mailsVorher).length, 2, 'keine weiteren Mails')
|
||||
|
||||
// ein folgender Webhook mit derselben Sitzung (Stripe-Retry) sendet und legt nichts doppelt an
|
||||
await sf.handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: st.checkoutSessions.get(k.sessionId) } })
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
assert.equal(seit(mailsVorher).length, 2, 'Webhook-Wiederholung: keine weiteren Mails')
|
||||
assert.equal(docById(k.docId).ticketId, doc.ticketId)
|
||||
})
|
||||
|
||||
test('ticket (a), Dienst: created true beim Anlegen, false danach; zwei gleichzeitige Aufrufe legen ein Ticket und senden K15/T16 einmal', async () => {
|
||||
const k = legeEinrichtung({ ticket: null, session: 'paid' })
|
||||
const tickets = workorders()
|
||||
const mailsVorher = mails.sent.length
|
||||
const [a, b] = await Promise.all([dienst(k, 'ticketNachlegen'), dienst(k, 'ticketNachlegen')])
|
||||
assert.deepEqual([a.created, b.created].sort(), [false, true])
|
||||
assert.equal(a.doc.ticketId, b.doc.ticketId)
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
assert.equal(seit(mailsVorher).length, 2, 'K15 und T16 je einmal')
|
||||
const spaeter = await dienst(k, 'ticketNachlegen')
|
||||
assert.equal(spaeter.created, false)
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
})
|
||||
|
||||
test('ticket (a): zwei parallele Klicks => ein Ticket, K15 und T16 je einmal', async () => {
|
||||
const k = legeEinrichtung({ ticket: null, session: 'paid' })
|
||||
const tickets = workorders()
|
||||
const mailsVorher = mails.sent.length
|
||||
const [a, b] = await Promise.all([nachlegen(k), nachlegen(k)])
|
||||
assert.deepEqual([a.status, b.status], [200, 200])
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
assert.equal(seit(mailsVorher).length, 2)
|
||||
assert.equal(mailsAn(k.email, K15_BETREFF).length, 1)
|
||||
})
|
||||
|
||||
test('ticket (a): Sitzung nicht bezahlt => 409, kein Ticket, keine Mail; Stripe-Ausfall => 503, danach geht es', async (t) => {
|
||||
const log = t.mock.method(console, 'error', () => {})
|
||||
const unbezahlt = legeEinrichtung({ ticket: null, session: 'unpaid' })
|
||||
const tickets = workorders()
|
||||
const mailsVorher = mails.sent.length
|
||||
const vorher = { ...docById(unbezahlt.docId) }
|
||||
const res = await nachlegen(unbezahlt)
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, NICHT_BEZAHLT)
|
||||
assert.deepEqual(docById(unbezahlt.docId), vorher)
|
||||
assert.equal(workorders(), tickets)
|
||||
|
||||
const k = legeEinrichtung({ ticket: null, session: 'paid' })
|
||||
const vorherK = { ...docById(k.docId) }
|
||||
st.failNext({ method: 'GET', path: /^\/v1\/checkout\/sessions\//, status: 400, message: 'Nachbau: Stripe kaputt' })
|
||||
const aus = await nachlegen(k)
|
||||
assert.equal(aus.status, 503)
|
||||
assert.equal(typeof aus.body.error, 'string')
|
||||
assert.ok(!JSON.stringify(aus.body).includes('Nachbau'), 'nichts Technisches nach aussen')
|
||||
assert.deepEqual(docById(k.docId), vorherK)
|
||||
assert.equal(workorders(), tickets)
|
||||
assert.equal(seit(mailsVorher).length, 0)
|
||||
assert.ok(log.mock.calls.some((c) => /Nachbau: Stripe kaputt/.test(c.arguments.join(' '))), 'Ursache im Log')
|
||||
|
||||
assert.equal((await nachlegen(k)).status, 200, 'Stripe wieder da')
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
assert.equal(seit(mailsVorher).length, 2)
|
||||
})
|
||||
|
||||
test('ticket (a): ohne vermerkte Sitzung => 409, Stripe wird nicht gefragt', async () => {
|
||||
const k = legeEinrichtung({ ticket: null })
|
||||
const aufrufe = st.calls.length
|
||||
const res = await nachlegen(k)
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, 'Zu dieser Funktion ist keine Zahlung vermerkt.')
|
||||
assert.equal(st.calls.length, aufrufe)
|
||||
})
|
||||
|
||||
test('ticket (b): Ticket geloescht => neues Ticket mit neuer ticketId, keine Mail, Stripe nicht gefragt', async () => {
|
||||
const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
|
||||
const tickets = workorders()
|
||||
const mailsVorher = mails.sent.length
|
||||
const aufrufe = st.calls.length
|
||||
assert.equal(ticketDoc(k.ticketId), undefined, 'Ausgangslage: Ticket fehlt')
|
||||
assert.equal(featuresOf({ body: await get(`/api/team/funktionen/${k.customerId}`, STAFF) }, k.projectId).termine.ticketMissing, true)
|
||||
const res = await nachlegen(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const doc = docById(k.docId)
|
||||
assert.ok(doc.ticketId && doc.ticketId !== k.ticketId, 'neue ticketId')
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
const ticket = ticketDoc(doc.ticketId)
|
||||
assert.match(ticket.topic, /^Terminplaner einbauen – Salon \d+$/)
|
||||
assert.ok(ticket.details.includes('Konfigurationsstand: Version 1'))
|
||||
assert.equal(ticket.status, 'Open')
|
||||
assert.equal(doc.state, 'setup')
|
||||
assert.match(doc.adminNote, /Andrej: ticket/)
|
||||
assert.equal(featuresOf(res, k.projectId).termine.ticketMissing, false)
|
||||
assert.equal(seit(mailsVorher).length, 0, 'keine Mails: K15/T16 gingen beim ersten Mal raus')
|
||||
assert.equal(st.calls.length, aufrufe, 'Stripe nicht gefragt')
|
||||
})
|
||||
|
||||
test('ticket (b): zwei parallele Klicks und zwei gleichzeitige Dienstaufrufe => genau ein neues Ticket', async () => {
|
||||
const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
|
||||
const tickets = workorders()
|
||||
const [a, b] = await Promise.all([nachlegen(k), nachlegen(k)])
|
||||
assert.deepEqual([a.status, b.status], [200, 200])
|
||||
assert.equal(workorders(), tickets + 1)
|
||||
|
||||
const k2 = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
|
||||
const tickets2 = workorders()
|
||||
const [x, y] = await Promise.all([dienst(k2, 'ticketNachlegen'), dienst(k2, 'ticketNachlegen')])
|
||||
assert.deepEqual([x.created, y.created].sort(), [false, true])
|
||||
assert.equal(workorders(), tickets2 + 1)
|
||||
assert.equal(x.doc.ticketId, y.doc.ticketId)
|
||||
})
|
||||
|
||||
test('ticket (c): Ticket vorhanden (offen oder geschlossen) => no-op, created false, nichts geschrieben, Stripe nicht gefragt', async () => {
|
||||
for (const ticket of ['Open', 'Closed']) {
|
||||
const k = legeEinrichtung({ ticket, session: 'paid' })
|
||||
const tickets = workorders()
|
||||
const mailsVorher = mails.sent.length
|
||||
const aufrufe = st.calls.length
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const res = await nachlegen(k)
|
||||
assert.equal(res.status, 200, ticket)
|
||||
assert.deepEqual(docById(k.docId), vorher, ticket)
|
||||
assert.equal(workorders(), tickets, ticket)
|
||||
assert.equal(seit(mailsVorher).length, 0, ticket)
|
||||
assert.equal(st.calls.length, aufrufe, ticket)
|
||||
const dienstErgebnis = await dienst(k, 'ticketNachlegen')
|
||||
assert.equal(dienstErgebnis.created, false, ticket)
|
||||
assert.equal(dienstErgebnis.doc.ticketId, k.ticketId, ticket)
|
||||
}
|
||||
})
|
||||
|
||||
test('ticket: nur in setup - off, ordered, active und disabled => 409; ohne Dokument => 409', async () => {
|
||||
const faelle = [
|
||||
legeEinrichtung({ state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } }),
|
||||
legeEinrichtung({ state: 'ordered', ticket: null, session: 'paid', over: { purchaseStatus: 'ordered', unlockedByPurchase: false } }),
|
||||
legeEinrichtung({ state: 'active', ticket: 'geloescht', session: 'paid' }),
|
||||
legeEinrichtung({ state: 'disabled', ticket: null, session: 'paid', over: { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' } }),
|
||||
]
|
||||
const tickets = workorders()
|
||||
const mailsVorher = mails.sent.length
|
||||
for (const k of faelle) {
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const res = await nachlegen(k)
|
||||
assert.equal(res.status, 409, vorher.state)
|
||||
assert.equal(res.body.error, NUR_SETUP_TICKET, vorher.state)
|
||||
assert.deepEqual(docById(k.docId), vorher, vorher.state)
|
||||
}
|
||||
assert.equal(workorders(), tickets)
|
||||
assert.equal(seit(mailsVorher).length, 0)
|
||||
aw.seed('websiteProjects', { $id: 'pt_d7_leer2', customerId: 'c1', projectName: 'Ohne Dokument D7b' })
|
||||
const docs = aw.docs('portalFeatures').size
|
||||
const ohneDoc = await team('c1', 'termine', { projectId: 'pt_d7_leer2', action: 'ticket' })
|
||||
assert.equal(ohneDoc.status, 409)
|
||||
assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
|
||||
assert.equal(aw.docs('portalFeatures').size, docs)
|
||||
})
|
||||
|
||||
test('ticket: fremdes Projekt => 403, Kundensitzung statt JWT => 401/403, nichts geschrieben', async () => {
|
||||
const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const tickets = workorders()
|
||||
assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'ticket' })).status, 403)
|
||||
const alsKunde = await nachlegen(k, 'termine', { cookie: customerCookie(k.customerId) })
|
||||
assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`)
|
||||
assert.deepEqual(docById(k.docId), vorher)
|
||||
assert.equal(workorders(), tickets)
|
||||
})
|
||||
|
||||
test('ticket bei News (a): derselbe Weg des Webhooks, K15 und T16 der News-Pinnwand', async () => {
|
||||
const k = legeEinrichtung({ key: 'news', abo: null, einstellung: false, ticket: null, session: 'paid' })
|
||||
const mailsVorher = mails.sent.length
|
||||
const res = await nachlegen(k, 'news')
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const doc = docById(k.docId)
|
||||
assert.ok(doc.ticketId)
|
||||
assert.equal(doc.state, 'setup')
|
||||
const neu = seit(mailsVorher)
|
||||
assert.equal(neu.filter((m) => m.subject === 'Neue Funktion bestellt: News-Pinnwand').length, 1)
|
||||
assert.equal(neu.filter((m) => m.to.some((to) => to.address === k.email)).length, 1, 'K15 an den Kunden')
|
||||
})
|
||||
|
||||
// ------------------------------------------- Team-Route Einbauanleitung (D8) ---
|
||||
// GET /api/team/funktionen/:customerId/termine/anleitung?projectId=: die volle Anleitung (ohne `kurz`) fuer das WOMS-Panel, nur lesend,
|
||||
// in jedem Zustand und bei jedem Modus. Dazu hindernis je setup-Dokument in der Kundenansicht des Teams (GET /:customerId).
|
||||
const ANLEITUNG_IDS = [
|
||||
'modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen',
|
||||
'einbau', 'snippet', 'css', 'datenschutz_sie', 'datenschutz_du', 'checkliste', 'abrechnung',
|
||||
]
|
||||
const ANLEITUNG_KEYS = ['abschnitte', 'hindernis', 'stand', 'state', 'text', 'ticketVersion', 'ticketWoid', 'warnungen'] // sortiert
|
||||
const D8_BETRIEB = {
|
||||
name: 'Salon Muster', email: 'inhaber@salon-muster.example', telefon: '0631 123456', adresse: 'Hauptstraße 1, 67655 Kaiserslautern',
|
||||
kleinunternehmer: false, impressumUrl: 'https://salon-muster.example/impressum', datenschutzUrl: 'https://salon-muster.example/datenschutz', bundesland: 'RP',
|
||||
}
|
||||
/** Salon-Vorlage mit einer zweiten Person; beide Personen tragen einen feedToken (nie in die Antwort). */
|
||||
function d8Einstellung() {
|
||||
const e = ausVorlage('salon', D8_BETRIEB, new Date('2026-10-05T10:00:00Z'))
|
||||
e.ressourcen.push({ id: 'r_ben00001', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['08:00', '16:00']] }, feedToken: neuerFeedToken() })
|
||||
return e
|
||||
}
|
||||
const TICKET_MIT_STAND = (version) =>
|
||||
`Funktion: Terminplaner\nProjekt: Salon\nDomain: salon.example\nKunde: Salon\nsiteKey: abcdefghijklmnop\nKonfigurationsstand: Version ${version}, 03.10.2026\n\nSchritte:\n1. WOMS`
|
||||
|
||||
/**
|
||||
* Funktionsdokument wie legeEinrichtung (Abo, Ticket), aber mit einer vollen Einstellung in termineConfig.
|
||||
* einstellung: Objekt | false (kein termineConfig-Dokument) | string (roh gespeichert); ticketDetails ersetzt die Details eines vorhandenen Tickets.
|
||||
*/
|
||||
function legeAnleitung({ einstellung = d8Einstellung(), version = 1, ticketDetails, ...rest } = {}) {
|
||||
const k = legeEinrichtung({ ...rest, einstellung: false })
|
||||
if (einstellung !== false) {
|
||||
aw.seed('termineConfig', {
|
||||
$id: k.docId, customerId: k.customerId, projectId: k.projectId,
|
||||
einstellung: typeof einstellung === 'string' ? einstellung : JSON.stringify(einstellung), version, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: 'test',
|
||||
})
|
||||
}
|
||||
const ticket = ticketDoc(k.ticketId)
|
||||
if (ticketDetails !== undefined && ticket) {
|
||||
aw.seed('workorders', { $id: k.ticketId, woid: ticket.woid, status: ticket.status, topic: ticket.topic, details: ticketDetails })
|
||||
}
|
||||
return k
|
||||
}
|
||||
const anleitungPfad = (k, query = `projectId=${k.projectId}`) => `/api/team/funktionen/${k.customerId}/termine/anleitung${query ? `?${query}` : ''}`
|
||||
const anleitung = (k, opts = STAFF, query) => rawGet(anleitungPfad(k, query), opts)
|
||||
const warnungDrift = (ticketVersion, jetzt) => `Einstellungen seit der Bestellung geändert (Ticket Version ${ticketVersion}, jetzt ${jetzt}).`
|
||||
|
||||
/** Fuehrt fn aus und zaehlt die schreibenden Anfragen (alles ausser GET) an den Appwrite-Nachbau. */
|
||||
async function zaehleSchreibzugriffe(fn) {
|
||||
const echt = globalThis.fetch
|
||||
const schreibend = []
|
||||
globalThis.fetch = async (url, optionen) => {
|
||||
const methode = optionen?.method ?? 'GET'
|
||||
if (methode !== 'GET' && String(url).startsWith(aw.url)) schreibend.push(`${methode} ${String(url).replace(/\?.*/, '')}`)
|
||||
return echt(url, optionen)
|
||||
}
|
||||
try {
|
||||
return { ergebnis: await fn(), schreibend }
|
||||
} finally {
|
||||
globalThis.fetch = echt
|
||||
}
|
||||
}
|
||||
const modusMerken = (t) => {
|
||||
const vorher = { mode: config.termine.mode, pilotEmails: [...config.termine.pilotEmails] }
|
||||
t.after(() => termineMit(vorher.mode, vorher.pilotEmails))
|
||||
}
|
||||
|
||||
test('Anleitung: ohne JWT 401, mit Kundensitzung statt JWT 401/403, die Route gibt es im Kundenrouter nicht (404)', async () => {
|
||||
const k = legeAnleitung()
|
||||
const ohne = await anleitung(k, {})
|
||||
assert.equal(ohne.status, 401)
|
||||
const alsKunde = await anleitung(k, { cookie: customerCookie(k.customerId) })
|
||||
assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`)
|
||||
assert.ok(!JSON.stringify(alsKunde.body).includes('abschnitte'), 'keine Anleitung fuer den Kunden')
|
||||
// kein Pfad unter /api/funktionen (Kundenrouter), auch nicht mit gueltiger Kundensitzung
|
||||
const kundenpfad = await rawGet(`/api/funktionen/termine/anleitung?projectId=${k.projectId}`, { cookie: customerCookie(k.customerId) })
|
||||
assert.equal(kundenpfad.status, 404)
|
||||
assert.equal((await rawGet('/api/funktionen/termine/anleitung', { cookie: customerCookie(k.customerId) })).status, 404)
|
||||
})
|
||||
|
||||
test('Anleitung: projectId fehlt oder ist leer oder doppelt => 400; fremdes oder unbekanntes Projekt => 403; ohne Dokument => 409 ohne Dokument anzulegen', async () => {
|
||||
const k = legeAnleitung()
|
||||
for (const query of ['', 'projectId=', 'projectId=a&projectId=b', 'projekt=x']) {
|
||||
const res = await anleitung(k, STAFF, query)
|
||||
assert.equal(res.status, 400, query)
|
||||
assert.equal(typeof res.body.error, 'string', query)
|
||||
}
|
||||
// pt3 gehoert c2, nicht dem Kunden in der URL
|
||||
assert.equal((await anleitung(k, STAFF, 'projectId=pt3')).status, 403)
|
||||
assert.equal((await rawGet(`/api/team/funktionen/c1/termine/anleitung?projectId=pt3`, STAFF)).status, 403, 'Projekt von c2 fuer c1')
|
||||
assert.equal((await anleitung(k, STAFF, 'projectId=gibtsnicht')).status, 403)
|
||||
const docs = aw.docs('portalFeatures').size
|
||||
aw.seed('websiteProjects', { $id: 'pt_d8_leer', customerId: 'c1', projectName: 'Ohne Dokument D8' })
|
||||
const ohneDoc = await rawGet('/api/team/funktionen/c1/termine/anleitung?projectId=pt_d8_leer', STAFF)
|
||||
assert.equal(ohneDoc.status, 409)
|
||||
assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
|
||||
assert.equal(ohneDoc.body.error, `Für dieses Projekt gibt es noch ${sf.FEATURE_LABELS.termine.keine}`)
|
||||
assert.equal(aw.docs('portalFeatures').size, docs, 'kein Dokument entstanden')
|
||||
// ein Dokument der News-Pinnwand ist kein Terminplaner
|
||||
const news = legeEinrichtung({ key: 'news', einstellung: false })
|
||||
assert.equal((await rawGet(`/api/team/funktionen/${news.customerId}/termine/anleitung?projectId=${news.projectId}`, STAFF)).status, 409)
|
||||
})
|
||||
|
||||
test('Anleitung: 200 in jedem Zustand und bei Modus off, nur lesend, ohne Kundenansicht-Protokoll, mit state und ohne kurz', async (t) => {
|
||||
modusMerken(t)
|
||||
termineMit('off')
|
||||
const faelle = {
|
||||
off: { state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } },
|
||||
ordered: { state: 'ordered', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: 'ordered' } },
|
||||
setup: { state: 'setup' },
|
||||
active: { state: 'active' },
|
||||
disabled: { state: 'disabled', over: { disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z' } },
|
||||
}
|
||||
const protokoll = aw.docs('customerViewLog').size
|
||||
for (const [state, optionen] of Object.entries(faelle)) {
|
||||
const k = legeAnleitung(optionen)
|
||||
const { ergebnis: res, schreibend } = await zaehleSchreibzugriffe(() => anleitung(k))
|
||||
assert.equal(res.status, 200, `${state}: ${JSON.stringify(res.body)}`)
|
||||
assert.equal(res.body.state, state)
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ANLEITUNG_KEYS, state)
|
||||
assert.ok(!('kurz' in res.body), `${state}: kurz bleibt im Ticket`)
|
||||
assert.deepEqual(schreibend, [], `${state}: die Route schreibt nichts`)
|
||||
}
|
||||
assert.equal(aw.docs('customerViewLog').size, protokoll, 'kein Eintrag im Protokoll der Kundenansicht')
|
||||
})
|
||||
|
||||
test('Anleitung: die Antwort enthaelt keinen feedToken der Einstellung', async () => {
|
||||
const einstellung = d8Einstellung()
|
||||
const tokens = einstellung.ressourcen.map((r) => r.feedToken)
|
||||
assert.equal(tokens.length, 2)
|
||||
assert.ok(tokens.every((tk) => typeof tk === 'string' && tk.length >= 20), 'Ausgangslage: echte Token')
|
||||
const k = legeAnleitung({ einstellung })
|
||||
const res = await anleitung(k)
|
||||
assert.equal(res.status, 200)
|
||||
const json = JSON.stringify(res.body)
|
||||
for (const tk of tokens) assert.ok(!json.includes(tk), 'feedToken in der Antwort')
|
||||
assert.ok(!json.includes('feedToken'))
|
||||
assert.ok(!('kurz' in res.body))
|
||||
assert.ok(!json.includes('Konfigurationsstand:'), 'die Kurzfassung des Tickets (mit ihrer Standzeile) gehoert nicht in die Antwort')
|
||||
})
|
||||
|
||||
test('Anleitung mit Einstellung: 13 Abschnitte wie der Generator, stand.version 1, Volltext, Warnungen als Liste', async () => {
|
||||
const k = legeAnleitung()
|
||||
const res = await anleitung(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(res.body.abschnitte.map((a) => a.id), ANLEITUNG_IDS)
|
||||
for (const a of res.body.abschnitte) assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id)
|
||||
assert.deepEqual(res.body.abschnitte.filter((a) => a.kopierbar).map((a) => a.id), ['snippet', 'css', 'datenschutz_sie', 'datenschutz_du'])
|
||||
assert.deepEqual(res.body.stand, { version: 1, updatedAt: '2026-10-03T08:15:00.000Z' })
|
||||
assert.equal(typeof res.body.text, 'string')
|
||||
assert.ok(res.body.text.includes(res.body.abschnitte[0].titel))
|
||||
assert.ok(Array.isArray(res.body.warnungen) && res.body.warnungen.every((w) => typeof w === 'string'))
|
||||
const snippet = res.body.abschnitte.find((a) => a.id === 'snippet').text
|
||||
assert.ok(snippet.includes('data-webklar-termine="abcdefghijklmnop"'), 'siteKey des Dokuments im Einbau-Code')
|
||||
// eine hoehere Version der Einstellung kommt an
|
||||
const v4 = legeAnleitung({ version: 4 })
|
||||
assert.equal((await anleitung(v4)).body.stand.version, 4)
|
||||
})
|
||||
|
||||
test('Anleitung ohne gespeicherte Einstellung: nur der Einbau-Code und die Warnung, stand.version 0; unlesbare Einstellung ebenso mit der Version des Dokuments', async (t) => {
|
||||
t.mock.method(console, 'error', () => {})
|
||||
const k = legeAnleitung({ einstellung: false })
|
||||
const res = await anleitung(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(res.body.abschnitte.map((a) => a.id), ['snippet'])
|
||||
assert.ok(res.body.warnungen.includes('Keine Einstellungen gespeichert'), res.body.warnungen.join(' | '))
|
||||
assert.deepEqual(res.body.stand, { version: 0, updatedAt: null })
|
||||
assert.ok(res.body.abschnitte[0].text.includes('abcdefghijklmnop'))
|
||||
// kaputtes JSON im Dokument: wie "keine Einstellung", die Version bleibt sichtbar
|
||||
const kaputt = legeAnleitung({ einstellung: '{kaputt', version: 4 })
|
||||
const r2 = await anleitung(kaputt)
|
||||
assert.equal(r2.status, 200, JSON.stringify(r2.body))
|
||||
assert.deepEqual(r2.body.abschnitte.map((a) => a.id), ['snippet'])
|
||||
assert.ok(r2.body.warnungen.includes('Keine Einstellungen gespeichert'))
|
||||
assert.equal(r2.body.stand.version, 4)
|
||||
})
|
||||
|
||||
test('Anleitung, Drift: Ticket auf Version 1, Einstellung jetzt Version 3 => Warnung am Ende von warnungen, ticketVersion und ticketWoid', async () => {
|
||||
// eine andere Warnung muss davor stehen (ohne Entwurfs-Banner sonst keine): fehlende Impressum-URL
|
||||
const einstellung = d8Einstellung()
|
||||
einstellung.betrieb.impressumUrl = ''
|
||||
const k = legeAnleitung({ einstellung, version: 3, ticketDetails: TICKET_MIT_STAND(1) })
|
||||
const res = await anleitung(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(warnungDrift(1, 3), 'Einstellungen seit der Bestellung geändert (Ticket Version 1, jetzt 3).')
|
||||
assert.equal(res.body.warnungen.at(-1), warnungDrift(1, 3))
|
||||
assert.ok(res.body.warnungen.length > 1 && /Impressum/.test(res.body.warnungen[0]), `sie steht hinter den uebrigen Warnungen: ${res.body.warnungen.join(' | ')}`)
|
||||
assert.equal(res.body.warnungen.filter((w) => w.startsWith('Einstellungen seit der Bestellung')).length, 1)
|
||||
assert.equal(res.body.ticketVersion, 1)
|
||||
assert.equal(res.body.ticketWoid, ticketDoc(k.ticketId).woid)
|
||||
assert.deepEqual(res.body.stand.version, 3)
|
||||
})
|
||||
|
||||
test('Anleitung, Drift: gleiche oder neuere Version im Ticket, Ticket ohne Standzeile, Ticket geloescht, Funktion ohne ticketId => keine Warnung und kein Fehler', async () => {
|
||||
const faelle = {
|
||||
'gleiche Version': { opts: { version: 3, ticketDetails: TICKET_MIT_STAND(3) }, ticketVersion: 3, hatTicket: true },
|
||||
'Ticket neuer als die Einstellung': { opts: { version: 3, ticketDetails: TICKET_MIT_STAND(5) }, ticketVersion: 5, hatTicket: true },
|
||||
'Ticket ohne Standzeile': { opts: { version: 3 }, ticketVersion: null, hatTicket: true },
|
||||
'Ticket geloescht': { opts: { version: 3, ticket: 'geloescht' }, ticketVersion: null, hatTicket: false },
|
||||
'ohne ticketId': { opts: { version: 3, ticket: null }, ticketVersion: null, hatTicket: false },
|
||||
}
|
||||
for (const [name, { opts, ticketVersion, hatTicket }] of Object.entries(faelle)) {
|
||||
const k = legeAnleitung(opts)
|
||||
const res = await anleitung(k)
|
||||
assert.equal(res.status, 200, `${name}: ${JSON.stringify(res.body)}`)
|
||||
assert.ok(!res.body.warnungen.some((w) => /seit der Bestellung/.test(w)), `${name}: ${res.body.warnungen.join(' | ')}`)
|
||||
assert.equal(res.body.ticketVersion, ticketVersion, name)
|
||||
assert.equal(res.body.ticketWoid, hatTicket ? ticketDoc(k.ticketId).woid : null, name)
|
||||
}
|
||||
// Ticket aus der Zeit ohne Einstellung (Version 0), inzwischen gespeichert (Version 1)
|
||||
const nullStand = legeAnleitung({ version: 1, ticketDetails: 'Funktion: Terminplaner\nKonfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)' })
|
||||
const r0 = await anleitung(nullStand)
|
||||
assert.equal(r0.body.ticketVersion, 0)
|
||||
assert.equal(r0.body.warnungen.at(-1), warnungDrift(0, 1))
|
||||
})
|
||||
|
||||
test('Anleitung, Drift: ein nicht lesbares Ticket (5xx) ist ein Fehler, keine stille Luecke; ein Aufruf spaeter geht', async (t) => {
|
||||
t.mock.method(console, 'error', () => {})
|
||||
const k = legeAnleitung({ version: 3, ticketDetails: TICKET_MIT_STAND(1) })
|
||||
aw.failNext({ collection: 'workorders', method: 'GET', status: 500, message: 'Nachbau: Ticket kaputt' })
|
||||
const aus = await anleitung(k)
|
||||
assert.ok(aus.status >= 500 && aus.status < 600, `Status ${aus.status}`)
|
||||
assert.equal(typeof aus.body.error, 'string')
|
||||
assert.equal((await anleitung(k)).status, 200)
|
||||
})
|
||||
|
||||
test('Anleitung, hindernis: nur in setup - ohne Abo kein_abo, mit Abo und Einstellung null, ohne Einstellung keine_einstellung; sonst null und das Abo bleibt ungelesen', async () => {
|
||||
const setupOhneAbo = legeAnleitung({ abo: null })
|
||||
assert.equal((await anleitung(setupOhneAbo)).body.hindernis, 'kein_abo')
|
||||
assert.equal((await anleitung(legeAnleitung({ abo: 'hosting' }))).body.hindernis, 'kein_abo', 'Hosting ist kein Wartungs-Abo')
|
||||
const beendet = legeAnleitung({ abo: 'wartung' })
|
||||
aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: beendet.customerId, status: 'canceled', endedAt: '2026-10-01T10:00:00.000Z' }))
|
||||
assert.equal((await anleitung(beendet)).body.hindernis, 'kein_abo', 'beendetes Abo')
|
||||
const frei = await anleitung(legeAnleitung())
|
||||
assert.equal(frei.body.hindernis, null)
|
||||
assert.equal(frei.body.state, 'setup')
|
||||
assert.equal((await anleitung(legeAnleitung({ einstellung: false }))).body.hindernis, 'keine_einstellung')
|
||||
// eine vorgemerkte Kuendigung und past_due laufen noch
|
||||
const gekuendigt = legeAnleitung()
|
||||
aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: gekuendigt.customerId, cancelAtPeriodEnd: true }))
|
||||
assert.equal((await anleitung(gekuendigt)).body.hindernis, null)
|
||||
|
||||
// active, disabled, ordered und off: null, auch ohne Abo, und das Abo wird gar nicht erst gelesen
|
||||
const vorher = aw.listCalls('subscriptions').length
|
||||
for (const [state, optionen] of Object.entries({
|
||||
active: { state: 'active', abo: null },
|
||||
disabled: { state: 'disabled', abo: null, over: { disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z' } },
|
||||
ordered: { state: 'ordered', abo: null, ticket: null, over: { unlockedByPurchase: false, purchaseStatus: 'ordered' } },
|
||||
off: { state: 'off', abo: null, ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } },
|
||||
})) {
|
||||
const res = await anleitung(legeAnleitung(optionen))
|
||||
assert.equal(res.status, 200, state)
|
||||
assert.equal(res.body.hindernis, null, state)
|
||||
}
|
||||
assert.equal(aw.listCalls('subscriptions').length, vorher, 'ausserhalb von setup keine Abo-Abfrage')
|
||||
})
|
||||
|
||||
test('Anleitung, hindernis: Abo nicht lesbar => 200 mit abo_unbekannt (geloggt), nie ein stilles null', async (t) => {
|
||||
const log = t.mock.method(console, 'error', () => {})
|
||||
const k = legeAnleitung()
|
||||
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Nachbau: Abo kaputt' })
|
||||
const res = await anleitung(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(res.body.hindernis, 'abo_unbekannt')
|
||||
assert.ok(log.mock.calls.length >= 1, 'der Fehler steht im Log')
|
||||
assert.equal((await anleitung(k)).body.hindernis, null, 'der naechste Aufruf liest wieder')
|
||||
})
|
||||
|
||||
test('Anleitung, Abrechnung: der Rechnungs-Link erscheint, sobald invoiceUrl im Dokument steht (auch bei altem Ticket); Preis aus dem Register', async () => {
|
||||
const k = legeAnleitung({ ticketDetails: TICKET_MIT_STAND(1) })
|
||||
const abrechnung = async () => (await anleitung(k)).body.abschnitte.find((a) => a.id === 'abrechnung').text
|
||||
const davor = await abrechnung()
|
||||
assert.ok(davor.includes('Rechnung folgt im Portal'), davor)
|
||||
assert.ok(davor.includes(config.features.termine.priceLabel), davor)
|
||||
const ticketVorher = { ...ticketDoc(k.ticketId) }
|
||||
// backfillInvoiceLinks traegt den Link spaeter nach: die Anleitung zeigt ihn sofort, das Ticket bleibt, wie es war
|
||||
await updateDocument(config.collections.portalFeatures, k.docId, { invoiceUrl: 'https://invoice.stripe.test/in_d8' })
|
||||
const danach = await abrechnung()
|
||||
assert.ok(danach.includes('https://invoice.stripe.test/in_d8'), danach)
|
||||
assert.ok(!danach.includes('folgt im Portal'), danach)
|
||||
assert.deepEqual(ticketDoc(k.ticketId), ticketVorher, 'das Ticket wird nicht angefasst')
|
||||
})
|
||||
|
||||
// ---- Teamansicht GET /:customerId: hindernis je setup-Dokument
|
||||
|
||||
test('Teamansicht: hindernis je Terminplaner in setup, null in jedem anderen Zustand und bei News; die Funktionsdokumente werden einmal gelesen', async (t) => {
|
||||
const kunde = legeAnleitung() // Abo und Einstellung vorhanden
|
||||
// zweites Projekt desselben Kunden in setup, ohne Einstellung
|
||||
aw.seed('websiteProjects', { $id: 'cdp_zwei', customerId: kunde.customerId, projectName: 'Zweites Projekt' })
|
||||
aw.seed('portalFeatures', { $id: 'f_cdp_zwei_termine', customerId: kunde.customerId, projectId: 'cdp_zwei', featureKey: 'termine', siteKey: 'bcdefghijklmnopq', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' })
|
||||
// drittes Projekt: aktiv, viertes: News in setup
|
||||
aw.seed('websiteProjects', { $id: 'cdp_drei', customerId: kunde.customerId, projectName: 'Drittes Projekt' })
|
||||
aw.seed('portalFeatures', { $id: 'f_cdp_drei_termine', customerId: kunde.customerId, projectId: 'cdp_drei', featureKey: 'termine', siteKey: 'cdefghijklmnopqr', state: 'active', enabled: true })
|
||||
aw.seed('portalFeatures', { $id: 'f_cdp_drei_news', customerId: kunde.customerId, projectId: 'cdp_drei', featureKey: 'news', siteKey: 'defghijklmnopqrs', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' })
|
||||
|
||||
const echt = globalThis.fetch
|
||||
const abfragen = []
|
||||
globalThis.fetch = async (url, optionen) => {
|
||||
if (/\/collections\/portalFeatures\/documents/.test(String(url))) abfragen.push(`${optionen?.method ?? 'GET'} ${String(url).replace(/\?.*/, '')}`)
|
||||
return echt(url, optionen)
|
||||
}
|
||||
t.after(() => (globalThis.fetch = echt))
|
||||
const abosVorher = aw.listCalls('subscriptions').length
|
||||
const res = await get(`/api/team/funktionen/${kunde.customerId}`, STAFF)
|
||||
globalThis.fetch = echt
|
||||
|
||||
assert.equal(abfragen.length, 1, abfragen.join(' | '))
|
||||
const features = (projectId) => res.projects.find((p) => p.projectId === projectId).features
|
||||
assert.equal(features(kunde.projectId).termine.hindernis, null, 'setup mit Abo und Einstellung')
|
||||
assert.equal(features('cdp_zwei').termine.hindernis, 'keine_einstellung', 'setup ohne Einstellung')
|
||||
assert.equal(features('cdp_drei').termine.hindernis, null, 'active')
|
||||
assert.equal(features('cdp_drei').news.hindernis, null, 'News in setup hat keine Freischalt-Hindernisse')
|
||||
assert.equal(features(kunde.projectId).news.hindernis, null, 'kein Dokument')
|
||||
for (const p of res.projects) for (const view of Object.values(p.features)) assert.ok('hindernis' in view, `${p.projectId}: Feld hindernis fehlt`)
|
||||
assert.equal(aw.listCalls('subscriptions').length - abosVorher, 1, 'zwei setup-Dokumente, ein Abo-Lauf (aboMemo je Anfrage)')
|
||||
|
||||
// ohne Abo: kein_abo, und die Ansicht der uebrigen Felder bleibt wie bisher
|
||||
const ohneAbo = legeAnleitung({ abo: null })
|
||||
const r2 = await get(`/api/team/funktionen/${ohneAbo.customerId}`, STAFF)
|
||||
const view = r2.projects.find((p) => p.projectId === ohneAbo.projectId).features.termine
|
||||
assert.equal(view.hindernis, 'kein_abo')
|
||||
assert.equal(view.state, 'setup')
|
||||
assert.equal(view.ticketMissing, false)
|
||||
})
|
||||
@@ -4,15 +4,14 @@ import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { aboMeldung } from '../server/services/featureOrder.js'
|
||||
import { featureConfig } from '../server/services/featureRegistry.js'
|
||||
|
||||
// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js werden auf die Dateien umgeleitet)
|
||||
// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js und der relative ./vertraege-link.js werden auf die Dateien umgeleitet)
|
||||
const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-'))
|
||||
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
|
||||
const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
|
||||
.replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`)
|
||||
.replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`)
|
||||
.replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`)
|
||||
const file = join(dir, 'funktionen.mjs')
|
||||
writeFileSync(file, src)
|
||||
const { initFunktionen, initNews, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href)
|
||||
@@ -114,7 +113,9 @@ test('News-Buchungsseite: Preis, Knopf und Zustimmungstext aus features.news, se
|
||||
features: { news: register },
|
||||
})
|
||||
assert.match(stage.innerHTML, /<strong>31 €<\/strong>/)
|
||||
assert.match(stage.innerHTML, /inklusive Mehrwertsteuer/, 'der Wortlaut bleibt bis Block F')
|
||||
assert.doesNotMatch(stage.innerHTML, /Mehrwertsteuer/, 'der Preishinweis kommt aus ustHinweis (Block F)')
|
||||
assert.match(stage.innerHTML, /einmalig, <span id="news-ust-hinweis"><\/span>/)
|
||||
assert.equal(stage.els['#news-ust-hinweis'].textContent, 'ohne Umsatzsteuer gemäß § 19 UStG', 'Rückfall ohne ustHinweis in der Antwort')
|
||||
assert.match(stage.innerHTML, /id="news-order" disabled>Jetzt kaufen · 31 €</)
|
||||
assert.equal(stage.els['#news-consent-text'].textContent, 'Registertext der Zustimmung')
|
||||
assert.equal(knopfAktiv, true)
|
||||
@@ -149,7 +150,7 @@ test('statisch: News postet weiter consent: true an /api/funktionen/news/checkou
|
||||
const withTermine = (id, state, extra = {}) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news, termine: { state, ...extra } } })
|
||||
const noAnalyse = { comingSoon: true }
|
||||
// Standardantwort bei verfuegbar: true wie GET /api/funktionen (Kunde mit Wartungs-Abo); abo ueberschreibt einzelne Felder
|
||||
const ABO_OK = { bestellbar: true, grund: null, preis: '149 €', einrichtung: 'meist innerhalb von 1–2 Wochen' }
|
||||
const ABO_OK = { bestellbar: true, grund: null, preis: '149 €', einrichtung: 'meist innerhalb von 1–2 Wochen', hinweis: null }
|
||||
async function renderTermine(projects, { verfuegbar, anfragen = {}, abo = {} } = {}) {
|
||||
const calls = []
|
||||
globalThis.fetch = async (url) => {
|
||||
@@ -178,10 +179,10 @@ test('Terminplaner-Kachel: fehlt termine.verfuegbar oder ist false ⇒ „Kommt
|
||||
}
|
||||
})
|
||||
|
||||
// Die Abo-Hinweise der Kachel kommen aus aboMeldung (server/services/featureOrder.js, einzige Quelle der Abo-Hinweistexte);
|
||||
// die Kachel steht im Browser und kennt den Text nur als Kopie: dieser Test haelt beide gleich.
|
||||
const TERMINE_REGISTER = featureConfig('termine')
|
||||
const KEIN_ABO_HINWEIS = /Im Wartungs-Abo enthalten\. <button type="button" class="kb-link" data-view="vertraege">Zu Verträge<\/button>/
|
||||
// Die Abo-Hinweise der Kachel kommen vom Server (termine.hinweis = aboMeldung in server/services/featureOrder.js, einzige Quelle der
|
||||
// Abo-Hinweistexte, R111); der Browser hat keine Kopie und zeigt den Text nur maskiert an. Die Tests nehmen eigene Texte, damit
|
||||
// eine Kopie im Browser auffaellt. Der Link zu den Vertraegen steht nur bei kein_abo.
|
||||
const ZU_VERTRAEGE = '<button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button>'
|
||||
const knopfDerKachel = (kachel) => kachel.match(/<button[^>]*fn-tile-cta[^>]*>([^<]*)<\/button>/)
|
||||
|
||||
test('Terminplaner-Kachel, Tabelle: jede Zeile mit Pille, Pillenklasse, Knopftext und Ziel', async () => {
|
||||
@@ -195,10 +196,10 @@ test('Terminplaner-Kachel, Tabelle: jede Zeile mit Pille, Pillenklasse, Knopftex
|
||||
['Aktiv', [withTermine('a', 'active')], {}, 'on', 'Aktiv', 'Termine öffnen', 'termine'],
|
||||
['Abgeschaltet', [withTermine('a', 'disabled')], {}, 'off', 'Abgeschaltet', 'Termine ansehen', 'termine'],
|
||||
// das Abo betrifft nur die Bestellung: wer schon bestellt hat oder nutzt, behaelt seinen Weg in die Ansicht
|
||||
['Zahlung offen, Abo gekündigt', [withTermine('a', 'ordered')], { bestellbar: false, grund: 'gekuendigt' }, 'setup', 'Zahlung offen', 'Status ansehen', 'termine'],
|
||||
['In Einrichtung, Abo nicht lesbar', [withTermine('a', 'setup')], { bestellbar: null, grund: null }, 'setup', 'In Einrichtung', 'Status ansehen', 'termine'],
|
||||
['Aktiv, Abo gekündigt', [withTermine('a', 'active')], { bestellbar: false, grund: 'gekuendigt' }, 'on', 'Aktiv', 'Termine öffnen', 'termine'],
|
||||
['Abgeschaltet, Abo nicht aktiv', [withTermine('a', 'disabled')], { bestellbar: false, grund: 'nicht_aktiv' }, 'off', 'Abgeschaltet', 'Termine ansehen', 'termine'],
|
||||
['Zahlung offen, Abo gekündigt', [withTermine('a', 'ordered')], { bestellbar: false, grund: 'gekuendigt', hinweis: 'Serverhinweis gekündigt.' }, 'setup', 'Zahlung offen', 'Status ansehen', 'termine'],
|
||||
['In Einrichtung, Abo nicht lesbar', [withTermine('a', 'setup')], { bestellbar: null, grund: null, hinweis: 'Serverhinweis nicht prüfbar.' }, 'setup', 'In Einrichtung', 'Status ansehen', 'termine'],
|
||||
['Aktiv, Abo gekündigt', [withTermine('a', 'active')], { bestellbar: false, grund: 'gekuendigt', hinweis: 'Serverhinweis gekündigt.' }, 'on', 'Aktiv', 'Termine öffnen', 'termine'],
|
||||
['Abgeschaltet, Abo nicht aktiv', [withTermine('a', 'disabled')], { bestellbar: false, grund: 'nicht_aktiv', hinweis: 'Serverhinweis nicht aktiv.' }, 'off', 'Abgeschaltet', 'Termine ansehen', 'termine'],
|
||||
]
|
||||
for (const [name, projects, abo, klasse, pille, knopfText, ziel] of zeilen) {
|
||||
const { html } = await renderTermine(projects, { verfuegbar: true, abo })
|
||||
@@ -210,44 +211,78 @@ test('Terminplaner-Kachel, Tabelle: jede Zeile mit Pille, Pillenklasse, Knopftex
|
||||
assert.ok(knopf[0].includes(`data-view="${ziel}" data-project="a"`), `${name}: Ziel ${knopf[0]}`)
|
||||
assert.doesNotMatch(kachel, /Wir schalten den Terminplaner für dich frei/, name)
|
||||
assert.doesNotMatch(kachel, /data-view="chat"/, `${name}: kein Chat-Knopf mehr`)
|
||||
assert.doesNotMatch(kachel, /Serverhinweis/, `${name}: der Abo-Hinweis gilt nur im Zustand Aus`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Terminplaner-Kachel: Aus ohne Abo ⇒ kein Knopf und kein Weg in die Ansicht, Hinweis je Grund', async () => {
|
||||
const hinweise = [
|
||||
['kein_abo', { bestellbar: false, grund: 'kein_abo' }, KEIN_ABO_HINWEIS],
|
||||
['gekuendigt', { bestellbar: false, grund: 'gekuendigt' }, new RegExp(`${aboMeldung(TERMINE_REGISTER, 'gekuendigt')}`)],
|
||||
['nicht_aktiv', { bestellbar: false, grund: 'nicht_aktiv' }, new RegExp(aboMeldung(TERMINE_REGISTER, 'nicht_aktiv').replace(/[()]/g, '\\$&'))],
|
||||
['null (Lesefehler)', { bestellbar: null, grund: null }, /Das Abo konnte gerade nicht geprüft werden\./],
|
||||
['Feld fehlt (ältere Antwort)', { bestellbar: undefined, grund: undefined }, /Das Abo konnte gerade nicht geprüft werden\./],
|
||||
['unbekannter Grund', { bestellbar: false, grund: 'etwas_neues' }, /Das Abo konnte gerade nicht geprüft werden\./],
|
||||
test('Terminplaner-Kachel: Aus ohne Abo => kein Knopf und kein Weg in die Ansicht, der Hinweis kommt vom Server (Link zu Verträge nur bei kein_abo)', async () => {
|
||||
const faelle = [
|
||||
['kein_abo', { bestellbar: false, grund: 'kein_abo', hinweis: 'Serverhinweis ohne Abo.' }, `Serverhinweis ohne Abo. ${ZU_VERTRAEGE}`],
|
||||
['gekuendigt', { bestellbar: false, grund: 'gekuendigt', hinweis: 'Serverhinweis gekündigt & beendet.' }, 'Serverhinweis gekündigt & beendet.'],
|
||||
['nicht_aktiv', { bestellbar: false, grund: 'nicht_aktiv', hinweis: 'Serverhinweis „nicht aktiv“ (offen).' }, 'Serverhinweis „nicht aktiv“ (offen).'],
|
||||
['unbekannt (Lesefehler: bestellbar null)', { bestellbar: null, grund: null, hinweis: 'Serverhinweis nicht prüfbar.' }, 'Serverhinweis nicht prüfbar.'],
|
||||
['unbekannter Grund', { bestellbar: false, grund: 'etwas_neues', hinweis: 'Serverhinweis zu etwas Neuem.' }, 'Serverhinweis zu etwas Neuem.'],
|
||||
]
|
||||
// mit und ohne Entwurf: auch ein vorhandener Entwurf oeffnet ohne Abo keinen Weg
|
||||
for (const projekte of [[withTermine('a', 'off')], [withTermine('a', 'off', { siteKey: 'abcdefghijklmnop' })]]) {
|
||||
for (const [name, abo, hinweis] of hinweise) {
|
||||
for (const [name, abo, erwartet] of faelle) {
|
||||
const { html, calls } = await renderTermine(projekte, { verfuegbar: true, abo })
|
||||
const kachel = tile(html, 'termine')
|
||||
assert.match(kachel, /fn-pill-off">Aus</, name)
|
||||
assert.match(kachel, hinweis, `${name}: ${kachel}`)
|
||||
assert.ok(kachel.includes(`<p class="fn-count">${erwartet}</p>`), `${name}: ${kachel}`)
|
||||
assert.doesNotMatch(kachel, /data-view="termine"/, name)
|
||||
assert.doesNotMatch(kachel, /fn-tile-cta/, `${name}: kein Knopf`)
|
||||
assert.doesNotMatch(html, /data-view="termine"/, `${name}: nirgends ein Weg in die Ansicht`)
|
||||
assert.deepEqual(calls, ['/api/funktionen'], name)
|
||||
// nur kein_abo verweist auf die Vertraege; die uebrigen Hinweise sind reiner Text
|
||||
assert.equal(kachel.split('data-view=').length - 1, abo.grund === 'kein_abo' ? 1 : 0, `${name}: Links`)
|
||||
}
|
||||
}
|
||||
// nur kein_abo verweist auf die Vertraege; die uebrigen Hinweise sind reiner Text
|
||||
const gekuendigt = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'gekuendigt' } })).html, 'termine')
|
||||
assert.doesNotMatch(gekuendigt, /data-view=/)
|
||||
})
|
||||
|
||||
test('Terminplaner-Kachel: die Abo-Hinweise stimmen wortgleich mit aboMeldung überein (gekündigt, nicht aktiv, nicht lesbar)', async () => {
|
||||
for (const grund of ['gekuendigt', 'nicht_aktiv']) {
|
||||
const { html } = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund } })
|
||||
assert.ok(tile(html, 'termine').includes(`<p class="fn-count">${aboMeldung(TERMINE_REGISTER, grund)}</p>`), grund)
|
||||
test('Terminplaner-Kachel: der Hinweis des Servers kommt maskiert in die Kachel (auch bei kein_abo neben dem Link)', async () => {
|
||||
const bose = '<img src=x onerror=1>"\'&'
|
||||
const maskiert = '<img src=x onerror=1>"'&'
|
||||
for (const grund of ['gekuendigt', 'nicht_aktiv', 'kein_abo']) {
|
||||
const { html } = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund, hinweis: bose } })
|
||||
const kachel = tile(html, 'termine')
|
||||
assert.doesNotMatch(html, /<img/, grund)
|
||||
assert.ok(kachel.includes(`<p class="fn-count">${maskiert}${grund === 'kein_abo' ? ` ${ZU_VERTRAEGE}` : ''}</p>`), `${grund}: ${kachel}`)
|
||||
}
|
||||
const { html } = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: null, grund: null } })
|
||||
assert.ok(tile(html, 'termine').includes(`<p class="fn-count">${aboMeldung(TERMINE_REGISTER, 'unbekannt')}</p>`))
|
||||
assert.equal(aboMeldung(TERMINE_REGISTER, 'unbekannt'), 'Das Abo konnte gerade nicht geprüft werden.')
|
||||
})
|
||||
|
||||
test('Terminplaner-Kachel: fehlt der Hinweis (leer, null, kein Text) gibt es keine Zeile und keinen Knopf; kein_abo behält den Link', async () => {
|
||||
for (const hinweis of [undefined, null, '', 5, { text: 'x' }]) {
|
||||
const ohne = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'gekuendigt', hinweis } })).html, 'termine')
|
||||
assert.match(ohne, /fn-pill-off">Aus</)
|
||||
assert.doesNotMatch(ohne, /fn-tile-cta|data-view=|fn-count|\[object/, String(hinweis))
|
||||
const keinAbo = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'kein_abo', hinweis } })).html, 'termine')
|
||||
assert.ok(keinAbo.includes(`<p class="fn-count">${ZU_VERTRAEGE}</p>`), String(hinweis))
|
||||
assert.doesNotMatch(keinAbo, /fn-tile-cta|data-view="termine"/)
|
||||
}
|
||||
})
|
||||
|
||||
test('Terminplaner-Kachel: mit Abo (bestellbar true) steht der Knopf da und ein Hinweis erscheint nicht', async () => {
|
||||
const kachel = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { hinweis: 'Serverhinweis darf nicht erscheinen.' } })).html, 'termine')
|
||||
assert.equal(knopfDerKachel(kachel)[1], 'Terminplaner einrichten · 149 €')
|
||||
assert.doesNotMatch(kachel, /Serverhinweis|fn-count/)
|
||||
})
|
||||
|
||||
test('Der Browser hat keine Kopie der Abo-Hinweise; der Link zu Verträge hat eine einzige Quelle (vertraege-link.js: Analyse, Terminplaner-Kachel und Ansicht #termine teilen sie)', async () => {
|
||||
assert.doesNotMatch(src, /TERMINE_ABO_TEXTE|termineAboHinweis|KEIN_ABO_HINWEIS|ist gekündigt\. Für|gerade nicht aktiv|nicht geprüft werden/)
|
||||
const quelle = readFileSync(new URL('../public/vertraege-link.js', import.meta.url), 'utf8')
|
||||
assert.equal(quelle.split("view: 'vertraege'").length - 1, 1, 'Ziel, Text und Klasse stehen genau einmal in vertraege-link.js')
|
||||
assert.equal(src.split('data-view="vertraege"').length - 1, 0, 'funktionen.js baut den Link aus ZU_VERTRAEGE, ohne eigenes Ziel')
|
||||
assert.equal(src.split('ZU_VERTRAEGE_LINK =').length - 1, 1, 'ein Baustein für alle Stellen')
|
||||
// Analyse-Kachel und -Seite (unveraendert) und Terminplaner-Kachel zeigen genau diesen Link
|
||||
const analyse = tile(await render([project('a', { eligible: false, reason: 'kein_abo', reasonText: 'Kein Abo' })]), 'analyse')
|
||||
assert.ok(analyse.includes(`<p class="fn-count">Im Wartungs-Abo enthalten. ${ZU_VERTRAEGE}</p>`), analyse)
|
||||
const andere = tile(await render([project('a', { eligible: false, reason: 'gekuendigt', reasonText: 'Gekündigt' })]), 'analyse')
|
||||
assert.ok(andere.includes('<p class="fn-count">Gekündigt</p>') && !andere.includes('data-view='), andere)
|
||||
const stage = await renderActivation({ state: undefined, eligible: false, reason: 'kein_abo', reasonText: 'Kein Abo' })
|
||||
assert.ok(stage.innerHTML.includes(`<p>Kein Abo ${ZU_VERTRAEGE}</p>`), stage.innerHTML)
|
||||
const termine = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'kein_abo', hinweis: 'T' } })).html, 'termine')
|
||||
assert.ok(termine.includes(ZU_VERTRAEGE), termine)
|
||||
})
|
||||
|
||||
test('Terminplaner-Kachel: ordered und setup fragen /api/termine nicht nach einer Zahl (nur aktive Websites)', async () => {
|
||||
@@ -347,11 +382,11 @@ test('Terminplaner-Kachel: Zielprojekt = aktiv, dann ordered/setup, dann abgesch
|
||||
})
|
||||
|
||||
test('Terminplaner-Kachel: mehrere Websites ohne Abo ⇒ die Pille bleibt „Aus“, nirgends ein Knopf (auch mit Entwurf)', async () => {
|
||||
const { html } = await renderTermine([withTermine('a', 'off'), withTermine('b', 'off', { siteKey: 'abcdefghijklmnop' })], { verfuegbar: true, abo: { bestellbar: false, grund: 'kein_abo' } })
|
||||
const { html } = await renderTermine([withTermine('a', 'off'), withTermine('b', 'off', { siteKey: 'abcdefghijklmnop' })], { verfuegbar: true, abo: { bestellbar: false, grund: 'kein_abo', hinweis: 'Serverhinweis ohne Abo.' } })
|
||||
const kachel = tile(html, 'termine')
|
||||
assert.match(kachel, /fn-pill-off">Aus</)
|
||||
assert.doesNotMatch(kachel, /fn-tile-cta|data-view="termine"/)
|
||||
assert.match(kachel, KEIN_ABO_HINWEIS)
|
||||
assert.ok(kachel.includes(`<p class="fn-count">Serverhinweis ohne Abo. ${ZU_VERTRAEGE}</p>`), kachel)
|
||||
})
|
||||
|
||||
test('Terminplaner-Kachel: Projektname und Zustand kommen nur maskiert in die Kachel', async () => {
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { test, after, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import { syncBuiltinESMExports } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Router } from 'express'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
|
||||
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, featureLegalAttachments, featureLegalReady, featureLegalPaths, legalRouter, setLegalDirForTests } = await import(
|
||||
'../server/services/legalDocs.js'
|
||||
)
|
||||
const analyse = await import('../server/services/analyseLegal.js')
|
||||
const { featureConfig } = await import('../server/services/featureRegistry.js')
|
||||
|
||||
const V = config.termine.avvVersion
|
||||
const ALT = '2026-10-02' // eingefrorene Altfassung der Gaestetexte und der Analyse-Rechtstexte
|
||||
const root = (rel) => new URL(`../${rel}`, import.meta.url)
|
||||
|
||||
// Router mit eigenem Verzeichnis: die Adressen ohne Fassung liefern die Datei der Fassung aus config
|
||||
const dir = mkdtempSync(join(tmpdir(), 'legaldocs-'))
|
||||
const inhalt = (name, version) => `<p>${name} ${version}</p>`
|
||||
const DATEIEN = ['leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'widerrufsbelehrung-funktionen']
|
||||
for (const slug of DATEIEN) writeFileSync(join(dir, `${slug}-${V}.html`), inhalt(slug, V))
|
||||
writeFileSync(join(dir, `avv-website-analyse-${ALT}.html`), inhalt('avv-website-analyse', ALT))
|
||||
const rechtliches = Router()
|
||||
rechtliches.use(legalRouter({ dir }))
|
||||
const app = await startTestApp({ '/rechtliches': rechtliches })
|
||||
after(() => {
|
||||
app.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const get = async (path) => {
|
||||
const r = await fetch(app.url + path)
|
||||
return { status: r.status, text: await r.text() }
|
||||
}
|
||||
|
||||
/** Konstante und Register fuer einen Test setzen; stellt sie danach wieder her. */
|
||||
async function mitFassung(fassung, fn) {
|
||||
const alt = config.termine.avvVersion
|
||||
config.termine.avvVersion = fassung
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
config.termine.avvVersion = alt
|
||||
}
|
||||
}
|
||||
|
||||
test('legalPaths("termine", V) nennt drei versionierte Adressen', () => {
|
||||
assert.deepEqual(legalPaths('termine', V), {
|
||||
lb: `/rechtliches/leistungsbeschreibung-terminplaner-${V}.html`,
|
||||
avv: `/rechtliches/avv-terminplaner-${V}.html`,
|
||||
widerruf: `/rechtliches/widerrufsbelehrung-funktionen-${V}.html`,
|
||||
})
|
||||
assert.deepEqual(Object.keys(legalPaths('termine', V)), ['lb', 'avv', 'widerruf'])
|
||||
assert.deepEqual(legalPaths('analyse', ALT), {
|
||||
avv: `/rechtliches/avv-website-analyse-${ALT}.html`,
|
||||
lb: `/rechtliches/leistungsbeschreibung-website-analyse-${ALT}.html`,
|
||||
})
|
||||
})
|
||||
|
||||
test('legalPaths: ungültige Fassung und unbekannter Satz werfen', () => {
|
||||
for (const satz of ['termine', 'analyse']) {
|
||||
for (const v of ['../x', '2026-10-5', '2026-10-05x', 'ENTWURF', '', undefined, null, '2026-10-05/../x']) {
|
||||
assert.throws(() => legalPaths(satz, v), /Fassung/, `${satz}: ${String(v)}`)
|
||||
assert.throws(() => legalPdfFiles(satz, v), /Fassung/)
|
||||
}
|
||||
}
|
||||
for (const satz of ['news', 'unbekannt', '__proto__', 'toString', '', undefined]) {
|
||||
assert.throws(() => legalPaths(satz, V), /Satz/, String(satz))
|
||||
assert.throws(() => legalPdfFiles(satz, V), /Satz/)
|
||||
}
|
||||
})
|
||||
|
||||
test('legalPdfFiles: Dateinamen und Pfade je Satz und Fassung', () => {
|
||||
const t = legalPdfFiles('termine', V)
|
||||
assert.deepEqual(Object.keys(t), ['lb', 'avv', 'widerruf'])
|
||||
assert.equal(t.lb.filename, `Leistungsbeschreibung-Terminplaner-${V}.pdf`)
|
||||
assert.equal(t.avv.filename, `AVV-Terminplaner-${V}.pdf`)
|
||||
assert.equal(t.widerruf.filename, `Widerrufsbelehrung-Muster-Widerrufsformular-${V}.pdf`)
|
||||
assert.equal(t.lb.path, new URL(`../public/rechtliches/leistungsbeschreibung-terminplaner-${V}.pdf`, import.meta.url).pathname)
|
||||
assert.equal(t.avv.path, new URL(`../public/rechtliches/avv-terminplaner-${V}.pdf`, import.meta.url).pathname)
|
||||
assert.equal(t.widerruf.path, new URL(`../public/rechtliches/widerrufsbelehrung-funktionen-${V}.pdf`, import.meta.url).pathname)
|
||||
const a = legalPdfFiles('analyse', ALT)
|
||||
assert.deepEqual(Object.keys(a), ['avv', 'lb'])
|
||||
assert.equal(a.avv.filename, `AVV-Website-Analyse-${ALT}.pdf`)
|
||||
assert.equal(a.lb.filename, `Leistungsbeschreibung-Website-Analyse-${ALT}.pdf`)
|
||||
})
|
||||
|
||||
test('legalPdfAttachments: fehlende Datei wirft, Analyse liefert avv und lb, ohne lässt Dokumente weg', async () => {
|
||||
await assert.rejects(legalPdfAttachments('termine', '1999-01-01'))
|
||||
await assert.rejects(legalPdfAttachments('analyse', '1999-01-01'))
|
||||
await assert.rejects(legalPdfAttachments('unbekannt', V), /Satz/)
|
||||
const alle = await legalPdfAttachments('analyse', ALT)
|
||||
assert.deepEqual(alle.map((x) => x.filename), [`AVV-Website-Analyse-${ALT}.pdf`, `Leistungsbeschreibung-Website-Analyse-${ALT}.pdf`])
|
||||
for (const x of alle) {
|
||||
assert.equal(x.contentType, 'application/pdf')
|
||||
assert.equal(x.content.subarray(0, 4).toString(), '%PDF')
|
||||
}
|
||||
const ohneAvv = await legalPdfAttachments('analyse', ALT, { ohne: ['avv'] })
|
||||
assert.deepEqual(ohneAvv.map((x) => x.filename), [`Leistungsbeschreibung-Website-Analyse-${ALT}.pdf`])
|
||||
// eine ausgelassene Datei muss nicht vorhanden sein
|
||||
assert.deepEqual(await legalPdfAttachments('termine', '1999-01-01', { ohne: ['lb', 'avv', 'widerruf'] }), [])
|
||||
await assert.rejects(legalPdfAttachments('analyse', ALT, { ohne: ['gibtsnicht'] }), /Dokument/)
|
||||
})
|
||||
|
||||
test('featureLegalAttachments / featureLegalReady: news ohne Dokumente, unbekannte Funktion nie bereit', async () => {
|
||||
assert.deepEqual(await featureLegalAttachments('news'), [])
|
||||
assert.deepEqual(await featureLegalAttachments('news', { ohne: ['lb'] }), [])
|
||||
assert.equal(await featureLegalReady('news'), true)
|
||||
await assert.rejects(featureLegalAttachments('gibtsnicht'), /Funktion/)
|
||||
await assert.rejects(featureLegalAttachments('analyse'), /Funktion/)
|
||||
await assert.rejects(featureLegalAttachments('__proto__'), /Funktion/)
|
||||
assert.equal(await featureLegalReady('gibtsnicht'), false)
|
||||
assert.equal(await featureLegalReady('analyse'), false)
|
||||
})
|
||||
|
||||
test('featureLegalAttachments("termine") liest die Fassung aus config.termine.avvVersion; fehlt eine Datei, ist die Funktion nicht bereit', async () => {
|
||||
await mitFassung('1999-01-01', async () => {
|
||||
await assert.rejects(featureLegalAttachments('termine'))
|
||||
assert.equal(await featureLegalReady('termine'), false)
|
||||
})
|
||||
})
|
||||
|
||||
// R114 (Task F8): featureLegalReady läuft bei jeder Bestellung (Stufe 5b des Checkouts) und prüft je PDF nur stat (Größe > 0) und die
|
||||
// ersten fünf Bytes "%PDF-", nie den ganzen Inhalt. Die Dateien liegen für den Test in einem Verzeichnis mit Kopien.
|
||||
test('featureLegalReady("termine"): je PDF stat und "%PDF-", nie den ganzen Inhalt lesen; jeder Mangel und jeder Fehler ergibt false, nie einen Wurf', async () => {
|
||||
const kopien = mkdtempSync(join(tmpdir(), 'legal-ready-'))
|
||||
mkdirSync(join(kopien, 'rechtliches'))
|
||||
// Quellen: die ausgelieferten Dateien (vor dem Tausch des Verzeichnisses ermittelt)
|
||||
const echteHtml = legalPaths('termine', V)
|
||||
const echtePdf = Object.fromEntries(Object.entries(legalPdfFiles('termine', V)).map(([doc, f]) => [doc, f.path]))
|
||||
const bereit = () => {
|
||||
for (const adresse of Object.values(echteHtml)) copyFileSync(root(`public${adresse}`), join(kopien, adresse))
|
||||
for (const pfad of Object.values(echtePdf)) copyFileSync(pfad, join(kopien, 'rechtliches', pfad.split('/').at(-1)))
|
||||
}
|
||||
const pdfPfad = (doc) => join(kopien, 'rechtliches', echtePdf[doc].split('/').at(-1))
|
||||
const htmlPfad = (doc) => join(kopien, echteHtml[doc])
|
||||
const lesen = mock.method(fsp, 'readFile')
|
||||
syncBuiltinESMExports()
|
||||
try {
|
||||
setLegalDirForTests(kopien)
|
||||
bereit()
|
||||
assert.equal(await featureLegalReady('termine'), true)
|
||||
assert.equal(lesen.mock.callCount(), 0, 'readFile wird nicht aufgerufen')
|
||||
|
||||
// ein riesiges PDF mit gültigem Kopf (nur Loch im Dateisystem): bereit, ohne es zu lesen
|
||||
const gross = openSync(pdfPfad('lb'), 'w')
|
||||
writeSync(gross, '%PDF-1.4\n')
|
||||
closeSync(gross)
|
||||
await fsp.truncate(pdfPfad('lb'), 512 * 1024 * 1024)
|
||||
assert.equal(await featureLegalReady('termine'), true)
|
||||
assert.equal(lesen.mock.callCount(), 0, 'auch ein 512-MB-PDF wird nicht gelesen')
|
||||
bereit()
|
||||
|
||||
// Mängel je Datei: fehlend, leer, falscher Kopf, zu kurz, Verzeichnis statt Datei
|
||||
for (const doc of ['lb', 'avv', 'widerruf']) {
|
||||
const pfad = pdfPfad(doc)
|
||||
rmSync(pfad)
|
||||
assert.equal(await featureLegalReady('termine'), false, `${doc}: PDF fehlt`)
|
||||
writeFileSync(pfad, '')
|
||||
assert.equal(await featureLegalReady('termine'), false, `${doc}: PDF leer`)
|
||||
writeFileSync(pfad, '<html>kein PDF</html>')
|
||||
assert.equal(await featureLegalReady('termine'), false, `${doc}: kein %PDF-`)
|
||||
writeFileSync(pfad, '%PDF')
|
||||
assert.equal(await featureLegalReady('termine'), false, `${doc}: zu kurz`)
|
||||
rmSync(pfad)
|
||||
mkdirSync(pfad)
|
||||
assert.equal(await featureLegalReady('termine'), false, `${doc}: Verzeichnis statt Datei`)
|
||||
rmSync(pfad, { recursive: true })
|
||||
bereit()
|
||||
assert.equal(await featureLegalReady('termine'), true, `${doc}: wieder bereit`)
|
||||
// die HTML-Seite fehlt
|
||||
rmSync(htmlPfad(doc))
|
||||
assert.equal(await featureLegalReady('termine'), false, `${doc}: HTML fehlt`)
|
||||
bereit()
|
||||
}
|
||||
assert.equal(lesen.mock.callCount(), 0, 'nie readFile')
|
||||
// ohne Verzeichnis, mit unbekannter Funktion und mit unbekannter Fassung: false statt Wurf
|
||||
await mitFassung('1999-01-01', async () => assert.equal(await featureLegalReady('termine'), false))
|
||||
assert.equal(await featureLegalReady('gibtsnicht'), false)
|
||||
setLegalDirForTests(join(kopien, 'gibtsnicht'))
|
||||
assert.equal(await featureLegalReady('termine'), false)
|
||||
// die Anhänge der Mail lesen dagegen die ganzen Dateien (legalPdfAttachments)
|
||||
setLegalDirForTests(kopien)
|
||||
bereit()
|
||||
assert.equal((await featureLegalAttachments('termine')).length, 3)
|
||||
assert.ok(lesen.mock.callCount() >= 3)
|
||||
} finally {
|
||||
setLegalDirForTests(null)
|
||||
mock.restoreAll()
|
||||
syncBuiltinESMExports()
|
||||
rmSync(kopien, { recursive: true, force: true })
|
||||
}
|
||||
assert.equal(await featureLegalReady('termine'), true, 'mit den ausgelieferten Dateien bereit')
|
||||
})
|
||||
|
||||
// Fassung wählbar (Task F8, Fix-Runde 1): K15 schickt die Unterlagen der Fassung, der der Kunde zugestimmt hat
|
||||
test('featureLegalAttachments / featureLegalReady / featureLegalPaths mit opts.fassung', async () => {
|
||||
assert.deepEqual(featureLegalPaths('termine'), legalPaths('termine', V))
|
||||
assert.deepEqual(featureLegalPaths('termine', { fassung: '2026-10-01' }), legalPaths('termine', '2026-10-01'))
|
||||
assert.equal(featureLegalPaths('news'), null)
|
||||
assert.throws(() => featureLegalPaths('gibtsnicht'), /Funktion/)
|
||||
assert.throws(() => featureLegalPaths('termine', { fassung: '2026-10-5' }), /Fassung/)
|
||||
// ausdrücklich die aktuelle Fassung = ohne Angabe
|
||||
assert.deepEqual((await featureLegalAttachments('termine', { fassung: V })).map((a) => a.filename), (await featureLegalAttachments('termine')).map((a) => a.filename))
|
||||
assert.equal(await featureLegalReady('termine', { fassung: V }), true)
|
||||
// andere Fassung ohne Dateien: Wurf bei den Anhängen, false bei der Prüfung, news weiter []
|
||||
await assert.rejects(featureLegalAttachments('termine', { fassung: '1999-01-01' }))
|
||||
assert.equal(await featureLegalReady('termine', { fassung: '1999-01-01' }), false)
|
||||
assert.equal(await featureLegalReady('termine', { fassung: 'kaputt' }), false)
|
||||
assert.deepEqual(await featureLegalAttachments('news', { fassung: '1999-01-01' }), [])
|
||||
// opts.ohne wirkt weiter neben fassung
|
||||
assert.deepEqual((await featureLegalAttachments('termine', { fassung: V, ohne: ['avv'] })).map((a) => a.filename.split('-')[0]), ['Leistungsbeschreibung', 'Widerrufsbelehrung'])
|
||||
})
|
||||
|
||||
test('config.termine.avvVersion: Datumsmuster, nach der Altfassung der Gästetexte, echter Tag', () => {
|
||||
assert.match(config.termine.avvVersion, /^\d{4}-\d{2}-\d{2}$/)
|
||||
assert.ok(config.termine.avvVersion > ALT, `${config.termine.avvVersion} liegt nicht nach ${ALT}`)
|
||||
assert.ok(config.termine.avvVersion >= '2026-10-03')
|
||||
assert.ok(!Number.isNaN(Date.parse(`${config.termine.avvVersion}T00:00:00Z`)))
|
||||
assert.equal(new Date(`${config.termine.avvVersion}T00:00:00Z`).toISOString().slice(0, 10), config.termine.avvVersion)
|
||||
})
|
||||
|
||||
test('config.ustHinweis bleibt wörtlich', () => {
|
||||
assert.equal(config.ustHinweis, 'ohne Umsatzsteuer gemäß § 19 UStG')
|
||||
})
|
||||
|
||||
test('Register: leistungsbeschreibung und avv des Terminplaners folgen der Konstante (Getter, Drift-Wächter)', async () => {
|
||||
const t = featureConfig('termine')
|
||||
assert.deepEqual(t.leistungsbeschreibung, { version: V, url: legalPaths('termine', V).lb })
|
||||
assert.deepEqual(t.avv, { version: V, url: legalPaths('termine', V).avv })
|
||||
assert.doesNotMatch(JSON.stringify([t.leistungsbeschreibung, t.avv]), /ENTWURF/)
|
||||
// zur Laufzeit geaendert: der Getter folgt
|
||||
await mitFassung('2099-01-02', async () => {
|
||||
assert.deepEqual(t.leistungsbeschreibung, { version: '2099-01-02', url: legalPaths('termine', '2099-01-02').lb })
|
||||
assert.deepEqual(t.avv, { version: '2099-01-02', url: legalPaths('termine', '2099-01-02').avv })
|
||||
})
|
||||
assert.deepEqual(t.leistungsbeschreibung, { version: V, url: legalPaths('termine', V).lb })
|
||||
// ein Test darf den Eintrag ueberschreiben (siehe termineFragebogen.test.js)
|
||||
const merke = t.leistungsbeschreibung
|
||||
t.leistungsbeschreibung = { version: 'T1', url: '/x' }
|
||||
assert.deepEqual(t.leistungsbeschreibung, { version: 'T1', url: '/x' })
|
||||
t.leistungsbeschreibung = merke
|
||||
assert.deepEqual(t.leistungsbeschreibung, merke)
|
||||
})
|
||||
|
||||
test('News: Leistungsbeschreibung folgt der Fassung <F> (Adresse unverändert, kein AVV, kein Getter am Eintrag)', () => {
|
||||
const n = featureConfig('news')
|
||||
assert.deepEqual(n.leistungsbeschreibung, { version: config.termine.avvVersion, url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' })
|
||||
assert.equal(n.avv, null)
|
||||
assert.deepEqual(analyse.legalPaths(ALT), legalPaths('analyse', ALT))
|
||||
assert.equal(Object.getOwnPropertyDescriptor(n, 'leistungsbeschreibung').get, undefined)
|
||||
})
|
||||
|
||||
test('config.js importiert legalDocs.js nicht (Adresse aus Slug und Version gebaut)', () => {
|
||||
const src = readFileSync(root('server/config.js'), 'utf8')
|
||||
assert.doesNotMatch(src, /^\s*(import\b[^\n]*|.*\bimport\()['"`][^'"`]*legalDocs/m)
|
||||
assert.doesNotMatch(src, /^import .* from '\.\/services\//m)
|
||||
})
|
||||
|
||||
test('analyseLegal.js ist ein dünner Wrapper um die Registry', async () => {
|
||||
assert.deepEqual(analyse.legalPaths(ALT), legalPaths('analyse', ALT))
|
||||
assert.deepEqual(analyse.legalPdfFiles(ALT), legalPdfFiles('analyse', ALT))
|
||||
assert.deepEqual(await analyse.legalPdfAttachments(ALT), await legalPdfAttachments('analyse', ALT))
|
||||
assert.throws(() => analyse.legalPaths('../x'), /Fassung/)
|
||||
await assert.rejects(analyse.legalPdfAttachments('1999-01-01'))
|
||||
assert.equal(typeof analyse.analyseLegalRouter, 'function')
|
||||
})
|
||||
|
||||
test('Router: Adressen ohne Fassung liefern die Fassung aus config (Terminplaner)', async () => {
|
||||
for (const slug of DATEIEN) {
|
||||
const r = await get(`/rechtliches/${slug}.html`)
|
||||
assert.equal(r.status, 200, slug)
|
||||
assert.equal(r.text, inhalt(slug, V))
|
||||
}
|
||||
// die Fassung folgt der Konstante zur Laufzeit: fuer eine andere Fassung gibt es keine Datei => 404
|
||||
await mitFassung('2099-01-02', async () => {
|
||||
assert.equal((await get('/rechtliches/avv-terminplaner.html')).status, 404)
|
||||
})
|
||||
assert.equal((await get('/rechtliches/avv-terminplaner.html')).status, 200)
|
||||
})
|
||||
|
||||
test('Router: Adressen ohne Fassung der Analyse liefern config.analyse.avvVersion', async () => {
|
||||
assert.equal(config.analyse.avvVersion, ALT)
|
||||
const r = await get('/rechtliches/avv-website-analyse.html')
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.text, inhalt('avv-website-analyse', ALT))
|
||||
// die zweite Analyse-Datei fehlt im Testverzeichnis: 404, kein 500
|
||||
assert.equal((await get('/rechtliches/leistungsbeschreibung-website-analyse.html')).status, 404)
|
||||
})
|
||||
|
||||
test('Router: nur die festen Adressen, versionierte und fremde Pfade fallen durch', async () => {
|
||||
for (const p of [
|
||||
`/rechtliches/avv-terminplaner-${V}.html`, // versionierte Adressen liefert express.static in index.js
|
||||
'/rechtliches/avv-terminplaner.pdf',
|
||||
'/rechtliches/../config.js',
|
||||
'/rechtliches/gibtsnicht.html',
|
||||
'/rechtliches/',
|
||||
]) {
|
||||
assert.equal((await get(p)).status, 404, p)
|
||||
}
|
||||
const post = await fetch(`${app.url}/rechtliches/avv-terminplaner.html`, { method: 'POST' })
|
||||
assert.equal(post.status, 404)
|
||||
})
|
||||
|
||||
test('Router: legalRouter() ohne Verzeichnis liefert die Analyse-Adressen aus public/rechtliches', async () => {
|
||||
const echt = Router()
|
||||
echt.use(legalRouter())
|
||||
const a = await startTestApp({ '/rechtliches': echt })
|
||||
try {
|
||||
const r = await fetch(`${a.url}/rechtliches/avv-website-analyse.html`)
|
||||
assert.equal(r.status, 200)
|
||||
assert.deepEqual(Buffer.from(await r.arrayBuffer()), readFileSync(root(`public/rechtliches/avv-website-analyse-${ALT}.html`)))
|
||||
} finally {
|
||||
a.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('server/index.js hängt den gemeinsamen Router vor express.static ein', () => {
|
||||
const index = readFileSync(root('server/index.js'), 'utf8')
|
||||
assert.match(index, /import \{ legalRouter \} from '\.\/services\/legalDocs\.js'/)
|
||||
assert.ok(index.includes("app.use('/rechtliches', legalRouter())"))
|
||||
assert.ok(!index.includes('analyseLegalRouter'))
|
||||
assert.ok(index.indexOf("app.use('/rechtliches', legalRouter())") < index.indexOf('app.use(express.static(publicDir))'))
|
||||
})
|
||||
+116
-4
@@ -382,7 +382,7 @@ test('Rechtsrunde: Zusatzspeicher ganz oder teilweise kündbar, auch über „Ve
|
||||
|
||||
test('Rechtsrunde Datenschutz: Aufbewahrung nach dem Ende des letzten Abos, Zahl der Pakete, kurzer UptimeRobot-Eintrag', () => {
|
||||
const html = read(DS), ds = strip(html)
|
||||
assert.equal(ds.split('drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet').length - 1, 2, 'Abschnitt 7 und Kündigungs-/Widerrufsseite')
|
||||
assert.equal(ds.split('drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet').length - 1, 3, 'Abschnitt 7 (Bestellungen, Funktionsbestellung) und Kündigungs-/Widerrufsseite')
|
||||
for (const s of ['drei Jahre nach Vertragsende', 'nach dem Ende Ihres letzten Abos']) assert.ok(!ds.includes(s), `noch da: ${s}`)
|
||||
// Abschnitt 6 (Kundenkonto) verweist für Abos und Bestellungen auf die Fristen in Abschnitt 7
|
||||
assert.ok(between(ds, '6. Kundenportal', '7. Abos').includes('Für Ihre Abos und Bestellungen gelten die Fristen in Abschnitt 7.'))
|
||||
@@ -482,7 +482,8 @@ test('legal_datenschutz_news: Abschnitt 6a und Leistungsbeschreibung (Entwurf)',
|
||||
const text = strip(lb)
|
||||
assert.ok(text.includes('Auf Ihren Wunsch schalten wir die Pinnwand ab') && !text.includes('Endet die Zusammenarbeit mit uns, schalten wir'))
|
||||
assert.ok(text.includes('abgeschlossen') && !text.includes('dauert erfahrungsgemäß'))
|
||||
for (const s of ['30,00 €', '§ 356 Abs. 4 BGB', '1–2 Wochen', 'keine laufenden Kosten']) assert.ok(text.includes(s), s)
|
||||
for (const s of ['30,00 €', '§ 356 Abs. 5 Nr. 2 BGB', '§ 357a Abs. 2 BGB', '1–2 Wochen', 'keine laufenden Kosten']) assert.ok(text.includes(s), s)
|
||||
assert.ok(!text.includes('§ 356 Abs. 4') && !/inklusive Umsatzsteuer/.test(text), 'altes Zitat und alter Preistext sind weg')
|
||||
|
||||
const muster = read('docs/funktionen/website-datenschutz-absatz.md')
|
||||
// 14 Tage Protokolle nur, solange Abschnitt 3 der WEBklar-Erklaerung dieselbe Frist nennt
|
||||
@@ -494,9 +495,120 @@ test('legal_datenschutz_news: Abschnitt 6a und Leistungsbeschreibung (Entwurf)',
|
||||
test('legal_datenschutz_kundenansicht: Abschnitt 6 nennt Kundenansicht, Sperren, Protokoll und 12 Monate', () => {
|
||||
const html = read(DS)
|
||||
assert.ok(!html.includes('Eine Nachricht in Ihrem Namen senden können sie dabei nicht.'))
|
||||
assert.ok(html.includes('Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie News-Beiträge anlegen, ändern oder löschen.'))
|
||||
assert.ok(html.includes('Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie News-Beiträge anlegen, ändern oder löschen und, wenn Sie den Terminplaner nutzen, dessen Einstellungen und Termine ansehen und ändern.'))
|
||||
assert.ok(!html.includes('News-Beiträge anlegen, ändern oder löschen. Bestellungen'), 'der alte Satz endet nicht mehr nach den News')
|
||||
assert.ok(html.includes('Bestellungen, Zahlungen, Änderungen an Ihrem Passwort sowie Nachrichten oder Anträge in Ihrem Namen sind dabei ausgeschlossen.'))
|
||||
assert.ok(html.includes('Jedes Öffnen und jede Änderung halten wir mit Mitarbeiter und Zeitpunkt fest und löschen diese Einträge nach 12 Monaten.'))
|
||||
assert.ok(html.includes('Rechtsgrundlage dafür ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist die Nachvollziehbarkeit von Zugriffen auf Ihr Konto.'))
|
||||
assert.ok(html.includes('Stand 01.10.2026'))
|
||||
// Stand: Tag der Änderung (TP2 F12a), nicht vor der Kundenansicht (01.10.2026) und nicht vor dem Datum aus termsVersion
|
||||
const stand = /Stand (\d{2})\.(\d{2})\.(2026)/.exec(html)
|
||||
assert.ok(stand, 'Stand JJ.MM.2026 fehlt')
|
||||
const standTag = `${stand[3]}-${stand[2]}-${stand[1]}`
|
||||
assert.ok(standTag >= '2026-10-01', `Stand ${stand[0]} liegt vor der Kundenansicht (01.10.2026)`)
|
||||
const terms = /^(\d{4})-(\d{2})-(\d{2})-v\d+$/.exec(config.abo.termsVersion)
|
||||
assert.ok(terms)
|
||||
assert.ok(standTag >= `${terms[1]}-${terms[2]}-${terms[3]}`, `Stand ${stand[0]} liegt vor termsVersion ${config.abo.termsVersion}`)
|
||||
assert.ok(html.includes('<p class="meta">Stand 05.10.2026</p>'), 'Stand = Tag der Änderung')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Abschnitt 6c und Funktionsbestellung (TP2 F12a)
|
||||
|
||||
/** Der Block von <h2 id="termine"> bis vor <h2>7. in public/datenschutz.html (roh, mit Tags). */
|
||||
const block6c = (html = read(DS)) => {
|
||||
const a = html.indexOf('<h2 id="termine">')
|
||||
assert.ok(a >= 0, '<h2 id="termine"> fehlt')
|
||||
const b = html.indexOf('<h2>7. ', a)
|
||||
assert.ok(b > a)
|
||||
return html.slice(a, b)
|
||||
}
|
||||
|
||||
test('legal_datenschutz_termine: Abschnitt 6c steht zwischen 6b und 7 und nennt Rolle, Orte, Fristen und Rückkehr', () => {
|
||||
const html = read(DS)
|
||||
const analyse = html.indexOf('id="analyse"')
|
||||
const termine = html.indexOf('id="termine"')
|
||||
const sieben = html.indexOf('<h2>7. ')
|
||||
assert.ok(analyse > 0 && termine > analyse && sieben > termine, 'Reihenfolge 6b < 6c < 7')
|
||||
assert.equal(html.split('id="termine"').length - 1, 1)
|
||||
assert.ok(html.includes('<h2 id="termine">6c. Online-Terminbuchung auf Websites unserer Kunden</h2>'))
|
||||
const roh = block6c(html)
|
||||
assert.ok(!roh.includes('<!--'), 'kein Kommentar im Block')
|
||||
const text = strip(roh)
|
||||
for (const s of ['Auftragsverarbeiter', 'Falkenstein', 'Nürnberg', '48 Stunden', '30 Tage', '90 Tage', 'sieben Tage', 'nicht dauerhaft']) assert.ok(text.includes(s), `6c: ${s}`)
|
||||
// Rolle: Auftragsverarbeiter für Gäste, Verantwortliche für Betriebsdaten, Fragebogen-Entwurf und Bestellung
|
||||
assert.ok(text.includes('ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden'))
|
||||
assert.ok(text.includes('Verantwortliche sind wir für die Betriebsdaten des Kunden im Kundenportal, für den Entwurf seines Fragebogens und für seine Bestellung'))
|
||||
// Entwurf, E-Mail über Hetzner (Nürnberg, Abschnitt 8), Aufruf je Modus, keine IP in der Buchung
|
||||
assert.ok(text.includes('Nicht bestellte Entwürfe löschen wir 90 Tage nach der letzten Änderung'))
|
||||
assert.ok(text.includes('(Rechenzentrum Nürnberg, Abschnitt 8)'))
|
||||
assert.ok(text.includes('mit der Buchung speichern wir die IP-Adresse nicht'))
|
||||
assert.ok(text.includes('schon beim Laden der Seite') && text.includes('Konfiguration mit den Texten und Leistungen'))
|
||||
// Ende des Abos (Wortlaut der Vorgabe)
|
||||
assert.ok(text.includes('Endet das Wartungs-Abo oder schalten wir die Funktion ab, schalten wir den Terminplaner spätestens am folgenden Tag ab und löschen alle Termindaten und Einstellungen 30 Tage danach; beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schalten wir ihn mit den vorhandenen Daten wieder ein.'))
|
||||
assert.ok(text.includes('bleiben gelöschte Daten noch bis zu sieben Tage erhalten'))
|
||||
// die Verbote der Nachbartests gelten auch hier
|
||||
for (const verboten of ['Abschnitt 6 und 7', 'spätestens nach 24 Stunden', 'Endet die Zusammenarbeit, schalten wir', 'Unterauftragsverarbeiter', 'im Rahmen des Wartungs-Abos']) assert.ok(!text.includes(verboten), `noch da: ${verboten}`)
|
||||
// Nummerierung 7 bis 15 unverändert
|
||||
const titel = [...html.matchAll(/<h2>(\d+)\. ([^<]*)<\/h2>/g)].map((m) => `${m[1]}. ${m[2]}`)
|
||||
assert.deepEqual(titel, [
|
||||
'1. Verantwortlicher', '2. Geltungsbereich', '3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit', '4. Cookies und Speicher im Browser', '5. Kontaktaufnahme', '6. Kundenportal',
|
||||
'7. Abos und Zahlung über Stripe', '8. E-Mail', '9. Buchhaltung und Rechnungen', '10. Domains unserer Kunden', '11. Interessenten', '12. Empfänger im Überblick', '13. Ihre Rechte',
|
||||
'14. Pflicht zur Bereitstellung, keine automatisierten Entscheidungen', '15. Änderungen',
|
||||
])
|
||||
})
|
||||
|
||||
test('datenschutz_6c_gleich_markdown: der Block in public/datenschutz.html ist die Markdown-Quelle, die Quelle ist ohne Entwurfs- und Prüflisten-Reste', () => {
|
||||
const md = read('docs/rechtliches/termine-datenschutz-6c.md').replace(/\r/g, '')
|
||||
const fence = /```html\n([\s\S]*?)\n```/.exec(md)
|
||||
assert.ok(fence, 'Markdown-Quelle ohne html-Block')
|
||||
const zeilen = (t) => t.split('\n').map((z) => z.trim()).filter((z) => z !== '')
|
||||
assert.deepEqual(zeilen(block6c()), zeilen(fence[1]), 'HTML-Block und Markdown-Quelle weichen ab')
|
||||
assert.equal(md.split('```').length - 1, 2, 'genau ein Codeblock')
|
||||
for (const rest of ['Einbau in TP2', 'nicht eingebaut', 'nicht veröffentlicht', 'Für die Rechtsprüfung offen', 'Entwurf für', 'anwaltlich prüfen lassen', '<!--']) assert.ok(!md.includes(rest), `noch da: ${rest}`)
|
||||
assert.ok(md.startsWith('# Datenschutzerklärung von WEBklar: Abschnitt 6c'))
|
||||
assert.ok(md.includes('Auftragsverarbeiter') && md.includes('6c. Online-Terminbuchung'))
|
||||
})
|
||||
|
||||
test('legal_datenschutz_funktionsbestellung: Abschnitt 7 nennt den Nachweis der Funktionsbestellung, die Rechtsgrundlagen, die Löschfrist und die Entwurfsdaten', () => {
|
||||
const abos = between(strip(read(DS)), '7. Abos und Zahlung über Stripe', '8. E-Mail')
|
||||
const absatz = 'Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner), speichern wir als Nachweis die Texte und Fassungen, denen Sie zugestimmt haben, den Zeitpunkt Ihres Klicks auf „Zahlungspflichtig bestellen“, die Stripe-Sitzung, IP-Adresse und Browserkennung sowie Ihr Konto und die angemeldete Person. '
|
||||
+ 'Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO; unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und der Zustimmungen, insbesondere für den Widerruf. '
|
||||
+ 'Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet. '
|
||||
+ 'Den Entwurf Ihres Fragebogens speichern wir bis zu 90 Tage nach Ihrer letzten Änderung und löschen ihn danach, wenn Sie nichts bestellt haben.'
|
||||
assert.ok(abos.includes(absatz), 'Absatz zur Funktionsbestellung (wörtlich) fehlt')
|
||||
assert.equal(abos.split('Bestellen Sie eine Funktion').length - 1, 1)
|
||||
// der Absatz steht im HTML als eigener Absatz von Abschnitt 7
|
||||
assert.ok(read(DS).includes(`<p>${absatz}</p>`))
|
||||
// 6c nennt die Entwurfsdaten und die Frist ebenfalls (Spec 3.7)
|
||||
assert.ok(strip(block6c()).includes('Entwurf seines Fragebogens'))
|
||||
})
|
||||
|
||||
test('wiederkehr_satz_gleich_lb: Rückkehr des Abos innerhalb von 30 Tagen steht in 6c, Leistungsbeschreibung, AVV und K22 mit „30 Tage“ und „wieder ein“', async () => {
|
||||
const { readdirSync } = await import('node:fs')
|
||||
const k22 = readdirSync(new URL('../server/mail-templates/', import.meta.url)).filter((f) => /^K22 .*\.html$/.test(f))
|
||||
assert.equal(k22.length, 1, 'genau eine Vorlage K22')
|
||||
const quellen = {
|
||||
'6c': block6c(),
|
||||
Leistungsbeschreibung: read('docs/rechtliches/leistungsbeschreibung-terminplaner.md'),
|
||||
AVV: read('docs/rechtliches/avv-terminplaner.md'),
|
||||
K22: read(`server/mail-templates/${k22[0]}`),
|
||||
}
|
||||
for (const [name, quelle] of Object.entries(quellen)) {
|
||||
// Absatz = Markdown-Absatz, <p> oder Tabellenzelle der Mail
|
||||
const absaetze = quelle.replace(/\r/g, '').split(/\n\s*\n|<\/p>|<\/td>/).map(strip)
|
||||
const treffer = absaetze.filter((a) => a.includes('wieder ein') && a.includes('Wartungs-Abo'))
|
||||
assert.equal(treffer.length, 1, `${name}: genau ein Absatz zur Rückkehr`)
|
||||
assert.ok(treffer[0].includes('30 Tage'), `${name}: Rückkehr ohne 30 Tage`)
|
||||
// die Rückkehr steht im Zusammenhang mit der Löschung nach 30 Tagen (nicht nur irgendwo im Absatz)
|
||||
const i = treffer[0].indexOf('wieder ein')
|
||||
assert.ok(treffer[0].lastIndexOf('30 Tage', i) >= 0, `${name}: die 30 Tage stehen vor der Rückkehr`)
|
||||
}
|
||||
})
|
||||
|
||||
test('datenschutz_pdf: das PDF der Datenschutzerklärung ist eingecheckt (> 20 KB, %PDF) und das Skript baut es im Modus datenschutz', () => {
|
||||
const pdf = readFileSync(file('docs/WEBklar-Datenschutzerklaerung.pdf'))
|
||||
assert.ok(pdf.length > 20 * 1024, `PDF zu klein: ${pdf.length} Bytes`)
|
||||
assert.equal(pdf.subarray(0, 5).toString('latin1'), '%PDF-')
|
||||
assert.ok(pdf.subarray(-2048).toString('latin1').includes('%%EOF'), 'PDF vollständig geschrieben')
|
||||
const skript = read('scripts/build-legal-pdfs.sh')
|
||||
assert.match(skript, /datenschutz\)\n\s*pdf public\/datenschutz\.html docs\/WEBklar-Datenschutzerklaerung\.pdf\n\s*;;/)
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
|
||||
|
||||
const DIR = new URL('../server/mail-templates/', import.meta.url)
|
||||
// G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb;
|
||||
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); K24–K27 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b)
|
||||
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); K24–K27 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
|
||||
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
|
||||
const EXPECTED = [...ids('G', 8), ...ids('K', 27), ...ids('T', 19), 'T20']
|
||||
const GUEST = ids('G', 8)
|
||||
@@ -74,8 +74,9 @@ test('T20: Betreff [Terminplaner], Platzhalter vorfall/details/zeit, keine Blöc
|
||||
test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text', () => {
|
||||
const k15 = loadTemplate('K15')
|
||||
assert.equal(k15.subject, 'Deine Bestellung: {{funktion}}')
|
||||
assert.deepEqual(k15.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_gen', 'funktion_akk', 'funktion_nom', 'funktion_pron', 'preis', 'preis_hinweis', 'zeit', 'danach', 'portal_url', 'rechnung_url'])
|
||||
assert.deepEqual(k15.blocks, ['rechnung', 'behalten', 'neu'])
|
||||
// F8: fassung, lb_url, avv_url, widerruf_url, zustimmungen_text und die Blöcke anlagen (mit anhang) und zustimmungen für Funktionen mit Rechtsdokumenten
|
||||
assert.deepEqual(k15.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_gen', 'funktion_akk', 'funktion_nom', 'funktion_pron', 'preis', 'preis_hinweis', 'zeit', 'danach', 'portal_url', 'rechnung_url', 'fassung', 'lb_url', 'avv_url', 'widerruf_url', 'zustimmungen_text'])
|
||||
assert.deepEqual(k15.blocks, ['rechnung', 'behalten', 'neu', 'anlagen', 'anhang', 'zustimmungen'])
|
||||
const k16 = loadTemplate('K16')
|
||||
assert.equal(k16.subject, '{{funktion_dein}} ist bereit')
|
||||
assert.deepEqual(k16.placeholders, ['kunde_name', 'projekt', 'funktion', 'funktion_dein', 'funktion_nom', 'bereit', 'knopf', 'portal_url'])
|
||||
@@ -89,6 +90,21 @@ test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text
|
||||
assert.ok(mail.text.includes(part), `K15 enthält: ${part}\n---\n${mail.text}`)
|
||||
}
|
||||
assert.doesNotMatch(renderMail('K15', sampleValues(k15), { rechnung: false }).text, /Rechnung ansehen/)
|
||||
// Vertragsunterlagen (F8): anlagen mit den drei versionierten Adressen, der PDF-Satz nur mit anhang, die Zustimmungen mehrzeilig
|
||||
const werte = { ...sampleValues(k15), fassung: '2026-10-05', lb_url: 'https://p.example/rechtliches/lb-2026-10-05.html', avv_url: 'https://p.example/rechtliches/avv-2026-10-05.html', widerruf_url: 'https://p.example/rechtliches/w-2026-10-05.html', zustimmungen_text: 'Du hast zugestimmt:\n– Erstens\n– Zweitens' }
|
||||
const aus = renderMail('K15', werte, { rechnung: false, anlagen: false, anhang: false, zustimmungen: false })
|
||||
assert.doesNotMatch(aus.text, /Vertragsunterlagen|Zustimmungen|Fassung 2026|hängen als PDF/)
|
||||
const ohneAnhang = renderMail('K15', werte, { anlagen: true, anhang: false, zustimmungen: false })
|
||||
assert.deepEqual(ohneAnhang.problems, [])
|
||||
for (const part of ['Deine Vertragsunterlagen', 'Fassung 2026-10-05: Leistungsbeschreibung (https://p.example/rechtliches/lb-2026-10-05.html)', 'Auftragsverarbeitungsvertrag (https://p.example/rechtliches/avv-2026-10-05.html)', 'Widerrufsbelehrung mit Muster-Widerrufsformular (https://p.example/rechtliches/w-2026-10-05.html)']) {
|
||||
assert.ok(ohneAnhang.text.includes(part), `K15 anlagen enthält: ${part}\n---\n${ohneAnhang.text}`)
|
||||
}
|
||||
assert.doesNotMatch(ohneAnhang.text, /hängen als PDF|Deine Zustimmungen/)
|
||||
const mitAnhang = renderMail('K15', werte, { anlagen: true, anhang: true, zustimmungen: true })
|
||||
assert.match(mitAnhang.text, /Alle drei hängen als PDF an dieser E-Mail\. Bitte bewahre sie auf\./)
|
||||
assert.ok(mitAnhang.text.includes('Deine Zustimmungen\nDu hast zugestimmt:\n– Erstens\n– Zweitens'), mitAnhang.text)
|
||||
// anhang steht nur innerhalb von anlagen
|
||||
assert.doesNotMatch(renderMail('K15', werte, { anlagen: false, anhang: true }).text, /hängen als PDF/)
|
||||
const bereit = renderMail('K16', sampleValues(k16), {})
|
||||
assert.deepEqual(bereit.problems, [])
|
||||
assert.equal(bereit.subject, 'Wert-funktion_dein ist bereit')
|
||||
@@ -101,7 +117,7 @@ test('K15, K16: Platzhalter und Blöcke je Funktion, keine Funktion fest im Text
|
||||
test('T16, T19: Betreff, Platzhalter und Blöcke stehen in der Vorlage', () => {
|
||||
const t16 = loadTemplate('T16')
|
||||
assert.equal(t16.subject, 'Neue Funktion bestellt: {{funktion}}')
|
||||
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team'])
|
||||
assert.deepEqual(t16.blocks, ['zahlung_fehlgeschlagen', 'ungeklaert', 'kein_abo', 'bereits_aktiv', 'anleitung', 'gesundheit', 'abo', 'team', 'anhang_fehlt'])
|
||||
const t19 = loadTemplate('T19')
|
||||
assert.equal(t19.file, 'T19 Erstattung faellig.html')
|
||||
assert.match(t19.subject, /^\[Abo\] Erstattung fällig: \{\{funktion\}\}-Einrichtung$/)
|
||||
@@ -239,7 +255,7 @@ test('optionale Blöcke: nur eingeschaltete erscheinen', () => {
|
||||
})
|
||||
|
||||
test('verschachtelte Blöcke (T09) werden aufgelöst', () => {
|
||||
const values = { email: 'a@example.com', name: 'A', kundennummer: '', stripe_abo_id: 'sub_1', abo_beginn: '01.10.2026', fristende: '15.10.2026', rechtzeitig: 'RECHTZEITIG' }
|
||||
const values = { email: 'a@example.com', name: 'A', kundennummer: '', produkt: 'Terminplaner', preis: '149 €', stripe_abo_id: 'sub_1', abo_beginn: '01.10.2026', fristende: '15.10.2026', rechtzeitig: 'RECHTZEITIG' }
|
||||
const ok = renderMail('T09', values, { abo_gefunden: true, auf_kuendigung_gesetzt: true })
|
||||
assert.match(ok.html, /sub_1 <span[^>]*>\(auf Kündigung gesetzt\)<\/span>/)
|
||||
assert.doesNotMatch(ok.html, /KEIN aktives Abo|NICHT automatisch/)
|
||||
@@ -580,6 +596,40 @@ test('K04: Folgen des Widerrufs je Bestellung (Abschluss, Wechsel, Zusatzspeiche
|
||||
assert.match(renderMail('K04', values, { pruefen: true }).text, /prüfen, ob für diese Bestellung eine Widerrufsfrist läuft/)
|
||||
})
|
||||
|
||||
// F9: Widerruf je Funktion - T09 kennt preis, die Blöcke bleiben unverändert; K04/T09 nennen Produkt und Folgen der Funktion
|
||||
test('F9: T09 deklariert preis, Blöcke von K04 und T09 unverändert', () => {
|
||||
assert.ok(loadTemplate('T09').placeholders.includes('preis'))
|
||||
assert.ok(loadTemplate('T09').placeholders.includes('produkt'))
|
||||
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen'])
|
||||
assert.deepEqual(loadTemplate('T09').blocks, [
|
||||
'kundennummer', 'abo_gefunden', 'auf_kuendigung_gesetzt', 'kein_abo_gefunden', 'kuendigung_fehlgeschlagen', 'abschluss', 'wechsel',
|
||||
'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt', 'wechsel_offen', 'abschluss_verspaetet', 'nicht_pruefbar', 'bereits_widerrufen',
|
||||
])
|
||||
})
|
||||
|
||||
test('F9: K04 und T09 beim Widerruf einer Funktion (begonnene Einrichtung, Wertersatz, Preis, Handgriffe)', () => {
|
||||
const k04 = { name: 'K', produkt: 'Terminplaner', bestellung: 'Terminplaner (Einrichtung)', eingang_zeitpunkt: '01.10.2026, 10:00', email: 'k@example.invalid', kundennummer: '' }
|
||||
const kunde = renderMail('K04', k04, { funktion: true })
|
||||
assert.deepEqual(kunde.problems, [])
|
||||
assert.equal(kunde.subject, 'Eingangsbestätigung Ihres Widerrufs – WEBklar Terminplaner')
|
||||
assert.match(kunde.text, /Widerruf der Bestellung „Terminplaner \(Einrichtung\)“/)
|
||||
assert.match(kunde.text, /Einrichtung auf Ihren ausdrücklichen Wunsch schon vor Ablauf der Widerrufsfrist begonnen/)
|
||||
assert.match(kunde.text, /Wertersatz[^.]*\(§ 357a Abs\. 2 BGB\)/)
|
||||
assert.doesNotMatch(kunde.text, /abgeschlossen/)
|
||||
assert.match(kunde.text, /Sie erhalten dazu eine gesonderte Abrechnung/)
|
||||
|
||||
const t09 = { ...sampleValues(loadTemplate('T09')), produkt: 'Terminplaner', preis: '149 €', ereignisse: 'Studio Anker: in Einrichtung' }
|
||||
const team = renderMail('T09', t09, { funktion: true })
|
||||
assert.deepEqual(team.problems, [])
|
||||
assert.match(team.text, /Widerruf einer Funktion \(Terminplaner, 149 € einmalig\)/)
|
||||
assert.match(team.text, /NICHTS automatisch geändert/)
|
||||
assert.match(team.text, /innerhalb von 14 Tagen erstatten/)
|
||||
assert.match(team.text, /abzüglich Wertersatz für den begonnenen Einbau \(Aufwand im Arbeitsblatt, ⚖ Q5\)/)
|
||||
assert.match(team.text, /Reiter „Funktionen“/)
|
||||
assert.match(team.text, /Beim Terminplaner bleibt das Wartungs-Abo unberührt/)
|
||||
assert.match(team.text, /Studio Anker: in Einrichtung/)
|
||||
})
|
||||
|
||||
test('K12: Monatspreis mit gebuchtem Zusatzspeicher (Block), Widerruf des Wechsels wie in Fassung 4', () => {
|
||||
const values = sampleValues(loadTemplate('K12'))
|
||||
const mit = renderMail('K12', values, { zusatzspeicher: true })
|
||||
|
||||
+183
-10
@@ -822,12 +822,12 @@ async function raeumeAnkerTest() {
|
||||
for (const id of ['fc_bezahlt', 'fc_offen']) if (aw.docs('featureConsents').has(id)) await deleteDocument('featureConsents', id)
|
||||
}
|
||||
|
||||
test('Widerruf Funktion: Fristanker ist die Zeit des Klicks der bezahlten Sitzung (rechtzeitig)', async () => {
|
||||
test('Widerruf Terminplaner: Fristanker ist die Zeit des Klicks der bezahlten Sitzung (rechtzeitig)', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
const klickAt = daysAgo(13)
|
||||
seedBezahlteBestellung({ klickAt })
|
||||
try {
|
||||
const res = await withdraw({ order: 'funktion' })
|
||||
const res = await withdraw({ order: 'termine' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const team = t09().text
|
||||
// Frist aus dem Klick der bezahlten Sitzung, nicht aus orderedAt (vor 1 Tag) und nicht aus der spaeteren Zustimmung
|
||||
@@ -841,12 +841,12 @@ test('Widerruf Funktion: Fristanker ist die Zeit des Klicks der bezahlten Sitzun
|
||||
}
|
||||
})
|
||||
|
||||
test('Widerruf Funktion: Fristanker aus dem Klick vor 20 Tagen ergibt NACH Fristende', async () => {
|
||||
test('Widerruf Terminplaner: Fristanker aus dem Klick vor 20 Tagen ergibt NACH Fristende', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
const klickAt = daysAgo(20)
|
||||
seedBezahlteBestellung({ klickAt })
|
||||
try {
|
||||
const res = await withdraw({ order: 'funktion' })
|
||||
const res = await withdraw({ order: 'termine' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const team = t09().text
|
||||
assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team)
|
||||
@@ -857,21 +857,21 @@ test('Widerruf Funktion: Fristanker aus dem Klick vor 20 Tagen ergibt NACH Frist
|
||||
}
|
||||
})
|
||||
|
||||
test('Widerruf Funktion: ohne Nachweis zur Sitzung gilt consentAt, dann orderedAt', async () => {
|
||||
test('Widerruf Terminplaner: ohne Nachweis zur Sitzung gilt consentAt, dann orderedAt', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
aw.seed('websiteProjects', { $id: 'pa1', customerId: 'c1', projectName: 'Studio Anker' })
|
||||
const base = { customerId: 'c1', projectId: 'pa1', featureKey: 'termine', state: 'setup', enabled: false, unlockedByPurchase: true, stripeSessionId: 'cs_ohne_nachweis' }
|
||||
try {
|
||||
// consentAt (Altbestand, News) vor orderedAt
|
||||
aw.seed('portalFeatures', { ...base, $id: 'f_pa1_termine', orderedAt: daysAgo(1), consentAt: daysAgo(20) })
|
||||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||||
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(daysAgo(20)).endsAt)))
|
||||
assert.match(t09().text, /NACH Fristende/)
|
||||
|
||||
// weder Nachweis noch consentAt: orderedAt
|
||||
mails.sent.length = 0
|
||||
aw.seed('portalFeatures', { ...base, $id: 'f_pa1_termine', orderedAt: daysAgo(3), consentAt: null })
|
||||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||||
assert.ok(t09().text.includes(fmtDate(withdrawalDeadline(daysAgo(3)).endsAt)))
|
||||
assert.match(t09().text, /RECHTZEITIG/)
|
||||
} finally {
|
||||
@@ -879,7 +879,7 @@ test('Widerruf Funktion: ohne Nachweis zur Sitzung gilt consentAt, dann orderedA
|
||||
}
|
||||
})
|
||||
|
||||
test('Widerruf Funktion: Nachweis-Collection nicht lesbar fällt auf orderedAt zurück, Antwort bleibt 200', async () => {
|
||||
test('Widerruf Terminplaner: Nachweis-Collection nicht lesbar fällt auf orderedAt zurück, Antwort bleibt 200', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
const orderedAt = daysAgo(2)
|
||||
seedBezahlteBestellung({ klickAt: daysAgo(20), orderedAt })
|
||||
@@ -888,7 +888,7 @@ test('Widerruf Funktion: Nachweis-Collection nicht lesbar fällt auf orderedAt z
|
||||
console.warn = (...args) => warnungen.push(args.join(' '))
|
||||
try {
|
||||
aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500 })
|
||||
const res = await withdraw({ order: 'funktion' })
|
||||
const res = await withdraw({ order: 'termine' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const team = t09().text
|
||||
assert.ok(team.includes(fmtDate(withdrawalDeadline(orderedAt).endsAt)), team)
|
||||
@@ -901,6 +901,178 @@ test('Widerruf Funktion: Nachweis-Collection nicht lesbar fällt auf orderedAt z
|
||||
}
|
||||
})
|
||||
|
||||
/** Bezahltes Funktionsdokument (Projekt wird mit angelegt) fuer die Tests je Funktion. */
|
||||
function seedBezahlt({ projectId, projektName, featureKey, orderedAt, state = 'setup' }) {
|
||||
aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: projektName })
|
||||
aw.seed('portalFeatures', {
|
||||
$id: `f_${projectId}_${featureKey}`,
|
||||
customerId: 'c1',
|
||||
projectId,
|
||||
featureKey,
|
||||
state,
|
||||
enabled: false,
|
||||
unlockedByPurchase: true,
|
||||
orderedAt,
|
||||
})
|
||||
}
|
||||
|
||||
async function raeumeBezahlt(...ids) {
|
||||
for (const id of ids) if (aw.docs('portalFeatures').has(id)) await deleteDocument('portalFeatures', id)
|
||||
}
|
||||
|
||||
test('widerruf_termine_frist_je_funktion: Terminplaner vor 3 Tagen, News vor 20 Tagen bestellt; jede Wahl rechnet mit der eigenen Funktion', async () => {
|
||||
const newsAt = daysAgo(20)
|
||||
const termineAt = daysAgo(3)
|
||||
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: newsAt, state: 'active' })
|
||||
seedBezahlt({ projectId: 'pt1', projektName: 'Studio Termine', featureKey: 'termine', orderedAt: termineAt })
|
||||
try {
|
||||
await resetSubH({ units: 0 })
|
||||
const before = st.calls.length
|
||||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||||
const termine = t09().text
|
||||
assert.match(termine, /RECHTZEITIG/)
|
||||
assert.doesNotMatch(termine, /NACH Fristende/)
|
||||
assert.ok(termine.includes(fmtDate(withdrawalDeadline(termineAt).endsAt)), 'Fristende des Terminplaners')
|
||||
assert.match(termine, /Studio Termine: in Einrichtung/)
|
||||
assert.doesNotMatch(termine, /Bäckerei News/, 'die News-Bestellung gehört nicht zu dieser Wahl')
|
||||
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf')
|
||||
assert.equal(aw.docs('portalFeatures').get('f_pt1_termine').state, 'setup', 'nichts automatisch geändert')
|
||||
|
||||
// umgekehrt: Widerruf "Funktion" meint die News und rechnet mit deren Frist; der junge Terminplaner rettet sie nicht
|
||||
await resetSubH({ units: 0 })
|
||||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||||
const news = t09().text
|
||||
assert.match(news, /NACH Fristende/)
|
||||
assert.doesNotMatch(news, /RECHTZEITIG/)
|
||||
assert.ok(news.includes(fmtDate(withdrawalDeadline(newsAt).endsAt)), 'Fristende der News')
|
||||
assert.match(news, /Bäckerei News: aktiv/)
|
||||
assert.doesNotMatch(news, /Studio Termine/)
|
||||
} finally {
|
||||
await raeumeBezahlt('f_pn1_news', 'f_pt1_termine')
|
||||
}
|
||||
})
|
||||
|
||||
test('widerruf_termine_frist_je_funktion: mehrere Projekte einer Funktion - spätestes Fristende, jede Zeile mit eigenem Fristende', async () => {
|
||||
const alt = daysAgo(20)
|
||||
const jung = daysAgo(3)
|
||||
seedBezahlt({ projectId: 'pn1', projektName: 'Alt News', featureKey: 'news', orderedAt: alt, state: 'active' })
|
||||
seedBezahlt({ projectId: 'pn2', projektName: 'Jung News', featureKey: 'news', orderedAt: jung })
|
||||
try {
|
||||
await resetSubH({ units: 0 })
|
||||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||||
const team = t09().text
|
||||
// bisheriges Verhalten: das Maximum der Fristenden entscheidet
|
||||
assert.match(team, /RECHTZEITIG/)
|
||||
assert.ok(team.includes(`${fmtDate(withdrawalDeadline(jung).endsAt)}, 24 Uhr`), 'Fristende = spätestes')
|
||||
assert.match(team, new RegExp(`Alt News: aktiv, bestellt am ${fmtDate(alt)}, Fristende ${fmtDate(withdrawalDeadline(alt).endsAt)}`))
|
||||
assert.match(team, new RegExp(`Jung News: in Einrichtung, bestellt am ${fmtDate(jung)}, Fristende ${fmtDate(withdrawalDeadline(jung).endsAt)}`))
|
||||
} finally {
|
||||
await raeumeBezahlt('f_pn1_news', 'f_pn2_news')
|
||||
}
|
||||
})
|
||||
|
||||
test('widerruf_termine_mails: K04 nennt „Terminplaner (Einrichtung)“, T09 den Preis und das Projekt; bei der News unverändert', async () => {
|
||||
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(3) })
|
||||
seedBezahlt({ projectId: 'pt1', projektName: 'Studio Termine', featureKey: 'termine', orderedAt: daysAgo(2) })
|
||||
try {
|
||||
await resetSubH({ units: 0 })
|
||||
assert.equal((await withdraw({ order: 'termine', customerNumber: 'KD-9' })).status, 200)
|
||||
const kunde = k04()
|
||||
assert.match(kunde.subject, /WEBklar Terminplaner$/)
|
||||
assert.match(kunde.text, /Widerruf der Bestellung „Terminplaner \(Einrichtung\)“/)
|
||||
assert.match(kunde.text, /Wir prüfen Ihren Widerruf/)
|
||||
assert.match(kunde.text, /Einrichtung auf Ihren ausdrücklichen Wunsch schon vor Ablauf der Widerrufsfrist begonnen/)
|
||||
assert.match(kunde.text, /§ 357a Abs\. 2 BGB/)
|
||||
assert.doesNotMatch(kunde.text, /Funktion \(z\. B\. News-Pinnwand\)|Es erfolgen keine weiteren Abbuchungen/)
|
||||
const team = t09().text
|
||||
assert.match(team, /Terminplaner \(Einrichtung\)/)
|
||||
assert.ok(team.includes(`Widerruf einer Funktion (${config.features.termine.label.name}, ${config.features.termine.priceLabel} einmalig)`), team)
|
||||
assert.match(team, /149 €/)
|
||||
assert.match(team, /Studio Termine: in Einrichtung/)
|
||||
assert.match(team, /Beim Terminplaner bleibt das Wartungs-Abo unberührt/)
|
||||
assert.match(team, /NICHTS automatisch geändert/)
|
||||
|
||||
// "Funktion (z. B. News-Pinnwand)" bleibt wie bisher: News-Pinnwand, 30 €
|
||||
await resetSubH({ units: 0 })
|
||||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||||
assert.match(k04().subject, /WEBklar News-Pinnwand$/)
|
||||
assert.match(k04().text, /Funktion \(z\. B\. News-Pinnwand\)/)
|
||||
assert.match(k04().text, /Wir prüfen Ihren Widerruf/)
|
||||
assert.match(t09().text, /Funktion \(z\. B\. News-Pinnwand\)/)
|
||||
assert.ok(t09().text.includes(`Widerruf einer Funktion (${config.features.news.label.name}, ${config.features.news.priceLabel} einmalig)`), t09().text)
|
||||
assert.match(t09().text, /Bäckerei News: in Einrichtung/)
|
||||
assert.doesNotMatch(t09().text, /Studio Termine/)
|
||||
} finally {
|
||||
await raeumeBezahlt('f_pn1_news', 'f_pt1_termine')
|
||||
}
|
||||
})
|
||||
|
||||
test('widerruf_anker_vertragAt: Zustimmung zur bezahlten Sitzung früher als das überschriebene orderedAt - Frist aus vertragAt; je Funktion gefiltert', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
// Terminplaner: Klick vor 13 Tagen, orderedAt (Neubestellung) vor 1 Tag; eine News vor 2 Tagen bestellt (kein Einfluss)
|
||||
const klickAt = daysAgo(13)
|
||||
seedBezahlteBestellung({ klickAt })
|
||||
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(2) })
|
||||
try {
|
||||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||||
let team = t09().text
|
||||
assert.ok(team.includes(fmtDate(withdrawalDeadline(klickAt).endsAt)), team)
|
||||
assert.ok(!team.includes(fmtDate(withdrawalDeadline(daysAgo(1)).endsAt)), 'nicht das Fristende aus orderedAt')
|
||||
assert.match(team, /RECHTZEITIG/)
|
||||
|
||||
// Rückfall ohne featureConsents-Eintrag: orderedAt
|
||||
mails.sent.length = 0
|
||||
await deleteDocument('featureConsents', 'fc_bezahlt')
|
||||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||||
team = t09().text
|
||||
assert.ok(team.includes(fmtDate(withdrawalDeadline(daysAgo(1)).endsAt)), team)
|
||||
assert.match(team, /RECHTZEITIG/)
|
||||
} finally {
|
||||
await raeumeAnkerTest()
|
||||
await raeumeBezahlt('f_pn1_news')
|
||||
}
|
||||
})
|
||||
|
||||
test('widerruf_unbekannte_funktion: kein bezahltes Dokument dieser Funktion - ungeprüft (keine bezahlte Funktion gefunden)', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||||
assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/)
|
||||
assert.match(t09().text, /keine Frist gefunden/)
|
||||
assert.ok(k04(), 'K04 geht trotzdem an den Kunden')
|
||||
|
||||
// nur eine News bezahlt, Widerruf des Terminplaners: auch dann ungeprüft statt Frist der News
|
||||
seedBezahlt({ projectId: 'pn1', projektName: 'Bäckerei News', featureKey: 'news', orderedAt: daysAgo(2) })
|
||||
try {
|
||||
await resetSubH({ units: 0 })
|
||||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||||
assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/)
|
||||
assert.doesNotMatch(t09().text, /RECHTZEITIG|NACH Fristende/)
|
||||
// noch nicht bezahlt (ordered) zählt nicht
|
||||
aw.seed('portalFeatures', { $id: 'f_pt9_termine', customerId: 'c1', projectId: 'pt9', featureKey: 'termine', state: 'ordered', enabled: false, unlockedByPurchase: false, orderedAt: daysAgo(1) })
|
||||
await resetSubH({ units: 0 })
|
||||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||||
assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/)
|
||||
} finally {
|
||||
await raeumeBezahlt('f_pn1_news', 'f_pt9_termine')
|
||||
}
|
||||
})
|
||||
|
||||
test('Widerruf Funktion: Dokument ohne featureKey (Altbestand) zählt als News-Pinnwand', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
aw.seed('websiteProjects', { $id: 'pl1', customerId: 'c1', projectName: 'Altbestand' })
|
||||
aw.seed('portalFeatures', { $id: 'f_pl1_alt', customerId: 'c1', projectId: 'pl1', state: 'active', enabled: true, unlockedByPurchase: true, orderedAt: daysAgo(3) })
|
||||
try {
|
||||
assert.equal((await withdraw({ order: 'funktion' })).status, 200)
|
||||
assert.match(t09().text, /Altbestand: aktiv/)
|
||||
assert.match(t09().text, /RECHTZEITIG/)
|
||||
mails.sent.length = 0
|
||||
assert.equal((await withdraw({ order: 'termine' })).status, 200)
|
||||
assert.match(t09().text, /ungeprüft \(keine bezahlte Funktion gefunden\)/)
|
||||
} finally {
|
||||
await raeumeBezahlt('f_pl1_alt')
|
||||
}
|
||||
})
|
||||
|
||||
test('Widerruf Abschluss in der Frist: auf Kündigung gesetzt; Altbestand ohne Ereignis zählt ab startedAt (F14)', async () => {
|
||||
const startedAt = daysAgo(2)
|
||||
await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } })
|
||||
@@ -1100,12 +1272,13 @@ test('kuendigen.html: Radioknöpfe contract, Feld packages, Bestätigungstext',
|
||||
assert.match(src, /fields: \['contract', 'packages', 'kind', 'reason', 'name', 'email', 'customerNumber', 'effective'\]/)
|
||||
})
|
||||
|
||||
test('widerrufen.html: Radioknöpfe order', () => {
|
||||
test('widerrufen_html_radio: Radioknöpfe order', () => {
|
||||
const src = html('widerrufen.html')
|
||||
assert.match(src, /name="order" value="abschluss"[^>]*> Abschluss meines Abos/)
|
||||
assert.match(src, /name="order" value="wechsel"[^>]*> Wechsel ins Wartungs-Abo/)
|
||||
assert.match(src, /name="order" value="zusatzspeicher"[^>]*> E-Mail-Zusatzspeicher/)
|
||||
assert.match(src, /name="order" value="funktion"[^>]*> Funktion \(z\. B\. News-Pinnwand\)/)
|
||||
assert.match(src, /name="order" value="termine"[^>]*data-text="Terminplaner \(Einrichtung\)"[^>]*> Terminplaner \(Einrichtung\)<\/label>/)
|
||||
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
|
||||
assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/)
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ const aw = await startAppwriteMock({
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { startTestApp, send } = await import('./support/testApp.js')
|
||||
const { kundenansichtCookie } = await import('./support/fixtures.js')
|
||||
@@ -32,7 +32,7 @@ const { resetOutboxForTests, warteAufVersand } = await import('../server/service
|
||||
const { buchungsNummer, formToken, pruefeFormToken, verwaltungsToken } = await import('../server/services/termine/token.js')
|
||||
const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js')
|
||||
const { resetTermineLimitsForTests, darfHaltung } = await import('../server/services/termine/limits.js')
|
||||
const { ZUSTIMMUNG_STORNO, ZUSTIMMUNG_ERINNERUNG, ZUSTIMMUNG_VORZEITIG, GESUNDHEIT_HINWEIS, formularHinweis, widerrufsbelehrung } = await import(
|
||||
const { RECHTSTEXTE_VERSION, ZUSTIMMUNG_STORNO, ZUSTIMMUNG_ERINNERUNG, ZUSTIMMUNG_VORZEITIG, GESUNDHEIT_HINWEIS, formularHinweis, widerrufsbelehrung } = await import(
|
||||
'../server/services/termine/rechtstexte.js'
|
||||
)
|
||||
const { halte } = await import('../server/services/termine/ablauf.js')
|
||||
@@ -274,6 +274,8 @@ test('config: Betriebsdaten, Leistungen, Regeln, Texte und Formular-Token; keine
|
||||
gesundheit: GESUNDHEIT_HINWEIS,
|
||||
widerrufsbelehrung: widerrufsbelehrung(ctx.einstellung.betrieb),
|
||||
})
|
||||
// Haken Erinnerung: Wortlaut der Fassung mit dem Widerrufssatz (Art. 7 Abs. 3 DSGVO), wörtlich in der öffentlichen Konfiguration
|
||||
assert.equal(c.texte.erinnerung, 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.')
|
||||
assert.equal(pruefeFormToken(SITE_KEY, c.formToken, plus(JETZT, 10000)), 'ok')
|
||||
assert.equal(pruefeFormToken('bcdefghijklmnopq', c.formToken, plus(JETZT, 10000)), 'ungueltig', 'das Token gilt nur fuer diesen siteKey')
|
||||
|
||||
@@ -564,6 +566,36 @@ test('Buchung: 201 mit maskierter Adresse und Haltezeit; Buchung gehalten, Mail
|
||||
assert.ok(!JSON.stringify(res.body).includes(b.$id))
|
||||
})
|
||||
|
||||
test('nachweis_fassung: eine neue Buchung trägt die aktuelle Fassung der Gästetexte, eine Altbuchung behält 2026-10-02 (der Nachweis wird nie umgeschrieben)', async () => {
|
||||
await speichere()
|
||||
const { id: neu, verwaltung } = await gebucht({ erinnerung: true, zustimmungen: { storno: true, vorzeitig: false } })
|
||||
const nachweisNeu = JSON.parse(aw.docs('termineBookings').get(neu).nachweis)
|
||||
assert.equal(nachweisNeu.fassung, RECHTSTEXTE_VERSION)
|
||||
assert.notEqual(nachweisNeu.fassung, '2026-10-02')
|
||||
assert.equal(nachweisNeu.erinnerung.hash.length, 64)
|
||||
|
||||
// Altbuchung: Nachweis der Fassung 2026-10-02; Bestätigen, Verschieben und Absagen schreiben den Nachweis nicht um
|
||||
const alt = await buchen({ email: 'alt@example.com', zeit: '11:00', ressource: R1 })
|
||||
assert.equal(alt.status, 201)
|
||||
await warteAufVersand()
|
||||
const buchungAlt = buchungen().find((b) => b.gastEmail === 'alt@example.com')
|
||||
const nachweisAlt = JSON.stringify({ ...JSON.parse(buchungAlt.nachweis), fassung: '2026-10-02' })
|
||||
await updateDocument('termineBookings', buchungAlt.$id, { nachweis: nachweisAlt })
|
||||
const vToken = tokenAusMail(mailsAn('alt@example.com')[0], 'v')
|
||||
assert.equal((await tokenAnruf('bestaetigen', { token: vToken })).status, 200)
|
||||
await warteAufVersand()
|
||||
const mToken = tokenAusMail(mailsAn('alt@example.com').find((m) => /#m=/.test(m.text)), 'm')
|
||||
assert.equal((await tokenAnruf('verschieben', { token: mToken, tag: TAG_FERN, zeit: '11:30' })).status, 200)
|
||||
assert.equal((await tokenAnruf('absagen', { token: mToken })).status, 200)
|
||||
const danach = aw.docs('termineBookings').get(buchungAlt.$id)
|
||||
assert.equal(danach.status, 'abgesagt')
|
||||
assert.equal(danach.nachweis, nachweisAlt, 'der Nachweis der Altbuchung bleibt byte-gleich')
|
||||
assert.equal(JSON.parse(danach.nachweis).fassung, '2026-10-02')
|
||||
// die neue Buchung ist davon unberührt
|
||||
assert.equal(JSON.parse(aw.docs('termineBookings').get(neu).nachweis).fassung, RECHTSTEXTE_VERSION)
|
||||
assert.ok(verwaltung)
|
||||
})
|
||||
|
||||
test('zu viele offene Termine: dieselbe 201-Antwort, keine neue Buchung, statt der Bestaetigung geht G01 "zu viele"', async () => {
|
||||
await speichere()
|
||||
// drei bestaetigte Termine belegen die drei offenen Plaetze; die Kappe der Adresse (3 Mails je Stunde) ist damit ausgeschoepft,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// const fx = installFakeDom({ antworten: [{ status: 400, body: { fehler: {...} } }] })
|
||||
// try { ...Client-Code bauen, fx.klick(knopf), fx.fetchLog pruefen... } finally { fx.restore() }
|
||||
//
|
||||
// installFakeDom stellt `document`, `window` (nur location und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
|
||||
// installFakeDom stellt `document`, `window` (nur location mit href, pathname, search, hash und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
|
||||
// alten Zustand wieder her. `h` aus public/termine-dom.js liest `document` bei jedem Aufruf: der Client braucht keine Einhaengepunkte.
|
||||
// Wurzeln, die `isConnected` brauchen (neuerSpeicher.nochHier), haengt der Test selbst an `document.body`.
|
||||
//
|
||||
@@ -479,7 +479,7 @@ const GLOBALE = ['document', 'window', 'CSS', 'fetch']
|
||||
export function installFakeDom({ antworten = [] } = {}) {
|
||||
const vorher = Object.fromEntries(GLOBALE.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]))
|
||||
const document = neuesDokument()
|
||||
const location = { href: 'http://localhost/', hash: '' }
|
||||
const location = { href: 'http://localhost/', pathname: '/dashboard.html', search: '', hash: '' }
|
||||
const window = {
|
||||
document,
|
||||
location,
|
||||
|
||||
@@ -34,7 +34,7 @@ const { zellenFuer, zellenId } = await import('../server/services/termine/zellen
|
||||
const { RECHTSTEXTE_VERSION, ZUSTIMMUNG_STORNO, ZUSTIMMUNG_VORZEITIG, ZUSTIMMUNG_ERINNERUNG, formularHinweis } = await import(
|
||||
'../server/services/termine/rechtstexte.js'
|
||||
)
|
||||
const { halte, vorschau, bestaetige } = await import('../server/services/termine/ablauf.js')
|
||||
const { halte, vorschau, bestaetige, pruefeEingabe } = await import('../server/services/termine/ablauf.js')
|
||||
after(() => setMailTransportForTests(null))
|
||||
|
||||
const MIN = 60000
|
||||
@@ -280,6 +280,50 @@ test('halte: Anfrage und Leistung ohne Preis bekommen ihre Knoepfe und brauchen
|
||||
assert.match(JSON.parse(anfrage.nachweis).widerruf.hash, /^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
test('storno_haken_nur_bei_preis: der Storno-Haken ist nur bei einer Leistung mit Preis Pflicht und steht nur dort im Nachweis', async () => {
|
||||
const ctx = await speichere()
|
||||
// mit Preis: ohne Haken wie bisher 400 (Feld storno), nichts angelegt
|
||||
const err = await fehlerVon(() => halte(ctx, eingabe({ zustimmungen: { storno: false, vorzeitig: false } }), JETZT))
|
||||
assert.equal(err.status, 400)
|
||||
assert.deepEqual(Object.keys(err.fehler), ['storno'])
|
||||
assert.equal(buchungen().length, 0)
|
||||
// ohne Preis (sofort): ohne Haken gehalten, kein Eintrag storno im Nachweis
|
||||
await halte(ctx, eingabe({ leistung: S_GRATIS, zeit: '10:00', email: 'a@example.com', zustimmungen: { storno: false, vorzeitig: false } }), JETZT)
|
||||
// ohne Preis, auch ganz ohne Zustimmungen
|
||||
await halte(ctx, eingabe({ leistung: S_GRATIS, zeit: '12:00', email: 'b@example.com', zustimmungen: undefined }), JETZT)
|
||||
// ohne Preis, Anfrage
|
||||
await halte(ctx, eingabe({ leistung: S_ANFRAGE, zeit: '14:00', email: 'c@example.com', zustimmungen: {} }), JETZT)
|
||||
// ohne Preis mit storno: true (altes Widget, anderer Client): ebenfalls kein Nachweis des Hakens
|
||||
await halte(ctx, eingabe({ leistung: S_GRATIS, zeit: '16:00', email: 'd@example.com', zustimmungen: { storno: true, vorzeitig: false } }), JETZT)
|
||||
assert.equal(buchungen().length, 4)
|
||||
for (const b of buchungen()) {
|
||||
assert.equal(b.status, 'gehalten')
|
||||
const nachweis = JSON.parse(b.nachweis)
|
||||
assert.equal(nachweis.storno, undefined, `${b.leistungId}: kein Storno-Nachweis ohne Preis`)
|
||||
assert.equal(Object.hasOwn(nachweis, 'storno'), false)
|
||||
assert.ok(!b.nachweis.includes(sha256(ZUSTIMMUNG_STORNO)), 'auch der Hash des Haken-Textes fehlt')
|
||||
}
|
||||
// mit Preis und Haken: Nachweis wie bisher
|
||||
await halte(ctx, eingabe({ zeit: '09:00', email: 'e@example.com' }), JETZT)
|
||||
const mitPreis = buchungen().find((b) => b.leistungId === S_SOFORT)
|
||||
assert.deepEqual(JSON.parse(mitPreis.nachweis).storno, { version: ctx.einstellung.storno.version, hash: sha256(ctx.einstellung.storno.text), haken: sha256(ZUSTIMMUNG_STORNO), am: iso(JETZT) })
|
||||
|
||||
// Anfrage mit Preis: Haken bleibt Pflicht
|
||||
const anfrageMitPreis = { id: 's_bbbbbbb4', name: 'Beratung', dauer: 30, preisArt: 'fest', preisCent: 3000, buchungsart: 'anfrage', ressourcen: [R1, R2] }
|
||||
const mitAnfrage = await speichere(einstellung({ leistungen: [anfrageMitPreis] }))
|
||||
const ohneHaken = await fehlerVon(() => halte(mitAnfrage, eingabe({ leistung: 's_bbbbbbb4', zeit: '11:00', email: 'f@example.com', zustimmungen: { storno: false, vorzeitig: true } }), JETZT))
|
||||
assert.deepEqual(Object.keys(ohneHaken.fehler), ['storno'])
|
||||
|
||||
// Handeintrag (manuell): nie Zustimmungen, mit und ohne Preis
|
||||
const e = ctx.einstellung
|
||||
for (const leistung of [S_SOFORT, S_GRATIS]) {
|
||||
const geprueft = pruefeEingabe(e, eingabe({ leistung, zustimmungen: undefined }), JETZT, { manuell: true })
|
||||
assert.equal(geprueft.stornoNoetig, false, `Handeintrag ${leistung}`)
|
||||
}
|
||||
assert.equal(pruefeEingabe(e, eingabe({ leistung: S_SOFORT }), JETZT).stornoNoetig, true)
|
||||
assert.equal(pruefeEingabe(e, eingabe({ leistung: S_GRATIS }), JETZT).stornoNoetig, false)
|
||||
})
|
||||
|
||||
test('halte: egal waehlt die Ressource mit den wenigsten Buchungen des Tages, eine feste Ressource bleibt sie', async () => {
|
||||
const ctx = await speichere()
|
||||
await halte(ctx, eingabe({ ressource: R1, zeit: '09:00' }), JETZT)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,7 @@ import {
|
||||
zahlungText,
|
||||
fehlendePflichtangaben,
|
||||
zeigtWiderruf,
|
||||
hatPreis,
|
||||
} from '../server/services/termine/einstellungen.js'
|
||||
import { VORLAGEN, vorlagenListe, ausVorlage } from '../server/services/termine/vorlagen.js'
|
||||
import { BUNDESLAENDER } from '../server/services/termine/feiertage.js'
|
||||
@@ -116,8 +117,12 @@ test('Vorlagen: Anfrage bei tattoo/handwerk/fotograf, sonst sofort; Werkstatt mi
|
||||
assert.ok(werkstatt.ressourcen.every((r) => r.typ === 'platz'))
|
||||
})
|
||||
|
||||
test('Vorlagen: Stornotext Variante 1 mit Frist aus den Regeln und 50 Prozent, Widerrufssatz bleibt', () => {
|
||||
for (const key of Object.keys(VORLAGEN)) {
|
||||
const MIT_PREIS = (key) => VORLAGEN[key].leistungen.some((l) => l.preisArt !== 'keiner' && l.preisCent > 0)
|
||||
|
||||
test('Vorlagen: Stornotext Variante 1 mit Frist aus den Regeln und 50 Prozent, Widerrufssatz bleibt (Vorlagen mit Preis)', () => {
|
||||
const mitPreis = Object.keys(VORLAGEN).filter(MIT_PREIS)
|
||||
assert.ok(mitPreis.length >= 7, 'die meisten Vorlagen haben Preise')
|
||||
for (const key of mitPreis) {
|
||||
const e = ausVorlage(key, BETRIEB, NOW)
|
||||
const text = e.storno.text
|
||||
assert.ok(text.startsWith(`Sie können Ihren Termin bis ${e.regeln.absageBisStd} Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail).`), key)
|
||||
@@ -129,6 +134,26 @@ test('Vorlagen: Stornotext Variante 1 mit Frist aus den Regeln und 50 Prozent, W
|
||||
}
|
||||
})
|
||||
|
||||
test('vorlage_ohne_preis_ohne_storno: eine Vorlage, deren Leistungen alle kostenlos sind, bringt keinen Stornotext mit; Vorlagen mit Preisen bleiben unverändert', () => {
|
||||
const ohnePreis = Object.keys(VORLAGEN).filter((key) => !MIT_PREIS(key))
|
||||
assert.deepEqual(ohnePreis.sort(), ['handwerk', 'sonstiges'], 'diese beiden Vorlagen haben nur kostenlose Leistungen')
|
||||
for (const key of ohnePreis) {
|
||||
const e = ausVorlage(key, BETRIEB, NOW)
|
||||
assert.ok(e.leistungen.every((l) => !hatPreis(l)), key)
|
||||
assert.equal(e.storno.text, '', `${key}: kein Stornotext`)
|
||||
assert.equal(e.storno.version, '', `${key}: und keine Fassungsnummer`)
|
||||
assert.deepEqual(gueltig(e, { bisher: e }), e, `${key}: stabil`)
|
||||
}
|
||||
// mit Preisen: der Vorschlag steht wie bisher im Entwurf (50 % / 24 h für Salon)
|
||||
for (const key of Object.keys(VORLAGEN).filter(MIT_PREIS)) {
|
||||
const e = ausVorlage(key, BETRIEB, NOW)
|
||||
assert.equal(e.storno.text, VORLAGEN[key].storno, key)
|
||||
assert.equal(e.storno.version, '2026-10-02-1', key)
|
||||
}
|
||||
// die Vorlagendaten selbst bleiben unverändert (der Text steht dort weiter, ausVorlage entscheidet)
|
||||
assert.ok(VORLAGEN.handwerk.storno.includes('50 %'))
|
||||
})
|
||||
|
||||
test('Vorlagen: zwei Aufrufe ergeben verschiedene IDs und Feed-Token; Kleinunternehmer wird uebernommen', () => {
|
||||
const a = ausVorlage('salon', BETRIEB, NOW)
|
||||
const b = ausVorlage('salon', { ...BETRIEB, kleinunternehmer: true }, NOW)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
import { luminanz as serverLuminanz, textFarbeAuf as serverTextFarbeAuf } from '../server/services/termine/einbauanleitung.js'
|
||||
|
||||
// Statische Pruefung des Einbau-Skripts public/embed/termine.js (Aufgabe 19, Rulings R3 und R4):
|
||||
// - Das Widget rechnet nie mit dem Datum oder der Zeitzone des Browsers (Kalender, Wochentage, Uhrzeiten und Datumstexte kommen
|
||||
@@ -196,6 +197,57 @@ test('Textfarbe: kein Farbton faellt unter 4,5 : 1 (alle Grautoene und ein Raste
|
||||
}
|
||||
})
|
||||
|
||||
test('Farbparitaet: Widget und Farbvorschlag des Servers (einbauanleitung.js) waehlen fuer alle 4096 Farben dieselbe Schrift', () => {
|
||||
const { textFarbeAuf, luminanz } = farbFunktionen()
|
||||
const stufen = Array.from({ length: 16 }, (_, i) => i * 17) // 0, 17, ... 255
|
||||
const hex = (c) => c.toString(16).padStart(2, '0')
|
||||
let anzahl = 0
|
||||
for (const r of stufen) {
|
||||
for (const g of stufen) {
|
||||
for (const b of stufen) {
|
||||
const farbe = `#${hex(r)}${hex(g)}${hex(b)}`
|
||||
assert.equal(serverTextFarbeAuf(farbe), textFarbeAuf(farbe), `${farbe}: Schriftfarbe weicht ab`)
|
||||
assert.equal(serverLuminanz(farbe), luminanz(farbe), `${farbe}: Leuchtdichte weicht ab`)
|
||||
anzahl += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(anzahl, 4096)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Stornobedingungen nur bei Leistungen mit Preis (TP2 F11)
|
||||
|
||||
/** Die reine Funktion stornoGilt des Skripts (zwischen den Marken @storno-anfang und @storno-ende) einzeln ausfuehren. */
|
||||
function stornoFunktion() {
|
||||
const m = /\/\/ @storno-anfang[^\n]*\n([\s\S]*?)\/\/ @storno-ende/.exec(QUELLE)
|
||||
assert.ok(m, 'Marken @storno-anfang/@storno-ende fehlen')
|
||||
return vm.runInNewContext(`${m[1]}; stornoGilt`, {})
|
||||
}
|
||||
|
||||
test('widget_storno_je_leistung: Box, Haken und Pruefung der Stornobedingungen haengen am Preis der gewaehlten Leistung', () => {
|
||||
const stornoGilt = stornoFunktion()
|
||||
const storno = { text: 'Absage bis 24 Stunden vorher.', version: '2026-10-02-1' }
|
||||
// nur mit Stornotext des Betriebs UND einer Leistung mit Preis (l.preis ist der Preistext des Servers, '' ohne Preis)
|
||||
assert.equal(stornoGilt(storno, { id: 's1', preis: '45,00 €' }), true)
|
||||
assert.equal(stornoGilt(storno, { id: 's1', preis: 'ab 45,00 €' }), true)
|
||||
assert.equal(stornoGilt(storno, { id: 's2', preis: '' }), false, 'kostenlos: weder Box noch Haken noch Pruefung')
|
||||
assert.equal(stornoGilt(storno, { id: 's2' }), false)
|
||||
assert.equal(stornoGilt(storno, { id: 's2', preis: null }), false)
|
||||
assert.equal(stornoGilt(storno, null), false)
|
||||
assert.equal(stornoGilt(storno, undefined), false)
|
||||
assert.equal(stornoGilt(null, { id: 's1', preis: '45,00 €' }), false, 'kein Stornotext beim Betrieb')
|
||||
assert.equal(stornoGilt(undefined, { id: 's1', preis: '45,00 €' }), false)
|
||||
// im Zusammenfassungs-Schritt: Box, Haken und Pruefung fragen alle stornoGilt(…, l) – nie mehr nur c.storno
|
||||
assert.match(OHNE_KOMMENTARE, /var stornoAn = stornoGilt\(c\.storno, l\)/)
|
||||
assert.match(OHNE_KOMMENTARE, /if \(stornoAn && istText\(c\.storno\.text\)\) \{\s*sb\.appendChild\(el\('h3', '', 'Stornobedingungen'\)\)/, 'Box nur mit Stornotext und Preis')
|
||||
assert.match(OHNE_KOMMENTARE, /if \(\(stornoAn && istText\(c\.storno\.text\)\) \|\| agbUrl\) \{/, 'der AGB-Link bleibt unabhaengig vom Preis')
|
||||
assert.match(OHNE_KOMMENTARE, /if \(stornoAn\) form\.appendChild\(haken\(refs, 'storno', c\.texte\.storno, dd\.storno, true,/)
|
||||
assert.match(OHNE_KOMMENTARE, /if \(stornoAn && !dd\.storno\) f\.storno = /)
|
||||
assert.doesNotMatch(OHNE_KOMMENTARE, /if \(c\.storno\)|if \(c\.storno &&|c\.storno && !dd\.storno/, 'kein Haken und keine Pruefung allein nach c.storno')
|
||||
// gesendet wird der Haken nur, wenn er gezeigt wurde
|
||||
assert.match(OHNE_KOMMENTARE, /zustimmungen: \{ storno: stornoGilt\(S\.cfg\.storno, aktuelleLeistung\(\)\) && !!dd\.storno, vorzeitig: !!dd\.vorzeitig \}/)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Demo und Einbauanleitung
|
||||
|
||||
test('Demo-Seite: noindex, Ausweichtext, Skript per defer, beide Modi', () => {
|
||||
@@ -216,7 +268,7 @@ test('Einbauanleitung: Snippet, Variablen, drei Stile, Datenschutz-Absatz, Abnah
|
||||
'data-modus', 'data-api', 'data-knopf', 'data-leistung',
|
||||
'--wkt-akzent', '--wkt-akzent-text', '--wkt-hintergrund', '--wkt-text', '--wkt-rand', '--wkt-radius', '--wkt-schrift', '--wkt-schrift-titel',
|
||||
'@layer wkt', 'wkt-style',
|
||||
'docs/rechtliches/termine-datenschutz-absatz.md', 'ENTWURF',
|
||||
'docs/rechtliches/termine-datenschutz-absatz.md', 'Fassung',
|
||||
'Abnahme', '/api/public/termine/v1', '/config', '/tage', '/zeiten', '/buchungen', '/widerruf', '/kalender/',
|
||||
'termine-demo.html', 'termine-widget.ui.mjs',
|
||||
]) {
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
} from '../server/services/termine/fragebogen.js'
|
||||
import { MELDUNG_PFLICHT, sichtbareLeistungen } from '../server/services/termine/einstellungen.js'
|
||||
import { aboMeldung } from '../server/services/featureOrder.js'
|
||||
import { oeffentlicheAnsicht, featureHaken, BELEHRUNG_PLATZHALTER } from '../server/services/featureRegistry.js'
|
||||
import { oeffentlicheAnsicht, featureHaken } from '../server/services/featureRegistry.js'
|
||||
import { widerrufsbelehrungFunktion } from '../server/services/termine/rechtstexte.js'
|
||||
import { config } from '../server/config.js'
|
||||
import { bestellbarerStand, entwurfDoc } from './support/fragebogenStand.js'
|
||||
|
||||
@@ -724,7 +725,7 @@ test('bestellungAnsicht: Abbildung von oeffentlicheAnsicht – Preis, Hinweis, E
|
||||
test('bestellungAnsicht: belehrung ist das Ergebnis des Hakens (derselbe Text, den der Nachweis speichert)', async () => {
|
||||
const ansicht = await bestellungAnsicht(T)
|
||||
assert.equal(ansicht.belehrung, await featureHaken('termine').belehrung())
|
||||
assert.equal(ansicht.belehrung, BELEHRUNG_PLATZHALTER)
|
||||
assert.equal(ansicht.belehrung, widerrufsbelehrungFunktion())
|
||||
// ein Test-Haken im Register ueberschreibt den Standard
|
||||
T.belehrung = async () => 'Belehrung Test'
|
||||
try {
|
||||
|
||||
@@ -322,8 +322,8 @@ test('einbau.snippet und feeds nur in active (feeds) und active/disabled (snippe
|
||||
}
|
||||
})
|
||||
|
||||
test('abo, offen und bestellung gibt es nur in off; sonst null', async () => {
|
||||
for (const zustand of ['ordered', 'setup', 'active', 'disabled']) {
|
||||
test('abo gibt es nur in off; offen und bestellung nur in off und ordered (R110); in setup, active und disabled null', async () => {
|
||||
for (const zustand of ['setup', 'active', 'disabled']) {
|
||||
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
||||
await deleteDocument('termineConfig', FEATURE).catch(() => {})
|
||||
setzeZustand(zustand)
|
||||
@@ -332,11 +332,57 @@ test('abo, offen und bestellung gibt es nur in off; sonst null', async () => {
|
||||
assert.equal(body.offen, null, zustand)
|
||||
assert.equal(body.bestellung, null, zustand)
|
||||
}
|
||||
setzeZustand('off')
|
||||
for (const zustand of ['off', 'ordered']) {
|
||||
await deleteDocument('portalFeatures', FEATURE).catch(() => {})
|
||||
await deleteDocument('termineConfig', FEATURE).catch(() => {})
|
||||
setzeZustand(zustand)
|
||||
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
||||
assert.notEqual(body.offen, null, zustand)
|
||||
assert.notEqual(body.bestellung, null, zustand)
|
||||
assert.equal(body.abo === null, zustand === 'ordered', `${zustand}: das Abo liest die Route nur in off`)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- ordered: bestellung und offen nur lesend (R110)
|
||||
// "Zahlung fortsetzen" ohne Nachweis (409 zustimmung_fehlt) zeigt im Zustand ordered wieder das Bestellformular; dafuer braucht der
|
||||
// Client bestellung und offen auch dort. Das Abo gehoert nicht dazu: es wird in ordered nicht gelesen, offen hat keinen Abo-Punkt (h).
|
||||
|
||||
test('ordered: bestellung ist die Sicht des Registers wie in off, offen die Bestellprüfung ohne Abo-Punkt, abo null, nichts wird geschrieben', async () => {
|
||||
const stand = setzeZustand('ordered')
|
||||
aw.resetListCalls()
|
||||
const vorher = groessen()
|
||||
const { status, body } = await send('GET', einstellungenUrl(), { cookie })
|
||||
assert.equal(status, 200)
|
||||
assert.equal(body.state, 'ordered')
|
||||
assert.deepEqual(body.bestellung, JSON.parse(JSON.stringify(await bestellungAnsicht(T))))
|
||||
assert.equal(body.bestellung.consent.knopf, T.consent.knopf)
|
||||
assert.deepEqual(body.offen, [], 'bestellbarer Entwurf: keine offenen Punkte, auch ohne Abo-Punkt')
|
||||
assert.deepEqual(body.offen, bestellpruefung({ einstellung: stand.einstellung, fragebogen: stand.fragebogen, feature: T, mitAbo: false }))
|
||||
assert.equal(body.abo, null)
|
||||
assert.deepEqual(aw.listCalls('subscriptions'), [], 'in ordered wird das Abo nicht gelesen')
|
||||
assert.deepEqual(groessen(), vorher, 'read-only: nichts angelegt, nichts protokolliert')
|
||||
})
|
||||
|
||||
test('ordered: ein unvollständiger Entwurf zeigt seine offenen Punkte (nie den Abo-Punkt), ohne Einstellung den Punkt „Branche“', async () => {
|
||||
setzeZustand('ordered', { stand: { ...bestellbarerStand({ now: JETZT }), fragebogen: leerFragebogen() } })
|
||||
const { body } = await send('GET', einstellungenUrl(), { cookie })
|
||||
assert.notEqual(body.abo, null)
|
||||
assert.notEqual(body.offen, null)
|
||||
assert.ok(body.offen.length > 0)
|
||||
assert.ok(body.offen.some((p) => p.schritt === 'leistungen'))
|
||||
assert.ok(body.offen.every((p) => p.schritt !== 'bestellen'), 'kein Abo-Punkt (h)')
|
||||
await deleteDocument('termineConfig', FEATURE)
|
||||
const ohne = await send('GET', einstellungenUrl(), { cookie })
|
||||
assert.equal(ohne.status, 200)
|
||||
assert.deepEqual(ohne.body.offen, [{ schritt: 'branche', text: 'Bitte starte den Fragebogen mit der Wahl deiner Branche.' }])
|
||||
assert.notEqual(ohne.body.bestellung, null)
|
||||
})
|
||||
|
||||
test('ordered in der Kundenansicht: bestellung und offen ebenfalls da, kundenansicht true', async () => {
|
||||
setzeZustand('ordered')
|
||||
const { status, body } = await send('GET', einstellungenUrl(), kv())
|
||||
assert.equal(status, 200)
|
||||
assert.equal(body.kundenansicht, true)
|
||||
assert.notEqual(body.bestellung, null)
|
||||
assert.deepEqual(body.offen, [])
|
||||
})
|
||||
|
||||
test('E11 bleibt: disabled liefert das Feld abgeschaltet mit Grund, Zeitpunkt und Löschtag; Schreiben ist weiter 409 "abgeschaltet"', async () => {
|
||||
|
||||
@@ -1443,35 +1443,72 @@ test('statusKarte ordered: „Zahlung fortsetzen“ sendet fortsetzenKoerper und
|
||||
assert.equal(fx.window.location.href, KNOPF_URL)
|
||||
}))
|
||||
|
||||
test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter() lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({
|
||||
test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter({ bestellen: true, meldung }) lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({
|
||||
antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }],
|
||||
}, async (fx) => {
|
||||
let weiter = 0
|
||||
const wurzel = karte({}, {}, { weiter: async () => { weiter += 1 } })
|
||||
const aufrufe = []
|
||||
const wurzel = karte({}, {}, { weiter: async (fall) => { aufrufe.push(fall) } })
|
||||
await fx.klick(fx.finde(wurzel, '[data-aktion="fortsetzen"]'))
|
||||
assert.equal(weiter, 1)
|
||||
assert.deepEqual(aufrufe, [{ bestellen: true, meldung: 'Bitte stimme der Erklärung zu, um zu bestellen.' }])
|
||||
assert.equal(fx.window.location.href, 'http://localhost/')
|
||||
}))
|
||||
|
||||
test('statusKarte ordered: ohne weiter() oder bei anderem Fehler steht die Meldung am Knopf, der Knopf ist wieder frei', mitDom({
|
||||
antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, { body: { url: 'http://unsicher.example/x' } }],
|
||||
test('statusKarte ordered: jeder andere Fehler ruft weiter({ bestellen: false, meldung }) (R110), die Karte bleibt nie veraltet stehen', mitDom({
|
||||
antworten: [
|
||||
{ status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
|
||||
{ status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } },
|
||||
{ status: 500, body: {} },
|
||||
],
|
||||
}, async (fx) => {
|
||||
let weiter = 0
|
||||
const aufrufe = []
|
||||
// wie im Browser zeichnet sich die Ansicht nach weiter() neu: der Knopf der alten Karte bleibt gesperrt, jeder Fall hat seine Karte
|
||||
for (let i = 0; i < 3; i += 1) await fx.klick(fx.finde(karte({}, {}, { weiter: (fall) => { aufrufe.push(fall) } }), '[data-aktion="fortsetzen"]'))
|
||||
assert.deepEqual(aufrufe, [
|
||||
{ bestellen: false, meldung: 'Bitte stimme der Erklärung zu, um zu bestellen.' },
|
||||
{ bestellen: false, meldung: 'Der Terminplaner ist für dieses Projekt schon bestellt.' },
|
||||
{ bestellen: false, meldung: 'Fehler 500' },
|
||||
], 'auch bei 400 (Zustand inzwischen setup oder active) und 409 ohne zustimmung_fehlt')
|
||||
}))
|
||||
|
||||
test('statusKarte ordered: scheitert weiter() (Neuladen nicht möglich), bleibt die Karte mit dem Fehler am Knopf; ohne weiter() steht die Meldung des Servers dort', mitDom({
|
||||
antworten: [
|
||||
{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
|
||||
{ status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } },
|
||||
{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
|
||||
{ status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } },
|
||||
{ body: { url: 'http://unsicher.example/x' } },
|
||||
],
|
||||
}, async (fx) => {
|
||||
const kaputt = () => { throw new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.') }
|
||||
const mit = karte({}, {}, { weiter: kaputt })
|
||||
const meldungMit = fx.finde(mit, '[data-meldung="fortsetzen"]')
|
||||
const knopfMit = fx.finde(mit, '[data-aktion="fortsetzen"]')
|
||||
await fx.klick(knopfMit)
|
||||
assert.equal(fx.text(meldungMit), 'Keine Verbindung. Bitte versuch es gleich noch einmal.', 'bei fehlender Zustimmung der Grund des Neuladens')
|
||||
assert.equal(knopfMit.disabled, false)
|
||||
await fx.klick(knopfMit)
|
||||
assert.equal(fx.text(meldungMit), 'Der Terminplaner ist für dieses Projekt schon bestellt.', 'sonst der Fehler des Versuchs')
|
||||
const ohne = karte()
|
||||
const knopf = fx.finde(ohne, '[data-aktion="fortsetzen"]')
|
||||
await fx.klick(knopf)
|
||||
assert.equal(fx.text(fx.finde(ohne, '[data-meldung="fortsetzen"]')), 'Bitte stimme der Erklärung zu, um zu bestellen.')
|
||||
assert.equal(knopf.disabled, false)
|
||||
const mit = karte({}, {}, { weiter: () => { weiter += 1 } })
|
||||
await fx.klick(fx.finde(mit, '[data-aktion="fortsetzen"]'))
|
||||
assert.equal(weiter, 0, 'weiter() nur bei zustimmung_fehlt')
|
||||
assert.equal(fx.text(fx.finde(mit, '[data-meldung="fortsetzen"]')), 'Der Terminplaner ist für dieses Projekt schon bestellt.')
|
||||
const ohneZwei = karte()
|
||||
await fx.klick(fx.finde(ohneZwei, '[data-aktion="fortsetzen"]'))
|
||||
assert.equal(fx.text(fx.finde(ohneZwei, '[data-meldung="fortsetzen"]')), 'Der Terminplaner ist für dieses Projekt schon bestellt.')
|
||||
const unsicher = karte()
|
||||
await fx.klick(fx.finde(unsicher, '[data-aktion="fortsetzen"]'))
|
||||
assert.equal(fx.window.location.href, 'http://localhost/', 'keine http-Navigation')
|
||||
assert.match(fx.text(fx.finde(unsicher, '[data-meldung="fortsetzen"]')), /Zahlungsseite/)
|
||||
}))
|
||||
|
||||
test('statusKarte: meldung steht beim Zeichnen am Knopf „Zahlung fortsetzen“ (als Text)', mitDom(undefined, (fx) => {
|
||||
const wurzel = karte({}, { meldung: 'Deine Zahlung läuft schon. <b>x</b>' })
|
||||
assert.equal(fx.text(fx.finde(wurzel, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft schon. <b>x</b>')
|
||||
assert.equal(fx.alle(wurzel, 'b').length, 0)
|
||||
assert.equal(fx.text(fx.finde(karte(), '[data-meldung="fortsetzen"]')), '')
|
||||
}))
|
||||
|
||||
test('statusKarte ordered: in der Kundenansicht ist „Zahlung fortsetzen“ gesperrt, mit Hinweis und ohne Anfrage', mitDom(undefined, async (fx) => {
|
||||
const wurzel = karte({}, { kundenansicht: true })
|
||||
const knopf = fx.finde(wurzel, '[data-aktion="fortsetzen"]')
|
||||
@@ -2965,3 +3002,519 @@ test('termine.css: Klassen der Schritte 4 bis 8 (Person, Ausnahmen, Sammelknopf,
|
||||
for (const klasse of ['tm-fb-person', 'tm-fb-ausnahmen', 'tm-fb-sammel', 'tm-fb-einbau']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} ist gestaltet`)
|
||||
assert.match(css.match(/\.tm-fb-sammel\s*\{[^}]*\}/)?.[0] ?? '', /flex-wrap:\s*wrap/, 'die Knöpfe brechen am Handy um')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------- Ansicht #termine nach Zustand und Rückkehr von Stripe (TP2, Task C10b)
|
||||
// initTermine in public/termine.js. Der Fake-fetch antwortet hier nach Adresse statt der Reihe nach: Antworten von GET /api/funktionen (dort
|
||||
// steht der Abo-Hinweis, R111) und GET /api/termine/einstellungen liefert der Test. Ein Array in der Tabelle sind aufeinanderfolgende Antworten
|
||||
// (die letzte gilt weiter). Der Zustand der Huelle (Auswahl des Projekts und des Reiters) bleibt zwischen den Tests im Modul: jeder Test nennt
|
||||
// sein Projekt (projectId), und kein Test verlangt einen bestimmten Reiter, ohne ihn zu waehlen.
|
||||
const AN_FUNKTIONEN = 'GET /api/funktionen'
|
||||
const AN_CHECKOUT = 'POST /api/funktionen/termine/checkout'
|
||||
const an = (projectId) => `GET /api/termine/einstellungen?projectId=${projectId}`
|
||||
const SERVER_HINWEIS = 'Dieser Satz steht nur beim Server, so wie der Server ihn schreibt.'
|
||||
const AUFRUFE = (fx) => fx.fetchLog.map((a) => `${a.method} ${a.url}`)
|
||||
|
||||
function routen(fx, tabelle) {
|
||||
globalThis.fetch = async (url, optionen = {}) => {
|
||||
const aufruf = { method: optionen.method || 'GET', url: String(url), body: optionen.body ? JSON.parse(optionen.body) : undefined }
|
||||
fx.fetchLog.push(aufruf)
|
||||
let eintrag = tabelle[`${aufruf.method} ${aufruf.url}`]
|
||||
if (eintrag === undefined && aufruf.method === 'GET' && aufruf.url.startsWith('/api/termine/buchungen')) eintrag = { body: { buchungen: [] } }
|
||||
if (Array.isArray(eintrag)) eintrag = eintrag.length > 1 ? eintrag.shift() : eintrag[0]
|
||||
if (typeof eintrag === 'function') eintrag = eintrag(aufruf)
|
||||
const { status = 200, body = {} } = eintrag ?? { status: 404, body: { error: `nicht vorgesehen: ${aufruf.method} ${aufruf.url}` } }
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Ansicht öffnen (Adresse mit hash, Tabelle der Antworten) und warten, bis auch die Reiter gezeichnet sind. => Wurzel der Ansicht. */
|
||||
async function oeffneAnsicht(fx, { projectId = 'p1', hash = '', tabelle }) {
|
||||
routen(fx, tabelle)
|
||||
fx.window.location.hash = hash
|
||||
const wurzel = h('div')
|
||||
document.body.append(wurzel)
|
||||
await huelle.initTermine(wurzel, { projectId })
|
||||
await fx.ruhe()
|
||||
return wurzel
|
||||
}
|
||||
|
||||
const projektEintrag = (id, state, extra = {}) => ({ projectId: id, projectName: `Website ${id}`, liveUrl: '', features: { news: { state: 'off' }, termine: { state, ...extra } } })
|
||||
const GEORDERT = { orderedAt: '2026-10-05T10:00:00.000Z', ticketWoid: null, invoiceUrl: null }
|
||||
const funktionenAntwort = (projekte, termine = {}) => ({
|
||||
body: { projects: projekte, termine: { verfuegbar: true, bestellbar: true, grund: null, preis: '149 €', einrichtung: EINRICHTUNG, hinweis: null, ...termine } },
|
||||
})
|
||||
const ohneAbo = (grund, hinweis = SERVER_HINWEIS) => ({ bestellbar: false, grund, hinweis })
|
||||
const ABGESCHALTET = { grund: 'abo_ende', seit: '2026-10-04T10:00:00.000Z', loeschungAm: '2026-11-03' }
|
||||
|
||||
/** Antwort von GET /api/termine/einstellungen im jeweiligen Zustand (off: Entwurf mit Abo, ordered: dazu die Sicht der Bestellung ohne Abo). */
|
||||
async function ansichtDaten(state, extra = {}) {
|
||||
const basis = { ...fbDaten({ state }), abo: null, offen: null, bestellung: null, feeds: {}, anfragenOffen: 0, aktivSeit: null, abgeschaltet: null }
|
||||
if (state === 'off') return { ...basis, ...fbDaten(), ...extra }
|
||||
if (state === 'ordered') return { ...basis, offen: [], bestellung: await bestellungTest(), ...extra }
|
||||
if (state === 'active') return { ...basis, aktivSeit: '2026-10-01T09:00:00.000Z', einbau: { snippet: '<script></script>' }, ...extra }
|
||||
if (state === 'disabled') return { ...basis, aktivSeit: '2026-10-01T09:00:00.000Z', abgeschaltet: ABGESCHALTET, ...extra }
|
||||
return { ...basis, ...extra }
|
||||
}
|
||||
const reiterTexte = (fx, wurzel) => fx.alle(wurzel, '[role="tab"]').map((tab) => fx.text(tab))
|
||||
const meldungDerHuelle = (fx, wurzel) => fx.text(fx.finde(wurzel, '.tm-meldung'))
|
||||
/** Antwort-Tabelle für ein Projekt p1 im Zustand state mit den Daten daten. */
|
||||
async function einProjekt(state, { eintrag = {}, daten = {}, termine = {}, tabelle = {} } = {}) {
|
||||
return {
|
||||
[AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', state, eintrag)], termine),
|
||||
[an('p1')]: { body: await ansichtDaten(state, daten) },
|
||||
...tabelle,
|
||||
}
|
||||
}
|
||||
|
||||
test('Ansicht off mit laufendem Abo: der Fragebogen (Fortschrittsleiste im Baum), weder Hinweis noch Reiter', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { fragebogen: fbBis(1) } }) })
|
||||
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt ol.tm-schritte'), 'renderFragebogen hat gezeichnet')
|
||||
assert.equal(fbSchritt(fx, wurzel), 'wer', 'Start beim ersten offenen Schritt des Entwurfs')
|
||||
assert.ok(fx.finde(wurzel, '[data-hinweis="abo"]') === null)
|
||||
assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
|
||||
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
|
||||
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')])
|
||||
}))
|
||||
|
||||
test('Ansicht off: ohne Entwurf die Branchenwahl; die Meldung der Hülle (ctx.meldung) steht über dem Fragebogen', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', {
|
||||
daten: { einstellung: null, version: 0, fragebogen: leerFragebogen(), betriebVorschlag: BETRIEB_KONTO },
|
||||
tabelle: {
|
||||
'POST /api/termine/einstellungen/vorlage': { status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } },
|
||||
[an('p1')]: [{ body: fbStart() }, { body: fbDaten({ version: 1, fragebogen: fbBis(0) }) }],
|
||||
'PUT /api/termine/fragebogen': fbAntwort(1),
|
||||
},
|
||||
}) })
|
||||
assert.equal(fbSchritt(fx, wurzel), 'branche')
|
||||
assert.equal(meldungDerHuelle(fx, wurzel), '')
|
||||
await fx.klick(fbRadio(fx, wurzel, 'salon'))
|
||||
await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]'))
|
||||
assert.equal(fbSchritt(fx, wurzel), 'wer')
|
||||
assert.match(meldungDerHuelle(fx, wurzel), /^Die Branche „Friseur und Salon“ ist angelegt\./, 'ohne meldungEl bliebe die Meldung stumm')
|
||||
assert.equal(fx.finde(wurzel, '.tm-meldung').getAttribute('role'), 'status')
|
||||
}))
|
||||
|
||||
test('Ansicht off ohne laufendes Abo: der Hinweis vom Server (kein_abo mit Link zu Verträge), kein Fragebogen, keine weitere Abfrage', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'kein_abo' } }, termine: ohneAbo('kein_abo') }) })
|
||||
const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]')
|
||||
assert.ok(karteEl)
|
||||
assert.ok(fx.text(karteEl).includes(SERVER_HINWEIS), 'der Text des Servers, unverändert')
|
||||
const link = fx.alle(karteEl, 'button')
|
||||
assert.deepEqual(link.map((b) => [fx.text(b), b.getAttribute('data-view'), b.getAttribute('class'), b.getAttribute('type')]), [['Zu Verträge', 'vertraege', 'kb-link', 'button']])
|
||||
assert.equal(fx.text(fx.finde(karteEl, 'p')), `${SERVER_HINWEIS} Zu Verträge`, 'Text, Leerzeichen, Link wie in der Kachel')
|
||||
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen')
|
||||
assert.equal(fx.alle(wurzel, 'input').length, 0)
|
||||
assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
|
||||
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')])
|
||||
}))
|
||||
|
||||
test('Ansicht off ohne Abo: gekündigt, nicht aktiv, nicht lesbar und fehlendes abo zeigen nur den Text des Servers, ohne Link', mitDom(undefined, async (fx) => {
|
||||
const faelle = [
|
||||
['gekuendigt', { ok: false, grund: 'gekuendigt' }, ohneAbo('gekuendigt', 'Server: Dein Wartungs-Abo ist gekündigt.')],
|
||||
['nicht_aktiv', { ok: false, grund: 'nicht_aktiv' }, ohneAbo('nicht_aktiv', 'Server: Dein Wartungs-Abo ist gerade nicht aktiv.')],
|
||||
['Lesefehler (abo null)', null, { bestellbar: null, grund: null, hinweis: 'Server: Das Abo konnte gerade nicht geprüft werden.' }],
|
||||
['abo fehlt in der Antwort', undefined, ohneAbo('gekuendigt', 'Server: ohne abo.')],
|
||||
]
|
||||
for (const [name, abo, termine] of faelle) {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo }, termine }) })
|
||||
const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]')
|
||||
assert.ok(karteEl, name)
|
||||
assert.equal(fx.text(fx.finde(karteEl, 'p')), termine.hinweis, name)
|
||||
assert.equal(fx.alle(karteEl, 'button').length, 0, `${name}: kein Link`)
|
||||
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, name)
|
||||
}
|
||||
}))
|
||||
|
||||
test('Ansicht off ohne Abo: der Hinweis kommt maskiert in die Seite und der Browser kennt keinen eigenen Text', mitDom(undefined, async (fx) => {
|
||||
const bose = '<img src=x onerror=alert(1)> & "Anführungszeichen"'
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt', bose) }) })
|
||||
assert.equal(fx.alle(wurzel, 'img').length, 0)
|
||||
assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="abo"] p')), bose)
|
||||
}))
|
||||
|
||||
test('Ansicht off ohne Abo: fehlt der Text in der Liste (das Abo hat sich seit dem Laden geändert), wird die Liste noch einmal geholt', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', {
|
||||
daten: { abo: { ok: false, grund: 'kein_abo' } },
|
||||
tabelle: { [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'off')]), funktionenAntwort([projektEintrag('p1', 'off')], ohneAbo('kein_abo'))] },
|
||||
}) })
|
||||
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_FUNKTIONEN])
|
||||
assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="abo"] p')), `${SERVER_HINWEIS} Zu Verträge`)
|
||||
// bleibt der Text auch dann aus, steht nur die Überschrift (kein erfundener Ersatztext)
|
||||
const leer = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } } }) })
|
||||
assert.ok(fx.finde(leer, '[data-hinweis="abo"] p') === null)
|
||||
assert.equal(fx.text(fx.finde(leer, '[data-hinweis="abo"] h3')), 'Terminplaner einrichten')
|
||||
}))
|
||||
|
||||
test('Ansicht off: in der Kundenansicht gilt dasselbe (Fragebogen mit Hinweis der Kundenansicht, ohne Abo der Hinweis)', mitDom(undefined, async (fx) => {
|
||||
const mit = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { kundenansicht: true } }) })
|
||||
assert.match(fx.text(fx.finde(mit, '.tm-box[role="note"]')), /Kundenansicht/)
|
||||
const ohne = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { kundenansicht: true, abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt') }) })
|
||||
assert.ok(fx.finde(ohne, '[data-hinweis="abo"]'))
|
||||
assert.ok(fx.finde(ohne, 'nav.tm-fortschritt') === null)
|
||||
}))
|
||||
|
||||
test('Ansicht active ohne Einstellung: weiter die Vorlagenauswahl von TP1 (kein Fragebogen), mit Einstellung die vier Reiter', mitDom(undefined, async (fx) => {
|
||||
const ohne = await oeffneAnsicht(fx, { tabelle: await einProjekt('active', { daten: { einstellung: null, version: 0, fragebogen: null } }) })
|
||||
assert.equal(fx.alle(ohne, 'input[name="termine-vorlage"]').length, 1)
|
||||
assert.ok(fx.text(ohne).includes('Womit möchtest du starten?'))
|
||||
assert.ok(fx.finde(ohne, 'nav.tm-fortschritt') === null)
|
||||
assert.ok(fx.finde(ohne, '.tm-reiterleiste') === null)
|
||||
const mit = await oeffneAnsicht(fx, { tabelle: await einProjekt('active') })
|
||||
assert.deepEqual(reiterTexte(fx, mit), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
|
||||
assert.ok(fx.finde(mit, '.tm-statuskarte') === null)
|
||||
assert.ok(fx.finde(mit, '.tm-hinweise .tm-box-warn') === null, 'weder Abschaltung noch fehlende Angaben')
|
||||
}))
|
||||
|
||||
test('Ansicht active: fehlende Pflichtangaben zeigen den Hinweis „Noch nicht buchbar“ weiter wie bisher', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('active', { daten: { fehlt: ['betrieb.telefon'] } }) })
|
||||
assert.match(fx.text(fx.finde(wurzel, '.tm-hinweise .tm-box-warn')), /Noch nicht buchbar/)
|
||||
}))
|
||||
|
||||
for (const [state, pille, ticket] of [['ordered', 'Zahlung offen', null], ['setup', 'In Einrichtung', 100342]]) {
|
||||
test(`Ansicht ${state}: Statuskarte „${pille}“ und die Reiter Leistungen, Team & Plätze, Einstellungen nur zum Lesen, ohne Reiter „Termine“`, mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt(state, {
|
||||
eintrag: { ...GEORDERT, ticketWoid: ticket, invoiceUrl: state === 'setup' ? 'https://invoice.stripe.test/i/in_1' : null },
|
||||
daten: { fehlt: ['betrieb.telefon'], aktivSeit: null },
|
||||
}) })
|
||||
const karteEl = fx.finde(wurzel, '.tm-statuskarte')
|
||||
assert.ok(karteEl)
|
||||
assert.equal(karteEl.getAttribute('data-state'), state)
|
||||
assert.equal(fx.text(fx.finde(karteEl, '.tm-pille')), pille)
|
||||
assert.match(fx.text(karteEl), /Bestellt am\s*05\.10\.2026/, 'Bestelldatum aus dem Eintrag der Liste')
|
||||
if (state === 'setup') {
|
||||
assert.match(fx.text(karteEl), /Ticketnummer\s*#100342/)
|
||||
assert.match(fx.text(karteEl), new RegExp(`Zeiteinschätzung\\s*${EINRICHTUNG}`), 'Einrichtungsdauer aus GET /api/funktionen')
|
||||
}
|
||||
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen')
|
||||
assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'])
|
||||
assert.ok(fx.finde(wurzel, '#tm-tab-termine') === null)
|
||||
assert.ok(fx.finde(wurzel, '.tm-hinweise .tm-box-warn') === null, 'weder Banner der Abschaltung noch „Noch nicht buchbar“')
|
||||
// jeder Reiter ist schreibgeschützt: Felder gesperrt, keine Knöpfe zum Ändern
|
||||
for (const [key, ohneKnopf] of [['leistungen', '[data-werkzeug="leistung-neu"]'], ['team', '[data-werkzeug="ressource-neu"]'], ['einstellungen', 'button[type="submit"]']]) {
|
||||
await fx.klick(fx.finde(wurzel, `#tm-tab-${key}`))
|
||||
await fx.ruhe()
|
||||
const panel = fx.finde(wurzel, '#tm-panel')
|
||||
assert.ok(fx.finde(panel, ohneKnopf) === null, `${key}: nichts zum Ändern`)
|
||||
assert.ok(fx.finde(panel, 'fieldset[disabled]') || key !== 'einstellungen', `${key}: gesperrt`)
|
||||
assert.ok(!fx.text(panel).includes('null'), `${key}: kein „null“ als Text (replaceChildren mit null)`)
|
||||
}
|
||||
// „Export und Status“ des Reiters Einstellungen sagt vor der Freischaltung nicht „Aktiv“
|
||||
assert.ok(fx.finde(fx.finde(wurzel, '#tm-panel'), '.tm-status') === null)
|
||||
assert.equal(fx.alle(fx.finde(wurzel, '#tm-panel'), 'a').length, 0, 'noch nichts zum Exportieren')
|
||||
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')], 'der Reiter „Termine“ wird nicht gelesen (der Server antwortete mit 409)')
|
||||
}))
|
||||
}
|
||||
|
||||
test('Ansicht ordered/setup: die Pfeiltasten wandern nur durch die drei Reiter, ein gemerkter Reiter „Termine“ fällt auf den ersten zurück und bleibt für andere Websites gemerkt', mitDom(undefined, async (fx) => {
|
||||
const tabelle = {
|
||||
[AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'active'), projektEintrag('p2', 'ordered', GEORDERT)]),
|
||||
[an('p1')]: { body: await ansichtDaten('active') },
|
||||
[an('p2')]: { body: await ansichtDaten('ordered') },
|
||||
}
|
||||
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', tabelle })
|
||||
await fx.klick(fx.finde(wurzel, '#tm-tab-termine'))
|
||||
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p2')
|
||||
await fx.ruhe()
|
||||
assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
|
||||
assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'])
|
||||
assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen', 'der erste Reiter, den es gibt')
|
||||
await fx.sende(fx.finde(wurzel, '.tm-reiterleiste'), 'keydown', { key: 'ArrowLeft' })
|
||||
assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'einstellungen', 'Pfeil links vom ersten Reiter springt zum letzten')
|
||||
await fx.sende(fx.finde(wurzel, '.tm-reiterleiste'), 'keydown', { key: 'ArrowRight' })
|
||||
assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen')
|
||||
// zurück zur aktiven Website: der zuvor gewählte Reiter „Termine“ ist wieder da
|
||||
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1')
|
||||
await fx.ruhe()
|
||||
assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen', 'die Wahl in p2 gilt ab jetzt')
|
||||
}))
|
||||
|
||||
test('Ansicht ordered in der Kundenansicht: „Zahlung fortsetzen“ ist gesperrt', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { eintrag: GEORDERT, daten: { kundenansicht: true } }) })
|
||||
assert.equal(fx.finde(wurzel, '[data-aktion="fortsetzen"]').disabled, true)
|
||||
}))
|
||||
|
||||
test('Ansicht ordered/setup ohne Einstellung: nur die Statuskarte, keine Reiter', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('setup', { eintrag: GEORDERT, daten: { einstellung: null, version: 0 } }) })
|
||||
assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
|
||||
assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
|
||||
assert.ok(!fx.text(wurzel).includes('null'))
|
||||
}))
|
||||
|
||||
test('Ansicht disabled: Banner der Abschaltung (hinweisAbgeschaltet) mit Löschdatum aus abgeschaltet.loeschungAm und Export-Link, alle vier Reiter, unverändert (E11)', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('disabled') })
|
||||
const text = ansicht.abschaltText(ABGESCHALTET)
|
||||
const banner = fx.finde(wurzel, '.tm-hinweise .tm-box-warn')
|
||||
assert.ok(banner)
|
||||
assert.equal(fx.text(banner), `${text.davor}exportieren${text.danach}`)
|
||||
assert.match(fx.text(banner), /03\.11\.2026|2026-11-03/, 'das Löschdatum aus loeschungAm')
|
||||
assert.deepEqual(fx.alle(banner, 'a').map((a) => [fx.text(a), a.getAttribute('href')]), [['exportieren', '/api/termine/export.csv?projectId=p1']])
|
||||
assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
|
||||
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
|
||||
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null)
|
||||
await fx.klick(fx.finde(wurzel, '#tm-tab-einstellungen'))
|
||||
await fx.ruhe()
|
||||
assert.match(fx.text(fx.finde(wurzel, '.tm-status')), /Abgeschaltet/, 'Export und Status bleiben in disabled')
|
||||
}))
|
||||
|
||||
test('Ansicht disabled ohne Einstellung: Banner und „Keine Termine eingerichtet“ wie bisher', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('disabled', { daten: { einstellung: null, version: 0 } }) })
|
||||
assert.ok(fx.finde(wurzel, '.tm-box-warn[role="note"]'))
|
||||
assert.ok(fx.text(wurzel).includes('Keine Termine eingerichtet'))
|
||||
assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null)
|
||||
}))
|
||||
|
||||
test('Ansicht: der Zustand der Daten gilt, auch wenn die Liste einen anderen kennt (Liste: off, Daten: active)', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: { [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'off')]), [an('p1')]: { body: await ansichtDaten('active') } } })
|
||||
assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
|
||||
}))
|
||||
|
||||
test('Ansicht ordered: kennt die Liste einen älteren Zustand als die Daten (nach der Zahlung), wird sie neu geholt und die Karte zeigt die Ticketnummer', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: {
|
||||
[AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'ordered', GEORDERT)]), funktionenAntwort([projektEintrag('p1', 'setup', { ...GEORDERT, ticketWoid: 100342 })])],
|
||||
[an('p1')]: { body: await ansichtDaten('setup') },
|
||||
} })
|
||||
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_FUNKTIONEN])
|
||||
assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'In Einrichtung')
|
||||
assert.match(fx.text(fx.finde(wurzel, '.tm-statuskarte')), /Ticketnummer\s*#100342/)
|
||||
// lässt sich die Liste nicht holen, gilt der Zustand der Daten, ohne Ticket
|
||||
const ohne = await oeffneAnsicht(fx, { tabelle: {
|
||||
[AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'ordered', GEORDERT)]), { status: 500, body: {} }],
|
||||
[an('p1')]: { body: await ansichtDaten('setup') },
|
||||
} })
|
||||
assert.equal(fx.text(fx.finde(ohne, '.tm-statuskarte .tm-pille')), 'In Einrichtung')
|
||||
assert.match(fx.text(fx.finde(ohne, '.tm-statuskarte')), /Wir legen dein Ticket gerade an\./)
|
||||
assert.ok(fx.finde(ohne, '[data-aktion="fortsetzen"]') === null, 'kein „Zahlung fortsetzen“ für eine bezahlte Bestellung')
|
||||
}))
|
||||
|
||||
test('Ansicht: ein Fehler beim Laden zeigt die Fehlerkarte mit „Noch einmal versuchen“, das lädt neu', mitDom(undefined, async (fx) => {
|
||||
const tabelle = await einProjekt('off', { tabelle: { [an('p1')]: [{ status: 500, body: { error: 'Der Server hat gerade Pause.' } }, { body: await ansichtDaten('off') }] } })
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle })
|
||||
assert.equal(fx.text(fx.finde(wurzel, '[role="alert"] p')), 'Der Server hat gerade Pause.')
|
||||
await fx.klick(fx.finde(wurzel, '[role="alert"] button'))
|
||||
await fx.ruhe()
|
||||
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'))
|
||||
}))
|
||||
|
||||
// ------------------------------------------------------- Rückkehr von Stripe
|
||||
const zweiProjekte = async (zustandP2 = 'ordered', extraP2 = GEORDERT) => ({
|
||||
[AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'off'), projektEintrag('p2', zustandP2, extraP2)]),
|
||||
[an('p1')]: { body: await ansichtDaten('off') },
|
||||
[an('p2')]: { body: await ansichtDaten(zustandP2) },
|
||||
})
|
||||
/** Aufrufe von history.replaceState mitschreiben. */
|
||||
function merkeAdressen(fx) {
|
||||
const adressen = []
|
||||
const echt = fx.window.history.replaceState
|
||||
fx.window.history.replaceState = (zustand, titel, url) => { adressen.push(url); echt(zustand, titel, url) }
|
||||
return adressen
|
||||
}
|
||||
|
||||
test('Rückkehr nach der Zahlung: #termine?bestellt=1&projekt=p2 wählt p2, zeigt „Danke!“, bereinigt die Adresse (einmal gelesen)', mitDom(undefined, async (fx) => {
|
||||
const adressen = merkeAdressen(fx)
|
||||
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte() })
|
||||
assert.equal(fx.finde(wurzel, '#termine-project').value, 'p2', 'das Projekt der Rückkehr (vor dem gemerkten p1)')
|
||||
assert.equal(fx.text(fx.finde(wurzel, '[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.')
|
||||
assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'Zahlung offen')
|
||||
assert.deepEqual(adressen, ['/dashboard.html#termine'], 'Pfad und Query bleiben, der Hash bis auf #termine weg')
|
||||
assert.equal(fx.window.location.hash, '#termine')
|
||||
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p2')], 'nur das Projekt der Rückkehr wird geladen')
|
||||
// ein zweiter Aufruf (Hash ist bereinigt) kennt weder Rückkehr noch Danke, behält aber die Auswahl
|
||||
const wieder = await oeffneAnsicht(fx, { projectId: '', hash: fx.window.location.hash, tabelle: await zweiProjekte() })
|
||||
assert.ok(fx.finde(wieder, '[data-danke]') === null)
|
||||
assert.equal(fx.finde(wieder, '#termine-project').value, 'p2')
|
||||
}))
|
||||
|
||||
test('Rückkehr nach der Zahlung, wenn der Webhook schon durch ist (setup): „Danke!“ auch dann', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte('setup', { ...GEORDERT, ticketWoid: 100342 }) })
|
||||
assert.ok(fx.finde(wurzel, '[data-danke]'))
|
||||
assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'In Einrichtung')
|
||||
}))
|
||||
|
||||
test('Rückkehr nach dem Abbruch: #termine?projekt=p2 wählt p2 ohne „Danke!“, die Adresse ist bereinigt; „Zahlung fortsetzen“ ist da', mitDom(undefined, async (fx) => {
|
||||
const adressen = merkeAdressen(fx)
|
||||
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?projekt=p2', tabelle: await zweiProjekte() })
|
||||
assert.equal(fx.finde(wurzel, '#termine-project').value, 'p2')
|
||||
assert.ok(fx.finde(wurzel, '[data-danke]') === null)
|
||||
assert.ok(!fx.text(wurzel).includes('Danke!'))
|
||||
assert.ok(fx.finde(wurzel, '[data-aktion="fortsetzen"]'))
|
||||
assert.deepEqual(adressen, ['/dashboard.html#termine'])
|
||||
assert.equal(fx.window.location.hash, '#termine')
|
||||
}))
|
||||
|
||||
test('Rückkehr: bestellt=1 ohne Projekt gilt dem gewählten Projekt; ein unbekanntes Projekt wird ignoriert (kein „Danke!“ für ein anderes)', mitDom(undefined, async (fx) => {
|
||||
const ohneProjekt = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1', tabelle: await zweiProjekte() })
|
||||
assert.ok(fx.finde(ohneProjekt, '[data-danke]'))
|
||||
assert.equal(fx.window.location.hash, '#termine')
|
||||
const unbekannt = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1&projekt=gibtsnicht', tabelle: await zweiProjekte() })
|
||||
assert.ok(fx.finde(unbekannt, '[data-danke]') === null)
|
||||
assert.equal(fx.finde(unbekannt, '#termine-project').value, 'p2', 'es bleibt bei der gewählten Website')
|
||||
assert.equal(fx.window.location.hash, '#termine')
|
||||
}))
|
||||
|
||||
test('Rückkehr: „Danke!“ nur für das Projekt der Zahlung und nur beim ersten Zeichnen, nicht nach einem Wechsel der Website', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte() })
|
||||
assert.ok(fx.finde(wurzel, '[data-danke]'))
|
||||
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1')
|
||||
await fx.ruhe()
|
||||
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'p1 ist die Website im Zustand off')
|
||||
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p2')
|
||||
await fx.ruhe()
|
||||
assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
|
||||
assert.ok(fx.finde(wurzel, '[data-danke]') === null, 'der Dank war einmal')
|
||||
}))
|
||||
|
||||
test('Rückkehr: bestellt=1 für ein Projekt, das nicht bestellt ist (active, off), zeigt kein „Danke!“; fremde Hashes werden nicht angefasst', mitDom(undefined, async (fx) => {
|
||||
const adressen = merkeAdressen(fx)
|
||||
const aktiv = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte('active', {}) })
|
||||
assert.ok(fx.finde(aktiv, '[data-danke]') === null)
|
||||
assert.ok(!fx.text(aktiv).includes('Danke!'))
|
||||
assert.equal(adressen.length, 1)
|
||||
const fremd = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#news?bestellt=1&projekt=p2', tabelle: await zweiProjekte() })
|
||||
assert.ok(fx.finde(fremd, '[data-danke]') === null, 'die Rückkehr der News gehört nicht hierher')
|
||||
assert.equal(adressen.length, 1, 'und ihr Hash bleibt stehen')
|
||||
assert.equal(fx.window.location.hash, '#news?bestellt=1&projekt=p2')
|
||||
}))
|
||||
|
||||
test('Rückkehr: Projekt-ID aus der Adresse landet nur als Wert in der Anfrage (codiert), nie im Baum', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=%3Cimg%20src%3Dx%3E', tabelle: await zweiProjekte() })
|
||||
assert.equal(fx.alle(wurzel, 'img').length, 0)
|
||||
assert.ok(!AUFRUFE(fx).some((a) => a.includes('<img')))
|
||||
}))
|
||||
|
||||
// ------------------------------------------------------- „Zahlung fortsetzen“ scheitert: Ansicht neu aufbauen (Ruling R110)
|
||||
const ORDERED_P1 = () => projektEintrag('p1', 'ordered', GEORDERT)
|
||||
const fortsetzenKnopf = (fx, wurzel) => fx.finde(wurzel, '[data-aktion="fortsetzen"]')
|
||||
|
||||
test('R110: 409 zustimmung_fehlt ⇒ Liste und Daten werden neu geholt und das Bestellformular steht an der Stelle der Karte', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
|
||||
eintrag: GEORDERT,
|
||||
tabelle: { [AN_CHECKOUT]: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } },
|
||||
}) })
|
||||
assert.ok(fx.finde(wurzel, '.tm-statuskarte'))
|
||||
assert.ok(fx.finde(wurzel, '.tm-bestellen') === null)
|
||||
await fx.klick(fortsetzenKnopf(fx, wurzel))
|
||||
await fx.ruhe()
|
||||
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_CHECKOUT, AN_FUNKTIONEN, an('p1')])
|
||||
assert.deepEqual(fx.fetchLog[2].body, { projectId: 'p1', fortsetzen: true })
|
||||
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null, 'die Karte ist weg')
|
||||
const seite = fx.finde(wurzel, '.tm-bestellen')
|
||||
assert.ok(seite, 'das Bestellformular')
|
||||
assert.equal(fx.text(bestellKnopf(fx, wurzel)), (await bestellungTest()).consent.knopf, 'Knopftext aus der Antwort des Servers')
|
||||
assert.equal(bestellKnopf(fx, wurzel).disabled, true, 'gesperrt, bis alle Haken stehen')
|
||||
assert.equal(hakenFelder(fx, seite).length, registerHaken().length)
|
||||
assert.equal(meldungDerHuelle(fx, wurzel), 'Bitte stimme der Erklärung zu, um zu bestellen.')
|
||||
assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'die Reiter bleiben, nur lesbar')
|
||||
// „Ändern“ führt nicht in den (gesperrten) Fragebogen, sondern erklärt, warum nicht
|
||||
await fx.klick(fx.finde(wurzel, '[data-aktion="aendern"]'))
|
||||
assert.match(meldungDerHuelle(fx, wurzel), /seit der Bestellung gesperrt/)
|
||||
// nach dem Setzen aller Haken geht die neue Zustimmung an den Checkout und es folgt die Zahlungsseite
|
||||
routen(fx, { [AN_CHECKOUT]: { body: { url: KNOPF_URL } } })
|
||||
await alleAnhaken(fx, seite)
|
||||
assert.equal(bestellKnopf(fx, wurzel).disabled, false)
|
||||
await fx.klick(bestellKnopf(fx, wurzel))
|
||||
const bestellt = fx.fetchLog.at(-1)
|
||||
assert.equal(bestellt.url, '/api/funktionen/termine/checkout')
|
||||
assert.equal(bestellt.body.zustimmung.version, (await bestellungTest()).consent.version)
|
||||
assert.equal(fx.window.location.href, KNOPF_URL)
|
||||
}))
|
||||
|
||||
test('R110: 400 (die Bestellung ist inzwischen bezahlt, der Server kennt sie nicht mehr als offen) ⇒ die Ansicht folgt dem neuen Zustand, die Karte steht nicht veraltet da', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
|
||||
eintrag: GEORDERT,
|
||||
tabelle: {
|
||||
[AN_CHECKOUT]: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
|
||||
[AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), funktionenAntwort([projektEintrag('p1', 'setup', { ...GEORDERT, ticketWoid: 100342 })])],
|
||||
[an('p1')]: [{ body: await ansichtDaten('ordered') }, { body: await ansichtDaten('setup') }],
|
||||
},
|
||||
}) })
|
||||
const alteKarte = fx.finde(wurzel, '.tm-statuskarte')
|
||||
await fx.klick(fortsetzenKnopf(fx, wurzel))
|
||||
await fx.ruhe()
|
||||
const karteEl = fx.finde(wurzel, '.tm-statuskarte')
|
||||
assert.ok(karteEl !== alteKarte, 'neu gezeichnet')
|
||||
assert.equal(fx.text(fx.finde(karteEl, '.tm-pille')), 'In Einrichtung')
|
||||
assert.match(fx.text(karteEl), /Ticketnummer\s*#100342/)
|
||||
assert.ok(fortsetzenKnopf(fx, wurzel) === null)
|
||||
assert.ok(fx.finde(wurzel, '.tm-bestellen') === null, 'kein Bestellformular für eine bezahlte Bestellung')
|
||||
assert.equal(meldungDerHuelle(fx, wurzel), '', 'der Fehler des alten Zustands steht nicht in der neuen Karte')
|
||||
assert.ok(!fx.text(wurzel).includes('stimme der Erklärung'))
|
||||
}))
|
||||
|
||||
test('R110: ist die Funktion nach dem Webhook aktiv, zeigt die Ansicht die vier Reiter statt der Karte', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
|
||||
eintrag: GEORDERT,
|
||||
tabelle: {
|
||||
[AN_CHECKOUT]: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
|
||||
[AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), funktionenAntwort([projektEintrag('p1', 'active')])],
|
||||
[an('p1')]: [{ body: await ansichtDaten('ordered') }, { body: await ansichtDaten('active') }],
|
||||
},
|
||||
}) })
|
||||
await fx.klick(fortsetzenKnopf(fx, wurzel))
|
||||
await fx.ruhe()
|
||||
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
|
||||
assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'])
|
||||
assert.equal(meldungDerHuelle(fx, wurzel), '')
|
||||
}))
|
||||
|
||||
test('R110: ein anderer Fehler bei noch offener Bestellung (409, die Sitzung läuft schon) ⇒ die Karte wird neu gezeichnet und der Fehler steht am Knopf', mitDom(undefined, async (fx) => {
|
||||
const FEHLER = 'Deine Zahlung läuft gerade oder ist schon da. Wir richten den Terminplaner ein, sobald sie bei uns ist.'
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
|
||||
eintrag: GEORDERT,
|
||||
tabelle: { [AN_CHECKOUT]: { status: 409, body: { error: FEHLER } } },
|
||||
}) })
|
||||
const alteKarte = fx.finde(wurzel, '.tm-statuskarte')
|
||||
await fx.klick(fortsetzenKnopf(fx, wurzel))
|
||||
await fx.ruhe()
|
||||
assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_CHECKOUT, AN_FUNKTIONEN, an('p1')])
|
||||
const karteEl = fx.finde(wurzel, '.tm-statuskarte')
|
||||
assert.ok(karteEl !== alteKarte)
|
||||
assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), FEHLER)
|
||||
assert.equal(fortsetzenKnopf(fx, wurzel).disabled, false, 'der Knopf ist frei')
|
||||
assert.ok(fx.finde(wurzel, '.tm-bestellen') === null)
|
||||
}))
|
||||
|
||||
test('R110: lässt sich die Ansicht nicht neu laden, bleibt die Karte stehen und der Fehler steht am Knopf (bei fehlender Zustimmung der Grund des Neuladens)', mitDom(undefined, async (fx) => {
|
||||
const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', {
|
||||
eintrag: GEORDERT,
|
||||
tabelle: {
|
||||
[AN_CHECKOUT]: [{ status: 409, body: { error: 'Deine Zahlung läuft gerade.' } }, { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }],
|
||||
[AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), { status: 500, body: { error: 'Der Server hat gerade Pause.' } }],
|
||||
},
|
||||
}) })
|
||||
const karteEl = fx.finde(wurzel, '.tm-statuskarte')
|
||||
await fx.klick(fortsetzenKnopf(fx, wurzel))
|
||||
await fx.ruhe()
|
||||
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === karteEl, 'dieselbe Karte')
|
||||
assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft gerade.')
|
||||
assert.equal(fortsetzenKnopf(fx, wurzel).disabled, false)
|
||||
await fx.klick(fortsetzenKnopf(fx, wurzel))
|
||||
await fx.ruhe()
|
||||
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === karteEl)
|
||||
assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), 'Der Server hat gerade Pause.')
|
||||
assert.ok(fx.finde(wurzel, '.tm-bestellen') === null)
|
||||
}))
|
||||
|
||||
test('R110: wechselt der Nutzer die Website, während neu geladen wird, zeichnet die späte Antwort nichts mehr', mitDom(undefined, async (fx) => {
|
||||
let freigeben
|
||||
const spaet = new Promise((fertig) => { freigeben = fertig })
|
||||
const tabelle = { ...(await zweiProjekte()), [AN_CHECKOUT]: { status: 409, body: { error: 'Deine Zahlung läuft gerade.' } } }
|
||||
const wurzel = await oeffneAnsicht(fx, { projectId: 'p2', tabelle })
|
||||
// ab jetzt hängt das Laden der Daten von p2, bis der Test es freigibt
|
||||
const antwort = globalThis.fetch
|
||||
globalThis.fetch = async (url, optionen) => {
|
||||
if (String(url) !== '/api/termine/einstellungen?projectId=p2') return antwort(url, optionen)
|
||||
fx.fetchLog.push({ method: 'GET', url: String(url), body: undefined })
|
||||
await spaet
|
||||
return { ok: true, status: 200, json: async () => structuredClone(await ansichtDaten('setup')) }
|
||||
}
|
||||
const klick = fx.klick(fortsetzenKnopf(fx, wurzel))
|
||||
await fx.ruhe()
|
||||
await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1')
|
||||
await fx.ruhe()
|
||||
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'p1 steht da')
|
||||
freigeben()
|
||||
await klick
|
||||
await fx.ruhe()
|
||||
assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'die späte Antwort für p2 hat p1 nicht überschrieben')
|
||||
assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null)
|
||||
}))
|
||||
@@ -0,0 +1,353 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
|
||||
// Freischalt-Hindernisse und Freigabe-Mail des Terminplaners (Task D6, aus B11 herausgeloest):
|
||||
// freischaltHindernis(doc, { abo }) => null | 'kein_abo' | 'keine_einstellung' | 'abo_unbekannt'
|
||||
// (Abo zuerst, dann Einstellung; ein Abo-Lesefehler gibt nie frei),
|
||||
// meldeFreigeschaltet(doc, { attachments }) = K16 an den Kunden (Fehler geloggt, nie geworfen),
|
||||
// der Standardhaken freischaltbar(doc) ist (await freischaltHindernis(doc)) === null,
|
||||
// runActivation ruft meldeFreigeschaltet fuer jedes aktivierte Dokument.
|
||||
// Die Tests von B11 (featureFreischaltung.test.js) laufen unveraendert weiter.
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
termineConfig: schemaKeys('termineConfig'),
|
||||
contractEvents: schemaKeys('contractEvents'),
|
||||
},
|
||||
})
|
||||
after(() => aw.close())
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { mailSink } = await import('./support/mailSink.js')
|
||||
const { aboMemo } = await import('../server/services/aboStatus.js')
|
||||
const reg = await import('../server/services/featureRegistry.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
const { freischaltHindernis, meldeFreigeschaltet } = await import('../server/services/termine/freischaltung.js')
|
||||
|
||||
const mails = mailSink()
|
||||
setMailTransportForTests(mails)
|
||||
after(() => setMailTransportForTests(null))
|
||||
|
||||
const BASE = 'https://project.webklar.com'
|
||||
const JETZT = new Date('2026-10-05T08:00:00.000Z')
|
||||
|
||||
// seedFixtures gibt c1 nur ein Hosting-Abo (sub_h): Termine-Faelle ueberschreiben es mit einem Wartungs-Abo
|
||||
const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch }))
|
||||
const nurHosting = () => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' }))
|
||||
|
||||
let nr = 0
|
||||
/** Ein portalFeatures-Dokument im Zustand 'setup' mit Projekt und geschlossenem Ticket; mit einstellung: true dazu die gespeicherte Einstellung. */
|
||||
function einrichtung(key = 'termine', { customerId = 'c1', einstellung = false } = {}) {
|
||||
nr += 1
|
||||
const id = `f_${key}_${nr}`
|
||||
const projectId = `p_${key}_${nr}`
|
||||
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Projekt ${nr}` })
|
||||
aw.seed('workorders', { $id: `t_${id}`, woid: `WO-${nr}`, status: 'Closed', topic: 'x' })
|
||||
aw.seed('portalFeatures', {
|
||||
$id: id,
|
||||
customerId,
|
||||
projectId,
|
||||
featureKey: key,
|
||||
state: 'setup',
|
||||
enabled: false,
|
||||
siteKey: `siteke${String(nr).padStart(10, 'a')}`.slice(0, 16),
|
||||
ticketId: `t_${id}`,
|
||||
purchaseStatus: 'paid',
|
||||
unlockedByPurchase: true,
|
||||
})
|
||||
if (einstellung) speichere(id, customerId, projectId)
|
||||
return aw.docs('portalFeatures').get(id)
|
||||
}
|
||||
|
||||
function speichere(featureId, customerId = 'c1', projectId = 'p') {
|
||||
aw.seed('termineConfig', {
|
||||
$id: featureId,
|
||||
customerId,
|
||||
projectId,
|
||||
einstellung: JSON.stringify({ betrieb: { name: 'Salon' } }),
|
||||
version: 1,
|
||||
updatedAt: JETZT.toISOString(),
|
||||
updatedBy: 'test',
|
||||
})
|
||||
}
|
||||
|
||||
const k16 = (subject) => mails.sent.filter((m) => m.subject === subject)
|
||||
const geloggt = () => console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n')
|
||||
const STUMM = (t) => {
|
||||
t.mock.method(console, 'error', () => {})
|
||||
t.mock.method(console, 'warn', () => {})
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const col of ['portalFeatures', 'workorders', 'termineConfig', 'subscriptions', 'websiteProjects', 'contractEvents', 'customers']) {
|
||||
for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
|
||||
}
|
||||
seedFixtures(aw)
|
||||
wartung()
|
||||
mails.sent.length = 0
|
||||
config.termine.mode = 'pilot'
|
||||
config.termine.pilotEmails = ['*']
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------ freischaltHindernis ---
|
||||
test('freischaltHindernis: Wartungs-Abo da + Einstellung da => null', async () => {
|
||||
const doc = einrichtung('termine', { einstellung: true })
|
||||
assert.equal(await freischaltHindernis(doc), null)
|
||||
})
|
||||
|
||||
test('freischaltHindernis: kein Wartungs-Abo => kein_abo, auch ohne Einstellung (Abo zuerst)', async () => {
|
||||
nurHosting()
|
||||
const mit = einrichtung('termine', { einstellung: true })
|
||||
const ohne = einrichtung('termine')
|
||||
assert.equal(await freischaltHindernis(mit), 'kein_abo')
|
||||
assert.equal(await freischaltHindernis(ohne), 'kein_abo', 'Abo zuerst: ohne Abo und ohne Einstellung steht kein_abo da')
|
||||
// c3 hat gar kein Abo
|
||||
const c3 = einrichtung('termine', { customerId: 'c3' })
|
||||
assert.equal(await freischaltHindernis(c3), 'kein_abo')
|
||||
})
|
||||
|
||||
test('freischaltHindernis: Abo da, keine Einstellung => keine_einstellung', async () => {
|
||||
const doc = einrichtung('termine')
|
||||
assert.equal(await freischaltHindernis(doc), 'keine_einstellung')
|
||||
speichere(doc.$id, 'c1', doc.projectId)
|
||||
assert.equal(await freischaltHindernis(doc), null, 'mit gespeicherter Einstellung frei')
|
||||
})
|
||||
|
||||
test('freischaltHindernis: Abo-Lesefehler (failNext auf subscriptions) => abo_unbekannt, geloggt, nie null', async (t) => {
|
||||
STUMM(t)
|
||||
const doc = einrichtung('termine', { einstellung: true })
|
||||
aw.failNext({ collection: 'subscriptions', status: 500, message: 'Nachbau: Abo kaputt' })
|
||||
assert.equal(await freischaltHindernis(doc), 'abo_unbekannt')
|
||||
assert.match(geloggt(), new RegExp(`Freischaltung termine \\(${doc.$id}\\) nicht prüfbar: .*Nachbau: Abo kaputt`))
|
||||
// der Fehler war einmalig: die naechste Frage liest wieder und gibt frei
|
||||
assert.equal(await freischaltHindernis(doc), null)
|
||||
})
|
||||
|
||||
test('freischaltHindernis: Abo past_due zaehlt als laufend => null', async () => {
|
||||
wartung({ status: 'past_due' })
|
||||
const doc = einrichtung('termine', { einstellung: true })
|
||||
assert.equal(await freischaltHindernis(doc), null)
|
||||
})
|
||||
|
||||
test('freischaltHindernis: Wechsel-Widerruf (E1: Spiegel Wartung active, juengstes wechsel-Ereignis widerrufen) => kein_abo; ein neueres erledigtes gibt frei', async () => {
|
||||
const wechsel = (n, status) =>
|
||||
aw.seed('contractEvents', {
|
||||
$id: `ev_w${n}`,
|
||||
$createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, n)).toISOString(),
|
||||
customerId: 'c1',
|
||||
subscriptionId: 'sub_h',
|
||||
type: 'wechsel',
|
||||
productKey: 'wartung',
|
||||
details: '{}',
|
||||
status,
|
||||
})
|
||||
const doc = einrichtung('termine', { einstellung: true })
|
||||
wechsel(1, 'widerrufen')
|
||||
assert.equal(await freischaltHindernis(doc), 'kein_abo')
|
||||
wechsel(2, 'erledigt')
|
||||
assert.equal(await freischaltHindernis(doc), null)
|
||||
})
|
||||
|
||||
test('freischaltHindernis: Abo beendet (canceled) => kein_abo', async () => {
|
||||
wartung({ status: 'canceled' })
|
||||
const doc = einrichtung('termine', { einstellung: true })
|
||||
assert.equal(await freischaltHindernis(doc), 'kein_abo')
|
||||
})
|
||||
|
||||
test('freischaltHindernis: ohne Abo wird die Einstellung gar nicht gelesen (ihr Lesefehler stoert dann nicht)', async () => {
|
||||
nurHosting()
|
||||
const doc = einrichtung('termine', { einstellung: true })
|
||||
aw.failNext({ collection: 'termineConfig', status: 500 })
|
||||
assert.equal(await freischaltHindernis(doc), 'kein_abo')
|
||||
// der eingeplante Fehler ist noch offen (nichts hat termineConfig gelesen); er trifft jetzt die Einstellungs-Pruefung
|
||||
wartung()
|
||||
await assert.rejects(() => freischaltHindernis(doc))
|
||||
})
|
||||
|
||||
test('freischaltHindernis: Lesefehler bei der Einstellung wirft (kein Freigeben, der Aufrufer faengt es)', async () => {
|
||||
const doc = einrichtung('termine', { einstellung: true })
|
||||
aw.failNext({ collection: 'termineConfig', status: 500 })
|
||||
await assert.rejects(() => freischaltHindernis(doc))
|
||||
})
|
||||
|
||||
test('freischaltHindernis: fragt das Abo des Kunden des Dokuments; ein eingereichtes abo ersetzt den Standard', async () => {
|
||||
const doc = einrichtung('termine', { customerId: 'c2', einstellung: true })
|
||||
const gefragt = []
|
||||
const stub = (antwort) => ({
|
||||
laeuft: async (customerId) => {
|
||||
gefragt.push(customerId)
|
||||
if (antwort instanceof Error) throw antwort
|
||||
return antwort
|
||||
},
|
||||
})
|
||||
assert.equal(await freischaltHindernis(doc, { abo: stub(true) }), null)
|
||||
assert.equal(await freischaltHindernis(doc, { abo: stub(false) }), 'kein_abo')
|
||||
assert.deepEqual(gefragt, ['c2', 'c2'])
|
||||
})
|
||||
|
||||
test('freischaltHindernis: abo.laeuft wirft => abo_unbekannt (auch bei einem eingereichten abo)', async (t) => {
|
||||
STUMM(t)
|
||||
const doc = einrichtung('termine', { einstellung: true })
|
||||
const abo = {
|
||||
laeuft: async () => {
|
||||
throw new Error('Memo: Ablehnung bleibt')
|
||||
},
|
||||
}
|
||||
assert.equal(await freischaltHindernis(doc, { abo }), 'abo_unbekannt')
|
||||
assert.match(geloggt(), /Memo: Ablehnung bleibt/)
|
||||
})
|
||||
|
||||
test('freischaltHindernis: ein gemeinsamer aboMemo() liest das Abo je Kunde nur einmal; ohne abo liest jeder Aufruf frisch', async () => {
|
||||
const doc = einrichtung('termine', { einstellung: true })
|
||||
aw.resetListCalls()
|
||||
const memo = aboMemo()
|
||||
assert.equal(await freischaltHindernis(doc, { abo: memo }), null)
|
||||
assert.equal(await freischaltHindernis(doc, { abo: memo }), null)
|
||||
assert.equal(aw.listCalls('subscriptions').length, 1, 'Memo: ein Lesen')
|
||||
|
||||
// ohne abo: frisch gelesen, ein Abo, das dazwischen endet, gibt nicht mehr frei
|
||||
assert.equal(await freischaltHindernis(doc), null)
|
||||
wartung({ status: 'canceled' })
|
||||
assert.equal(await freischaltHindernis(doc), 'kein_abo')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------- Delegation des Standardhakens ---
|
||||
test('Standardhaken freischaltbar: true nur bei null, false bei kein_abo / keine_einstellung / abo_unbekannt', async (t) => {
|
||||
STUMM(t)
|
||||
const { freischaltbar } = reg.featureHaken('termine')
|
||||
assert.equal(typeof freischaltbar, 'function')
|
||||
const doc = einrichtung('termine')
|
||||
assert.equal(await freischaltbar(doc), false, 'keine_einstellung')
|
||||
speichere(doc.$id, 'c1', doc.projectId)
|
||||
assert.equal(await freischaltbar(doc), true, 'null')
|
||||
aw.failNext({ collection: 'subscriptions', status: 500 })
|
||||
assert.equal(await freischaltbar(doc), false, 'abo_unbekannt: fail-closed, kein Wurf')
|
||||
wartung({ status: 'canceled' })
|
||||
assert.equal(await freischaltbar(doc), false, 'kein_abo')
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------ meldeFreigeschaltet ---
|
||||
test('meldeFreigeschaltet: K16 mit "Terminplaner" im Betreff und Link #termine, genau eine Mail an den Kunden', async () => {
|
||||
const doc = einrichtung('termine', { einstellung: true })
|
||||
await meldeFreigeschaltet(doc)
|
||||
assert.equal(mails.sent.length, 1, mails.sent.map((m) => m.subject).join(' / '))
|
||||
const [mail] = k16('Dein Terminplaner ist bereit')
|
||||
assert.ok(mail, mails.sent.map((m) => m.subject).join(' / '))
|
||||
assert.match(mail.subject, /Terminplaner/)
|
||||
assert.deepEqual(mail.to.map((t) => t.address), ['k@example.invalid'])
|
||||
assert.match(mail.text, /Hallo Kunde Eins,/)
|
||||
assert.match(mail.text, /Funktion: Terminplaner\nProjekt: Projekt \d+/)
|
||||
assert.ok(mail.text.includes(`Termine öffnen: ${BASE}/dashboard.html#termine`), mail.text)
|
||||
assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/dashboard\.html#termine"/)
|
||||
assert.deepEqual(mail.attachments ?? [], [], 'ohne attachments kein Anhang')
|
||||
})
|
||||
|
||||
test('meldeFreigeschaltet: attachments (die PDFs der Staff-Aktivierung) stehen in der gesendeten Mail', async () => {
|
||||
const doc = einrichtung('termine', { einstellung: true })
|
||||
const pdf = (filename) => ({ filename, content: Buffer.from(`%PDF-1.4\n% ${filename}\n`), contentType: 'application/pdf' })
|
||||
await meldeFreigeschaltet(doc, { attachments: [pdf('Leistungsbeschreibung.pdf'), pdf('AVV.pdf')] })
|
||||
assert.equal(mails.sent.length, 1)
|
||||
const [mail] = mails.sent
|
||||
assert.equal(mail.subject, 'Dein Terminplaner ist bereit')
|
||||
assert.deepEqual(mail.attachments.map((a) => [a.filename, a.contentType]), [
|
||||
['Leistungsbeschreibung.pdf', 'application/pdf'],
|
||||
['AVV.pdf', 'application/pdf'],
|
||||
])
|
||||
for (const a of mail.attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF')
|
||||
})
|
||||
|
||||
test('meldeFreigeschaltet gilt fuer jede Funktion des Registers: News => K16 der News-Pinnwand mit Link #news', async () => {
|
||||
const doc = einrichtung('news')
|
||||
await meldeFreigeschaltet(doc)
|
||||
const [mail] = k16('Deine News-Pinnwand ist bereit')
|
||||
assert.ok(mail, mails.sent.map((m) => m.subject).join(' / '))
|
||||
assert.ok(mail.text.includes(`${BASE}/dashboard.html#news`), mail.text)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
})
|
||||
|
||||
test('meldeFreigeschaltet: Versandfehler => kein Wurf, console.error mit Kunde und Grund', async (t) => {
|
||||
STUMM(t)
|
||||
const doc = einrichtung('termine', { einstellung: true })
|
||||
setMailTransportForTests({
|
||||
sendMail: async () => {
|
||||
throw new Error('SMTP: Postfach voll')
|
||||
},
|
||||
})
|
||||
try {
|
||||
await assert.doesNotReject(() => meldeFreigeschaltet(doc))
|
||||
} finally {
|
||||
setMailTransportForTests(mails)
|
||||
}
|
||||
assert.equal(console.error.mock.calls.length, 1)
|
||||
assert.match(geloggt(), /K16 an Kunde c1 fehlgeschlagen/)
|
||||
assert.match(geloggt(), /SMTP: Postfach voll/)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
})
|
||||
|
||||
test('meldeFreigeschaltet: Kunde ohne E-Mail oder unbekannt => keine Mail, kein Wurf, kein Fehler im Log', async (t) => {
|
||||
STUMM(t)
|
||||
aw.seed('customers', { $id: 'c_leer', appwriteUserId: 'u_leer', email: '', name: 'Ohne Adresse', status: 'active' })
|
||||
const ohneMail = einrichtung('termine', { customerId: 'c_leer' })
|
||||
const unbekannt = einrichtung('termine', { customerId: 'c_gibtsnicht' })
|
||||
await meldeFreigeschaltet(ohneMail)
|
||||
await meldeFreigeschaltet(unbekannt)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
assert.equal(console.error.mock.calls.length, 0)
|
||||
})
|
||||
|
||||
test('meldeFreigeschaltet: Kunde nicht lesbar oder Funktion nicht im Register => kein Wurf, geloggt, keine Mail', async (t) => {
|
||||
STUMM(t)
|
||||
const doc = einrichtung('termine', { einstellung: true })
|
||||
aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Nachbau: Kunden kaputt' })
|
||||
await assert.doesNotReject(() => meldeFreigeschaltet(doc))
|
||||
assert.match(geloggt(), /K16 an Kunde c1 fehlgeschlagen.*Nachbau: Kunden kaputt/)
|
||||
|
||||
const fremd = einrichtung('gibtsnicht')
|
||||
await assert.doesNotReject(() => meldeFreigeschaltet(fremd))
|
||||
assert.match(geloggt(), /K16 an Kunde c1 fehlgeschlagen.*gibtsnicht nicht im Register/)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------------------------- runActivation ---
|
||||
test('runActivation sendet fuer ein aktiviertes Termine-Dokument genau ein K16 (ueber meldeFreigeschaltet), ein zweiter Lauf keins', async () => {
|
||||
const doc = einrichtung('termine', { einstellung: true })
|
||||
const done = await sf.runActivation({ now: JETZT })
|
||||
assert.deepEqual(done.map((d) => d.$id), [doc.$id])
|
||||
assert.equal(aw.docs('portalFeatures').get(doc.$id).state, 'active')
|
||||
assert.equal(mails.sent.length, 1)
|
||||
assert.equal(k16('Dein Terminplaner ist bereit').length, 1)
|
||||
assert.deepEqual(await sf.runActivation({ now: JETZT }), [])
|
||||
assert.equal(mails.sent.length, 1)
|
||||
})
|
||||
|
||||
test('runActivation: ein fehlgeschlagener K16-Versand haelt weder die Aktivierung noch die uebrigen Mails auf', async (t) => {
|
||||
STUMM(t)
|
||||
const erstes = einrichtung('termine', { einstellung: true })
|
||||
const zweites = einrichtung('termine', { customerId: 'c2', einstellung: true })
|
||||
aw.seed('subscriptions', subscriptionDoc('sub_h2', { ...wartungDoc, customerId: 'c2', email: 'k2@example.invalid', stripeCustomerId: 'cus_c2' }))
|
||||
let n = 0
|
||||
setMailTransportForTests({
|
||||
sendMail: async (mail) => {
|
||||
n += 1
|
||||
if (n === 1) throw new Error('SMTP: erster Versand scheitert')
|
||||
return mails.sendMail(mail)
|
||||
},
|
||||
})
|
||||
let done
|
||||
try {
|
||||
done = await sf.runActivation({ now: JETZT })
|
||||
} finally {
|
||||
setMailTransportForTests(mails)
|
||||
}
|
||||
assert.deepEqual(done.map((d) => d.$id).sort(), [erstes.$id, zweites.$id].sort())
|
||||
for (const doc of [erstes, zweites]) assert.equal(aw.docs('portalFeatures').get(doc.$id).state, 'active')
|
||||
assert.equal(mails.sent.length, 1, 'die zweite Mail geht trotzdem raus')
|
||||
assert.match(geloggt(), /K16 an Kunde c\d fehlgeschlagen.*erster Versand scheitert/)
|
||||
})
|
||||
@@ -31,7 +31,7 @@ const { leerFragebogen, normalisiereFragebogen, FRAGEBOGEN_MAX } = await import(
|
||||
const { activate } = await import('../server/services/siteFeatures.js')
|
||||
const K = await import('../server/services/termine/kontext.js')
|
||||
const M = await import('../server/services/termine/mails.js')
|
||||
const { ladeEinstellung, speichereEinstellung, speichereFragebogen, kontextPerSiteKey, kontextPerFeature, kontextFuerPortal, websiteUrl, liveHost, resetKontextForTests } = K
|
||||
const { ladeEinstellung, ladeStand, speichereEinstellung, speichereFragebogen, kontextPerSiteKey, kontextPerFeature, kontextFuerPortal, websiteUrl, liveHost, resetKontextForTests } = K
|
||||
const { BETREFFE, werteFuer, anGast, anBetrieb } = M
|
||||
after(() => setMailTransportForTests(null))
|
||||
|
||||
@@ -266,6 +266,78 @@ test('ladeEinstellung: unlesbares JSON gilt als nicht gespeichert (mit Log); feh
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- ladeStand (TP2 D4: Stand fuer das Einrichtungs-Ticket)
|
||||
|
||||
test('ladeStand: ohne termineConfig-Dokument null, mit Dokument Einstellung, Version, updatedAt und der Fragebogen als gespeicherte Angaben', async () => {
|
||||
assert.equal(await ladeStand(FEATURE), null, 'noch nichts gespeichert')
|
||||
const e = einstellung()
|
||||
const fb = frage({ branche: 'salon', bestaetigt: ['branche'], einbau: { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Startseite' } })
|
||||
await speichereEinstellung(feature(), e, { version: 0, now: JETZT, fragebogen: fb })
|
||||
const stand = await ladeStand(FEATURE)
|
||||
assert.deepEqual(Object.keys(stand).sort(), ['einstellung', 'fragebogen', 'updatedAt', 'version'])
|
||||
assert.deepEqual(stand.einstellung, e)
|
||||
assert.equal(stand.version, 1)
|
||||
assert.equal(new Date(stand.updatedAt).getTime(), JETZT.getTime())
|
||||
assert.deepEqual(stand.fragebogen, JSON.parse(gespeichert().fragebogen))
|
||||
assert.equal(stand.fragebogen.einbau.knopfText, 'Termin buchen')
|
||||
await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Salon Neu' }) }), { version: 1, now: SEKUNDEN(60) })
|
||||
const zweit = await ladeStand(FEATURE)
|
||||
assert.equal(zweit.version, 2)
|
||||
assert.equal(zweit.einstellung.betrieb.name, 'Salon Neu')
|
||||
assert.equal(new Date(zweit.updatedAt).getTime(), SEKUNDEN(60).getTime())
|
||||
assert.deepEqual(zweit.fragebogen, stand.fragebogen, 'der Fragebogen bleibt, wenn nur die Einstellung gespeichert wird')
|
||||
assert.equal(log.error.mock.callCount(), 0, 'lesbare Dokumente: keine Logzeile')
|
||||
})
|
||||
|
||||
test('ladeStand: kaputtes fragebogen-JSON gibt fragebogen null bei gueltiger Einstellung (mit Log), kaputte Einstellung gibt einstellung null mit der Version', async () => {
|
||||
const e = einstellung()
|
||||
aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e), version: 2, updatedAt: JETZT.toISOString(), updatedBy: '', fragebogen: '{kaputt' })
|
||||
const stand = await ladeStand(FEATURE)
|
||||
assert.deepEqual(stand.einstellung, e)
|
||||
assert.equal(stand.fragebogen, null)
|
||||
assert.equal(stand.version, 2)
|
||||
assert.equal(log.error.mock.callCount(), 1)
|
||||
assert.match(String(log.error.mock.calls[0].arguments[0]), /Fragebogen f_p_projekt01_termine/)
|
||||
|
||||
const fb = frage({ branche: 'salon' })
|
||||
await deleteDocument('termineConfig', FEATURE)
|
||||
aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: '{kaputt', version: 3, updatedAt: JETZT.toISOString(), updatedBy: '', fragebogen: JSON.stringify(fb) })
|
||||
const kaputt = await ladeStand(FEATURE)
|
||||
assert.equal(kaputt.einstellung, null)
|
||||
assert.equal(kaputt.version, 3, 'die Version des Dokuments bleibt')
|
||||
assert.deepEqual(kaputt.fragebogen, fb)
|
||||
assert.equal(log.error.mock.callCount(), 2)
|
||||
})
|
||||
|
||||
test('ladeStand: fehlendes oder leeres fragebogen-Attribut, ein Nicht-Objekt und fehlende Zeit ergeben null; Version ohne Zahl ist 0', async () => {
|
||||
const e = einstellung()
|
||||
const doc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e), version: 1, updatedAt: JETZT.toISOString(), updatedBy: '' }
|
||||
for (const fragebogen of [undefined, null, '']) {
|
||||
await deleteDocument('termineConfig', FEATURE).catch(() => {})
|
||||
aw.seed('termineConfig', { ...doc, ...(fragebogen === undefined ? {} : { fragebogen }) })
|
||||
assert.equal((await ladeStand(FEATURE)).fragebogen, null, String(fragebogen))
|
||||
}
|
||||
assert.equal(log.error.mock.callCount(), 0, 'nichts gespeichert ist kein Fehler')
|
||||
for (const roh of ['[1,2]', '"text"', '42', 'null']) {
|
||||
await deleteDocument('termineConfig', FEATURE)
|
||||
aw.seed('termineConfig', { ...doc, fragebogen: roh })
|
||||
assert.equal((await ladeStand(FEATURE)).fragebogen, null, roh)
|
||||
}
|
||||
await deleteDocument('termineConfig', FEATURE)
|
||||
aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e) })
|
||||
const ohneZeit = await ladeStand(FEATURE)
|
||||
assert.equal(ohneZeit.updatedAt, null)
|
||||
assert.equal(ohneZeit.version, 0)
|
||||
assert.deepEqual(ohneZeit.einstellung, e)
|
||||
})
|
||||
|
||||
test('ladeStand: Eingabepruefung wie ladeEinstellung, fehlende Collection ist ein Fehler und kein "nichts"', async () => {
|
||||
for (const id of [undefined, null, '', 7]) await assert.rejects(() => ladeStand(id), TypeError, String(id))
|
||||
await mitFehlenderCollection('termineConfig', async () => {
|
||||
await assert.rejects(() => ladeStand(FEATURE), (err) => err.status === 404 && err.type === 'collection_not_found')
|
||||
})
|
||||
})
|
||||
|
||||
test('R40 speichereEinstellung mit Funktion: laeuft nach der Versionspruefung, bekommt die bisherige Einstellung (erst null), Ergebnis wird gespeichert', async () => {
|
||||
const aufrufe = []
|
||||
const erste = einstellung({ betrieb: betriebMit({ name: 'Erste' }) })
|
||||
|
||||
+502
-19
@@ -1,17 +1,22 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { config } from '../server/config.js'
|
||||
import {
|
||||
RECHTSTEXTE_VERSION,
|
||||
RECHTSTEXTE_ENTWURF,
|
||||
WEBKLAR_KONTAKT,
|
||||
widerrufsbelehrung,
|
||||
widerrufsbelehrungFunktion,
|
||||
formularHinweis,
|
||||
datenschutzAbsatz,
|
||||
ZUSTIMMUNG_VORZEITIG,
|
||||
ZUSTIMMUNG_STORNO,
|
||||
ZUSTIMMUNG_ERINNERUNG,
|
||||
GESUNDHEIT_HINWEIS,
|
||||
} from '../server/services/termine/rechtstexte.js'
|
||||
|
||||
// ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen.
|
||||
// Die Belehrung wird gegen den Wortlaut der Anlagen 1 und 2 zu Art. 246a EGBGB geprueft (docs/rechtliches/quellen/,
|
||||
// abgerufen 2026-10-02): jeder Absatz des Musters muss unveraendert im Ergebnis stehen, nur die Luecken sind gefuellt.
|
||||
|
||||
@@ -19,6 +24,10 @@ const KOPF = 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen'
|
||||
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
|
||||
const zeilen = (text) => text.split('\n').map((z) => z.trim())
|
||||
|
||||
// Wortlisten fuer die Anredepruefung: Du-Woerter duerfen in der Sie-Fassung nicht stehen und umgekehrt
|
||||
const DU_WOERTER = /\bdein|\bdu\b|\bdir\b|\bdich\b/i
|
||||
const SIE_WOERTER = /\bIhr|\bIhnen\b|\bSie (haben|können|erhalten|wünschen|machen|buchen|klicken|sind|wollen)\b/
|
||||
|
||||
const BETRIEB = { name: 'Salon Müller & Söhne', adresse: 'Hauptstraße 1, 67722 Winnweiler', telefon: '06301 1234', email: 'info@salon-mueller.de' }
|
||||
const KONTAKT = 'Salon Müller & Söhne, Hauptstraße 1, 67722 Winnweiler, Telefon: 06301 1234, E-Mail: info@salon-mueller.de'
|
||||
// Platzhalter-Betrieb: so steht die Belehrung in docs/rechtliches/termine-widerrufsbelehrung-muster.md
|
||||
@@ -45,8 +54,11 @@ function fristBaustein() {
|
||||
|
||||
const belehrung = (betrieb = BETRIEB) => widerrufsbelehrung(betrieb)
|
||||
|
||||
test('Fassung der Rechtstexte', () => {
|
||||
assert.equal(RECHTSTEXTE_VERSION, '2026-10-02')
|
||||
test('version_gleich_register: RECHTSTEXTE_VERSION ist das Fassungsdatum des Registers und nicht mehr die Altfassung 2026-10-02', () => {
|
||||
assert.equal(RECHTSTEXTE_VERSION, config.termine.avvVersion)
|
||||
assert.notEqual(RECHTSTEXTE_VERSION, '2026-10-02')
|
||||
assert.match(RECHTSTEXTE_VERSION, /^\d{4}-\d{2}-\d{2}$/)
|
||||
assert.ok(RECHTSTEXTE_VERSION > '2026-10-02', 'die Fassung liegt nach der eingefrorenen Altfassung')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Widerrufsbelehrung
|
||||
@@ -167,8 +179,8 @@ test('Zustimmungstexte wortgleich zu den Vorgaben (Sie-Form)', () => {
|
||||
+ 'Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).',
|
||||
)
|
||||
assert.equal(ZUSTIMMUNG_STORNO, 'Ich habe die Stornobedingungen gelesen.')
|
||||
assert.equal(ZUSTIMMUNG_ERINNERUNG(24), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.')
|
||||
assert.equal(ZUSTIMMUNG_ERINNERUNG(2), 'Ich möchte 2 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.')
|
||||
assert.equal(ZUSTIMMUNG_ERINNERUNG(24), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.')
|
||||
assert.equal(ZUSTIMMUNG_ERINNERUNG(2), 'Ich möchte 2 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.')
|
||||
assert.equal(GESUNDHEIT_HINWEIS, 'Bitte keine Gesundheitsangaben.')
|
||||
})
|
||||
|
||||
@@ -177,6 +189,23 @@ test('Zustimmung zum vorzeitigen Beginn setzt den Betriebsnamen unverändert ein
|
||||
assert.ok(text.startsWith("Ich verlange ausdrücklich, dass Frisör $& $1 'Anna' mit der Leistung vor Ablauf"))
|
||||
})
|
||||
|
||||
test('erinnerung_widerrufssatz: Haken-Text, Hinweise-Dokument wörtlich gleich; der Haken trägt den Widerrufssatz (Art. 7 Abs. 3 DSGVO)', () => {
|
||||
const satz = (n) => `Ich möchte ${n} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.`
|
||||
for (const n of [2, 12, 24, 48]) assert.equal(ZUSTIMMUNG_ERINNERUNG(n), satz(n))
|
||||
const md = lies('docs/rechtliches/termine-hinweise.md').replace(/\r/g, '')
|
||||
assert.ok(md.includes(satz('{{n}}')), 'Hinweise-Dokument: Haken Erinnerung weicht ab')
|
||||
assert.ok(md.includes(ZUSTIMMUNG_ERINNERUNG('{{n}}')))
|
||||
assert.ok(!md.includes('Erinnerung per E-Mail erhalten. |'), 'der alte Haken ohne Widerrufssatz steht nicht mehr im Dokument')
|
||||
assert.equal(ZUSTIMMUNG_ERINNERUNG(24).split('\n').length, 1, 'ein Satz im Haken, keine Umbrüche')
|
||||
})
|
||||
|
||||
test('dokumente_nennen_fassung: die drei Gäste-Dokumente tragen die Fassung der Gästetexte', () => {
|
||||
for (const rel of ['docs/rechtliches/termine-hinweise.md', 'docs/rechtliches/termine-datenschutz-absatz.md', 'docs/rechtliches/termine-widerrufsbelehrung-muster.md']) {
|
||||
assert.ok(lies(rel).includes(`Fassung ${RECHTSTEXTE_VERSION}.`), `${rel} nennt nicht „Fassung ${RECHTSTEXTE_VERSION}.“`)
|
||||
assert.ok(!lies(rel).includes('Fassung 2026-10-02.'), `${rel} trägt noch die Altfassung`)
|
||||
}
|
||||
})
|
||||
|
||||
const EINSTELLUNG = { anrede: 'sie', aufbewahrungTage: 90, betrieb: BETRIEB }
|
||||
|
||||
test('Formular-Hinweis (Sie): Zweck, Auftragsverarbeiter, Löschfristen, keine Cookies, Gesundheit, Verantwortlicher', () => {
|
||||
@@ -214,19 +243,295 @@ test('Formular-Hinweis: Sie ist die Vorgabe, Aufbewahrung 90 Tage ohne gültigen
|
||||
assert.throws(() => formularHinweis(undefined), TypeError)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Datenschutz-Absatz „Online-Terminbuchung“ (Quelle der Einbauanleitung und der Doku)
|
||||
|
||||
test('D1 (f): die Rechtstexte gelten nicht mehr als Entwurf (RECHTSTEXTE_ENTWURF === false)', () => {
|
||||
assert.equal(RECHTSTEXTE_ENTWURF, false)
|
||||
})
|
||||
|
||||
const DS_SCHALTER = ['telefonFeld', 'freitext', 'erinnerung', 'widerruf', 'telefon']
|
||||
// Bedingungstext der Quelle ([[wenn …: …]]) -> Schalter; eine unbekannte Bedingung in der Quelle lässt den Test scheitern
|
||||
const DS_BEDINGUNGEN = [
|
||||
[/^Telefon-Feld aktiv$/, 'telefonFeld'],
|
||||
[/^Nachricht oder Zusatzfelder aktiv$/, 'freitext'],
|
||||
[/^Erinnerung angeboten$/, 'erinnerung'],
|
||||
[/^Gäste Verbraucher sein können$/, 'widerruf'],
|
||||
[/^Telefon hinterlegt$/, 'telefon'],
|
||||
]
|
||||
// Satz bzw. Satzteil, den jeder Schalter einschaltet (Sie- und Du-Fassung)
|
||||
const DS_MARKER = {
|
||||
telefonFeld: /Telefonnummer/,
|
||||
freitext: /im Formular mitteil/,
|
||||
erinnerung: /Erinnerung/,
|
||||
widerruf: /Vertrag widerrufen/,
|
||||
telefon: /telefonisch erreichen/,
|
||||
}
|
||||
const DS_ALLE_AN = Object.fromEntries(DS_SCHALTER.map((k) => [k, true]))
|
||||
const DS_ALLE_AUS = Object.fromEntries(DS_SCHALTER.map((k) => [k, false]))
|
||||
|
||||
/** Echte, normalisierte Einstellung; jeder Schalter steuert genau die Einstellung, aus der die Funktion ihn ableitet. */
|
||||
function dsEinstellung(zustand, extra = {}) {
|
||||
return {
|
||||
anrede: 'sie',
|
||||
aufbewahrungTage: 90,
|
||||
betrieb: { ...BETRIEB, telefon: zustand.telefon ? BETRIEB.telefon : '' },
|
||||
gastFelder: { telefon: zustand.telefonFeld ? 'optional' : 'aus', nachricht: zustand.freitext ? 'optional' : 'aus', zusatz: [] },
|
||||
erinnerung: { anbieten: zustand.erinnerung, stundenVorher: 24 },
|
||||
recht: { gaesteSind: zustand.widerruf ? 'verbraucher' : 'unternehmer', freizeit: false },
|
||||
ressourcen: [{ id: 'r1', aktiv: true }],
|
||||
leistungen: [{ id: 'l1', aktiv: true, ressourcen: ['r1'] }],
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
/** Absätze durch Leerzeile, innerhalb eines Absatzes jeder Leerraum ein Leerzeichen. */
|
||||
const normalisiere = (text) => text.split(/\n[ \t]*\n/).map((a) => a.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n\n')
|
||||
|
||||
/**
|
||||
* Datenschutz-Absatz aus der Markdown-Quelle: Blockquote der Fassung („Sie“ | „Du“), `> `, `**` und `###` entfernt,
|
||||
* [[wenn …: X]] durch X (Schalter an) oder nichts (Schalter aus) ersetzt, Platzhalter mit den Testwerten gefüllt.
|
||||
* Ein Satzteil, der mit einem Wort beginnt („und die Angaben …“), trägt sein Leerzeichen selbst in der Klammer (zwei Leerzeichen
|
||||
* nach dem Doppelpunkt); die Ersetzung fügt nichts hinzu.
|
||||
*/
|
||||
function datenschutzQuelle(fassung, zustand, { tage = 90 } = {}) {
|
||||
const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '')
|
||||
const teil = md.split(/^## Fassung /m).slice(1).find((t) => t.startsWith(fassung))
|
||||
assert.ok(teil, `Fassung ${fassung} fehlt in der Quelle`)
|
||||
const zitat = teil.split('\n').filter((z) => z.startsWith('>')).map((z) => z.replace(/^>\s?/, '').replace(/^### /, ''))
|
||||
assert.ok(zitat.length > 10, 'Blockquote der Quelle nicht gefunden')
|
||||
const roh = zitat.join('\n').replace(/\*\*/g, '')
|
||||
const bedingt = roh.replace(/\[\[wenn ([^:]+): ([\s\S]*?)\]\]/g, (_, bedingung, satz) => {
|
||||
const eintrag = DS_BEDINGUNGEN.find(([muster]) => muster.test(bedingung))
|
||||
assert.ok(eintrag, `unbekannte Bedingung in der Quelle: ${bedingung}`)
|
||||
return zustand[eintrag[1]] ? satz : ''
|
||||
})
|
||||
const werte = { Firma: BETRIEB.name, Anschrift: BETRIEB.adresse, 'E-Mail': BETRIEB.email, Telefon: BETRIEB.telefon, Tage: String(tage) }
|
||||
const gefuellt = bedingt.replace(/\{\{([^}]+)\}\}/g, (_, name) => {
|
||||
assert.ok(Object.hasOwn(werte, name), `unbekannter Platzhalter in der Quelle: ${name}`)
|
||||
return werte[name]
|
||||
})
|
||||
assert.ok(!/\[\[|\]\]|\{\{|\}\}/.test(gefuellt), 'offene Klammer nach dem Ersetzen')
|
||||
return normalisiere(gefuellt)
|
||||
}
|
||||
|
||||
const alleZustaende = () =>
|
||||
Array.from({ length: 2 ** DS_SCHALTER.length }, (_, maske) => Object.fromEntries(DS_SCHALTER.map((k, i) => [k, Boolean(maske & (1 << i))])))
|
||||
|
||||
test('Datenschutz-Absatz: Wortlaut entspricht der Markdown-Quelle, beide Anreden, alle 32 Bedingungskombinationen', () => {
|
||||
for (const [fassung, anrede] of [['Sie', 'sie'], ['Du', 'du']]) {
|
||||
for (const zustand of alleZustaende()) {
|
||||
assert.equal(
|
||||
normalisiere(datenschutzAbsatz(dsEinstellung(zustand), anrede)),
|
||||
datenschutzQuelle(fassung, zustand),
|
||||
`${fassung} ${JSON.stringify(zustand)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Datenschutz-Absatz: Ausgabe ist schon normalisiert (Absätze durch Leerzeile, keine doppelten Leerzeichen, kein Platzhalterrest)', () => {
|
||||
for (const anrede of ['sie', 'du']) {
|
||||
for (const zustand of alleZustaende()) {
|
||||
const text = datenschutzAbsatz(dsEinstellung(zustand), anrede)
|
||||
assert.equal(text, normalisiere(text), `${anrede} ${JSON.stringify(zustand)}`)
|
||||
assert.ok(!/ {2}| [,.;:]|\[\[|\]\]|\{\{|\}\}|undefined|null|NaN/.test(text), `${anrede} ${JSON.stringify(zustand)}`)
|
||||
assert.ok(text.startsWith('Online-Terminbuchung\n\n'))
|
||||
assert.equal(text.split('\n\n')[0], 'Online-Terminbuchung')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Datenschutz-Absatz: gleiche Quelle auch mit anderer Aufbewahrung (Pin mit 365 Tagen)', () => {
|
||||
const einstellung = dsEinstellung(DS_ALLE_AN, { aufbewahrungTage: 365 })
|
||||
assert.equal(normalisiere(datenschutzAbsatz(einstellung, 'sie')), datenschutzQuelle('Sie', DS_ALLE_AN, { tage: 365 }))
|
||||
assert.equal(normalisiere(datenschutzAbsatz(einstellung, 'du')), datenschutzQuelle('Du', DS_ALLE_AN, { tage: 365 }))
|
||||
})
|
||||
|
||||
test('Datenschutz-Absatz: jede Bedingung schaltet genau ihren Satz', () => {
|
||||
for (const anrede of ['sie', 'du']) {
|
||||
for (const schalter of DS_SCHALTER) {
|
||||
const aus = datenschutzAbsatz(dsEinstellung({ ...DS_ALLE_AN, [schalter]: false }), anrede)
|
||||
const an = datenschutzAbsatz(dsEinstellung({ ...DS_ALLE_AUS, [schalter]: true }), anrede)
|
||||
for (const [name, muster] of Object.entries(DS_MARKER)) {
|
||||
assert.equal(muster.test(aus), name !== schalter, `${anrede}: ${schalter} aus, Marker ${name}`)
|
||||
assert.equal(muster.test(an), name === schalter, `${anrede}: nur ${schalter} an, Marker ${name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Datenschutz-Absatz: die Bedingungen lesen die Einstellung wie das Widget', () => {
|
||||
const alleAn = dsEinstellung(DS_ALLE_AN)
|
||||
const text = (e, anrede = 'sie') => datenschutzAbsatz(e, anrede)
|
||||
// Telefon-Feld: nur „aus“ blendet aus, optional und Pflicht zählen
|
||||
assert.ok(DS_MARKER.telefonFeld.test(text({ ...alleAn, gastFelder: { telefon: 'pflicht', nachricht: 'aus', zusatz: [] } })))
|
||||
assert.ok(!DS_MARKER.telefonFeld.test(text({ ...alleAn, gastFelder: { telefon: 'aus', nachricht: 'aus', zusatz: [] } })))
|
||||
// Nachricht oder Zusatzfelder: eines von beiden genügt
|
||||
const aus = { telefon: 'aus', nachricht: 'aus' }
|
||||
assert.ok(!DS_MARKER.freitext.test(text({ ...alleAn, gastFelder: { ...aus, zusatz: [] } })))
|
||||
assert.ok(DS_MARKER.freitext.test(text({ ...alleAn, gastFelder: { ...aus, nachricht: 'pflicht', zusatz: [] } })))
|
||||
assert.ok(DS_MARKER.freitext.test(text({ ...alleAn, gastFelder: { ...aus, zusatz: [{ key: 'wunsch', label: 'Wunsch', typ: 'text', optionen: [], pflicht: false }] } })))
|
||||
// Widerruf: genau die Bedingung des Widerrufsknopfs (Verbraucher-Gäste und mindestens eine sichtbare Leistung)
|
||||
assert.ok(DS_MARKER.widerruf.test(text({ ...alleAn, recht: { gaesteSind: 'gemischt', freizeit: false } })))
|
||||
assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, recht: { gaesteSind: 'unternehmer', freizeit: false } })))
|
||||
assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, recht: { gaesteSind: 'verbraucher', freizeit: true } })))
|
||||
assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, leistungen: [{ id: 'l1', aktiv: false, ressourcen: ['r1'] }] })))
|
||||
assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, ressourcen: [{ id: 'r1', aktiv: false }] })))
|
||||
// Telefon des Betriebs: Leerraum zählt als nicht hinterlegt
|
||||
assert.ok(!DS_MARKER.telefon.test(text({ ...alleAn, betrieb: { ...BETRIEB, telefon: ' ' } })))
|
||||
assert.ok(text({ ...alleAn, betrieb: { ...BETRIEB, telefon: ' 06301\n1234 ' } }).includes('telefonisch erreichen: 06301 1234.'))
|
||||
})
|
||||
|
||||
const ABO_ENDE_SATZ = 'Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.'
|
||||
|
||||
test('absatz_abo_ende: Funktion und Markdown (Sie und Du) nennen den Abo-Ende-Satz mit „30 Tage nach dem Abschalten“; der alte Satz ist weg', () => {
|
||||
const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '')
|
||||
const fassungen = md.split(/^## Fassung /m).slice(1)
|
||||
assert.equal(fassungen.length, 2)
|
||||
for (const f of fassungen) {
|
||||
assert.ok(normalisiere(f.replace(/^> ?/gm, '')).includes(ABO_ENDE_SATZ), 'Markdown: Abo-Ende-Satz fehlt')
|
||||
assert.ok(f.includes('30 Tage nach dem Abschalten'))
|
||||
assert.ok(!f.includes('löschen wir alle Termindaten 30 Tage'), 'Markdown: alter Satz steht noch')
|
||||
}
|
||||
for (const anrede of ['sie', 'du']) {
|
||||
for (const zustand of [DS_ALLE_AN, DS_ALLE_AUS]) {
|
||||
const text = datenschutzAbsatz(dsEinstellung(zustand), anrede)
|
||||
assert.equal(text.split(ABO_ENDE_SATZ).length, 2, `${anrede}: Satz genau einmal`)
|
||||
assert.ok(text.includes('30 Tage nach dem Abschalten'))
|
||||
assert.ok(!text.includes('löschen wir alle Termindaten'), `${anrede}: alter Satz steht noch`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('haltezeit_aus_config: „der Bestätigungslink gilt N Minuten“ folgt config.termine.holdMinutes; die Markdown-Quelle nennt denselben Wert', () => {
|
||||
const md = lies('docs/rechtliches/termine-datenschutz-absatz.md')
|
||||
for (const anrede of ['sie', 'du']) {
|
||||
assert.ok(datenschutzAbsatz(dsEinstellung(DS_ALLE_AN), anrede).includes(`(der Bestätigungslink gilt ${config.termine.holdMinutes} Minuten)`))
|
||||
}
|
||||
assert.equal((md.match(new RegExp(`Bestätigungslink gilt ${config.termine.holdMinutes} Minuten`, 'g')) || []).length, 2, 'die Quelle nennt beide Fassungen mit dem Wert aus config')
|
||||
const vorher = config.termine.holdMinutes
|
||||
try {
|
||||
config.termine.holdMinutes = 45
|
||||
for (const anrede of ['sie', 'du']) {
|
||||
const text = datenschutzAbsatz(dsEinstellung(DS_ALLE_AN), anrede)
|
||||
assert.ok(text.includes('(der Bestätigungslink gilt 45 Minuten)'), anrede)
|
||||
assert.ok(!text.includes('30 Minuten'), `${anrede}: kein fester Wert mehr im Text`)
|
||||
}
|
||||
} finally {
|
||||
config.termine.holdMinutes = vorher
|
||||
}
|
||||
})
|
||||
|
||||
test('quelle_satzteile_mit_leerzeichen: ein Satzteil der Quelle, der mit einem Wort beginnt, trägt sein Leerzeichen in der Klammer', () => {
|
||||
const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '')
|
||||
let mitLeerzeichen = 0
|
||||
for (const m of md.matchAll(/\[\[wenn ([^:]+): ([\s\S]*?)\]\]/g)) {
|
||||
if (m[1] === '…') continue // die Erklärung der Schreibweise im Kopf der Quelle
|
||||
const davor = md[m.index - 1]
|
||||
if (/^[A-Za-zÄÖÜäöüß]/.test(m[2])) {
|
||||
// Satzteil beginnt mit einem Wort: nur nach Leerzeichen erlaubt (Satzanfang), sonst klebte er am Vordertext („Platzund“)
|
||||
assert.ok(/\s/.test(davor), `Satzteil „${m[2].slice(0, 30)}…“ hängt ohne Leerzeichen am Vordertext`)
|
||||
} else if (m[2].startsWith(' ')) {
|
||||
mitLeerzeichen += 1
|
||||
assert.ok(/^ [A-Za-zÄÖÜäöüß]/.test(m[2]) && !/\s/.test(davor), `Leerzeichen in der Klammer nur am Anschluss an ein Wort: „${m[2].slice(0, 30)}…“`)
|
||||
}
|
||||
}
|
||||
assert.equal(mitLeerzeichen, 4, 'vier Satzteile mit „und“ tragen ihr Leerzeichen selbst (Sie und Du, je zwei)')
|
||||
})
|
||||
|
||||
test('Datenschutz-Absatz: Aufbewahrung nach dem Termin, ungültiger Wert wird zu 90 Tagen (wie der Formular-Hinweis)', () => {
|
||||
for (const anrede of ['sie', 'du']) {
|
||||
const mit = (aufbewahrungTage) => datenschutzAbsatz(dsEinstellung(DS_ALLE_AN, { aufbewahrungTage }), anrede)
|
||||
assert.ok(mit(365).includes('Bestätigte Termine löschen wir 365 Tage nach dem Termin,'))
|
||||
assert.ok(mit(14).includes('löschen wir 14 Tage nach dem Termin,'))
|
||||
for (const ungueltig of ['viel', undefined, null, 14.5, NaN]) {
|
||||
assert.ok(mit(ungueltig).includes('löschen wir 90 Tage nach dem Termin,'), `${anrede}: ${String(ungueltig)}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Datenschutz-Absatz: Sie-Fassung ohne Du-Wörter, Du-Fassung ohne Sie-Wörter', () => {
|
||||
for (const zustand of [DS_ALLE_AN, DS_ALLE_AUS]) {
|
||||
const sie = datenschutzAbsatz(dsEinstellung(zustand), 'sie')
|
||||
const du = datenschutzAbsatz(dsEinstellung(zustand), 'du')
|
||||
assert.ok(!DU_WOERTER.test(sie), 'Du-Form in der Sie-Fassung')
|
||||
assert.ok(!SIE_WOERTER.test(du), 'Sie-Form in der Du-Fassung')
|
||||
assert.ok(/Sie|Ihr/.test(sie) && /\bdu\b|\bdein/.test(du), 'Fassungen sind nicht zu unterscheiden')
|
||||
assert.ok(sie.includes('Auf unserer Website können Sie Termine online buchen.'))
|
||||
assert.ok(du.includes('Auf unserer Website kannst du Termine online buchen.'))
|
||||
}
|
||||
})
|
||||
|
||||
test('Datenschutz-Absatz: die Anrede kommt aus dem Parameter, alles außer „du“ ist die Sie-Fassung', () => {
|
||||
const einstellung = dsEinstellung(DS_ALLE_AN, { anrede: 'du' })
|
||||
assert.ok(datenschutzAbsatz(einstellung, 'sie').includes('können Sie Termine online buchen'))
|
||||
assert.ok(datenschutzAbsatz(einstellung, 'du').includes('kannst du Termine online buchen'))
|
||||
for (const anrede of [undefined, 'unbekannt', 'Du', null]) {
|
||||
assert.equal(datenschutzAbsatz(einstellung, anrede), datenschutzAbsatz(einstellung, 'sie'), String(anrede))
|
||||
}
|
||||
})
|
||||
|
||||
test('Datenschutz-Absatz: Name und E-Mail sind Pflicht (TypeError), eine leere Anschrift lässt kein leeres Komma stehen', () => {
|
||||
const ohne = (feld) => ({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, [feld]: undefined } })
|
||||
assert.throws(() => datenschutzAbsatz(ohne('name'), 'sie'), TypeError)
|
||||
assert.throws(() => datenschutzAbsatz(ohne('email'), 'du'), TypeError)
|
||||
assert.throws(() => datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, name: ' \n ' } }, 'sie'), TypeError)
|
||||
assert.throws(() => datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, email: '' } }, 'sie'), TypeError)
|
||||
assert.throws(() => datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: undefined }, 'sie'), TypeError)
|
||||
assert.throws(() => datenschutzAbsatz({}, 'sie'), TypeError)
|
||||
assert.throws(() => datenschutzAbsatz(undefined, 'sie'), TypeError)
|
||||
|
||||
for (const adresse of ['', ' ', undefined]) {
|
||||
for (const anrede of ['sie', 'du']) {
|
||||
const text = datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, adresse } }, anrede)
|
||||
assert.ok(text.endsWith('Verantwortlich ist Salon Müller & Söhne, E-Mail info@salon-mueller.de.'), `${anrede}: ${String(adresse)}`)
|
||||
assert.ok(!/, ,|,,|, E-Mail , /.test(text))
|
||||
}
|
||||
}
|
||||
const voll = datenschutzAbsatz(dsEinstellung(DS_ALLE_AN), 'sie')
|
||||
assert.ok(voll.endsWith('Verantwortlich ist Salon Müller & Söhne, Hauptstraße 1, 67722 Winnweiler, E-Mail info@salon-mueller.de.'))
|
||||
})
|
||||
|
||||
test('Datenschutz-Absatz: reiner Text, nicht HTML-maskiert, Betriebsangaben unverändert eingesetzt (kein Ersetzungsmuster)', () => {
|
||||
const betrieb = { name: "Frisör $& $1 <b>'Anna'</b> & Co", adresse: 'Weg 1,\n67722 Winnweiler', telefon: '06301 $` 1234', email: 'a&b@salon.de' }
|
||||
const text = datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb }, 'sie')
|
||||
assert.ok(text.includes("Absender ist Frisör $& $1 <b>'Anna'</b> & Co, Ihre Antworten"))
|
||||
assert.ok(text.includes("Verantwortlich ist Frisör $& $1 <b>'Anna'</b> & Co, Weg 1, 67722 Winnweiler, E-Mail a&b@salon.de."))
|
||||
assert.ok(text.includes('telefonisch erreichen: 06301 $` 1234.'))
|
||||
assert.ok(!text.includes('&') && !text.includes('<'))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Dokumente
|
||||
|
||||
const DOKUMENTE = [
|
||||
'docs/rechtliches/termine-widerrufsbelehrung-muster.md',
|
||||
'docs/rechtliches/termine-datenschutz-absatz.md',
|
||||
'docs/rechtliches/termine-hinweise.md',
|
||||
'docs/rechtliches/termine-datenschutz-6c-entwurf.md',
|
||||
'docs/rechtliches/termine-datenschutz-6c.md',
|
||||
]
|
||||
|
||||
test('Jedes Rechtstext-Dokument beginnt mit dem Entwurfs-Kopf', () => {
|
||||
// Das Wort ENTWURF als Kennzeichnung; ENTWURF_BANNER und RECHTSTEXTE_ENTWURF (Bezeichner im Code) zählen nicht
|
||||
const ENTWURF_WORT = /ENTWURF(?![_A-Z])/
|
||||
|
||||
test('kein_entwurf_mehr: kein ENTWURF-Kopf in den vier Dokumenten, keiner in den Texten von rechtstexte.js und in der Einbau-Doku; die Konstante steht auf false', () => {
|
||||
for (const rel of DOKUMENTE) {
|
||||
assert.equal(lies(rel).split('\n')[0].trim(), KOPF, rel)
|
||||
const md = lies(rel)
|
||||
assert.ok(!ENTWURF_WORT.test(md), `${rel}: ENTWURF steht noch im Dokument`)
|
||||
assert.notEqual(md.split('\n')[0].trim(), KOPF, `${rel}: der Entwurfs-Kopf steht noch in der ersten Zeile`)
|
||||
assert.ok(md.startsWith('# '), `${rel}: das Dokument beginnt mit der Überschrift`)
|
||||
}
|
||||
assert.ok(!existsSync(new URL('../docs/rechtliches/termine-datenschutz-6c-entwurf.md', import.meta.url)), 'die 6c-Datei heißt nicht mehr -entwurf')
|
||||
const quelle = lies('server/services/termine/rechtstexte.js')
|
||||
assert.ok(!ENTWURF_WORT.test(quelle.replace(/RECHTSTEXTE_ENTWURF/g, '')), 'rechtstexte.js: ENTWURF in Kopf oder Kommentar (nur der Bezeichner RECHTSTEXTE_ENTWURF ist erlaubt)')
|
||||
assert.ok(/export const RECHTSTEXTE_ENTWURF = false\b/.test(quelle))
|
||||
assert.equal(RECHTSTEXTE_ENTWURF, false)
|
||||
const einbau = lies('docs/funktionen/termine-einbau.md')
|
||||
assert.ok(!ENTWURF_WORT.test(einbau), 'termine-einbau.md: ENTWURF')
|
||||
assert.ok(!/nur beim Pilotkunden einsetzen|Solange der Kopf dort steht/.test(einbau), 'termine-einbau.md Abschnitt 6 ohne die Pilot-Einschränkung')
|
||||
// die Dokumente haben keine offene Rechtsprüfungs-Liste mehr (die Fragen stehen im Kanzlei-Paket); die 6c-Quelle (TP2 F12a) auch keine Einbau-Checkliste
|
||||
for (const rel of DOKUMENTE) assert.ok(!/^## Für die Rechtsprüfung/m.test(lies(rel)), `${rel}: Liste „Für die Rechtsprüfung offen“ steht noch`)
|
||||
const sechsC = lies('docs/rechtliches/termine-datenschutz-6c.md')
|
||||
assert.ok(!/^## Einbau in TP2/m.test(sechsC) && !sechsC.includes('nicht eingebaut'), '6c-Quelle: Einbau-Checkliste und „nicht eingebaut“ sind weg')
|
||||
})
|
||||
|
||||
test('Hinweise-Dokument: Formular-Hinweis (Sie/Du) und alle Zustimmungstexte stehen wörtlich darin', () => {
|
||||
@@ -243,6 +548,19 @@ test('Hinweise-Dokument: Formular-Hinweis (Sie/Du) und alle Zustimmungstexte ste
|
||||
}
|
||||
})
|
||||
|
||||
test('hinweise_ohne_entwurf: das Hinweise-Dokument nennt kein „Entwurf“ (auch nicht klein) und sagt, dass Vorlagen und Widget für die Abschnitte 4 bis 6 maßgeblich sind', () => {
|
||||
const md = lies('docs/rechtliches/termine-hinweise.md').replace(/\r/g, '')
|
||||
assert.ok(!/Entwurf/i.test(md), 'termine-hinweise.md: das Wort Entwurf steht noch im Dokument')
|
||||
assert.ok(md.includes('## 5. Widerrufshinweis in der Bestätigungs-E-Mail'))
|
||||
assert.ok(md.includes('## 6. Widerrufsfunktion (Texte)'))
|
||||
const kopf = md.split('\n')[2]
|
||||
assert.ok(kopf.includes('Die Abschnitte 4 bis 6 geben Texte des Widgets und der Mails wieder'), 'Zeile 3 sagt, was die Abschnitte 4 bis 6 sind')
|
||||
assert.ok(kopf.includes('maßgeblich sind die Mailvorlagen und das Widget'), 'Zeile 3 nennt Vorlagen und Widget als maßgeblich')
|
||||
// die genannten Quellen gibt es
|
||||
assert.ok(md.includes('server/mail-templates/G02 Termin bestaetigt.html') && existsSync(new URL('../server/mail-templates/G02 Termin bestaetigt.html', import.meta.url)))
|
||||
assert.ok(md.includes('server/services/termine/einstellungen.js') && existsSync(new URL('../server/services/termine/einstellungen.js', import.meta.url)))
|
||||
})
|
||||
|
||||
test('Datenschutz-Absatz: Löschfristen der Vorgaben, Sie- und Du-Fassung, Auftragsverarbeiter und Gesundheitshinweis', () => {
|
||||
const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '')
|
||||
assert.ok(/Fassung Sie/i.test(md) && /Fassung Du/i.test(md))
|
||||
@@ -260,16 +578,181 @@ test('Datenschutz-Absatz: Löschfristen der Vorgaben, Sie- und Du-Fassung, Auftr
|
||||
assert.ok(f.includes('Art. 6 Abs. 1 lit. b DSGVO') && f.includes('Art. 6 Abs. 1 lit. a DSGVO'))
|
||||
}
|
||||
assert.ok(fassungen[0].startsWith('Sie') && fassungen[1].startsWith('Du'))
|
||||
assert.ok(!/\bdein|\bdu\b|\bdir\b|\bdich\b/i.test(fassungen[0]), 'Du-Form in der Sie-Fassung')
|
||||
assert.ok(!/\bIhr|\bIhnen\b|\bSie (haben|können|erhalten|wünschen|machen|buchen|klicken|sind|wollen)\b/.test(fassungen[1]), 'Sie-Form in der Du-Fassung')
|
||||
assert.ok(!DU_WOERTER.test(fassungen[0]), 'Du-Form in der Sie-Fassung')
|
||||
assert.ok(!SIE_WOERTER.test(fassungen[1]), 'Sie-Form in der Du-Fassung')
|
||||
})
|
||||
|
||||
test('6c-Entwurf: nur als Entwurfsdatei, public/datenschutz.html bleibt unverändert (ohne Abschnitt 6c)', () => {
|
||||
const md = lies('docs/rechtliches/termine-datenschutz-6c-entwurf.md')
|
||||
assert.ok(md.includes('6c. Online-Terminbuchung'))
|
||||
assert.ok(md.includes('Auftragsverarbeiter'))
|
||||
assert.ok(/nicht (in|auf) .*(einbauen|veröffentlich)|nicht eingebaut|nicht veröffentlicht/i.test(md))
|
||||
// TP2 baut 6c ein und entfernt diese Absicherung
|
||||
const html = lies('public/datenschutz.html')
|
||||
assert.ok(!/6c\./.test(html) && !html.includes('id="termine"'), '6c darf vor TP2 nicht in public/datenschutz.html stehen')
|
||||
// ---------------------------------------------------------------- Widerrufsbelehrung der Funktionen (WEBklar als Unternehmer, Task F3)
|
||||
|
||||
const KONTAKT_WEBKLAR = 'WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com'
|
||||
const AN_ZEILE_WEBKLAR = 'An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:'
|
||||
const WIDERRUFEN_URL = 'https://project.webklar.com/widerrufen.html'
|
||||
const funktion = () => widerrufsbelehrungFunktion()
|
||||
|
||||
test('Gäste-Belehrung bleibt byte-gleich (Hash der Ausgabe vor Task F3)', () => {
|
||||
const text = widerrufsbelehrung(BETRIEB)
|
||||
assert.equal(createHash('sha256').update(text).digest('hex'), 'a4b3549b7b4ba0bdeeca438bc823d07eede5e4aa35e21e335fb5304ff36a1ff6')
|
||||
assert.equal(text.length, 2983)
|
||||
})
|
||||
|
||||
test('WEBKLAR_KONTAKT: WEBklar GbR als Unternehmer', () => {
|
||||
assert.deepEqual(WEBKLAR_KONTAKT, {
|
||||
name: 'WEBklar GbR',
|
||||
adresse: 'Am Schwimmbad 10, 67722 Winnweiler',
|
||||
telefon: '0176 23726355',
|
||||
email: 'support@webklar.com',
|
||||
})
|
||||
})
|
||||
|
||||
test('belehrung_funktion_muster_woertlich: jeder Absatz der Anlage 1 für Dienstleistungen steht wörtlich darin, nur die Lücken sind gefüllt', () => {
|
||||
const text = funktion()
|
||||
const muster = musterZeilen()
|
||||
assert.equal(muster.length, 8)
|
||||
assert.equal(fristBaustein(), 'des Vertragsabschlusses.')
|
||||
for (const zeile of muster) {
|
||||
const erwartet = zeile
|
||||
.replace('ab dem Tag .', `ab dem Tag ${fristBaustein()}`)
|
||||
.replace('uns ( )', `uns (${KONTAKT_WEBKLAR})`)
|
||||
assert.ok(text.includes(erwartet), `Absatz fehlt oder ist verändert: ${erwartet}`)
|
||||
}
|
||||
// Fristbeginn und Kontakt stehen genau einmal
|
||||
assert.equal(text.split('ab dem Tag des Vertragsabschlusses.').length - 1, 1)
|
||||
assert.equal(text.split(KONTAKT_WEBKLAR).length - 1, 1)
|
||||
})
|
||||
|
||||
test('belehrung_funktion_widerrufsfunktion: Satzrahmen der Anlage 1 wörtlich, die Lücke ist die Adresse der Widerrufsfunktion', () => {
|
||||
const text = funktion()
|
||||
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')
|
||||
const absatz = `${m[1]} ${WIDERRUFEN_URL} ${m[2]} ${m[3]}`
|
||||
assert.ok(text.includes(absatz), 'Absatz zur Widerrufsfunktion fehlt oder weicht vom Satzrahmen der Anlage ab')
|
||||
assert.equal(text.split(WIDERRUFEN_URL).length - 1, 1)
|
||||
// kein Hinweis auf eine Schaltfläche der Gäste-Fassung
|
||||
assert.ok(!text.includes('Termin verwalten') && !text.includes('Schaltfläche'))
|
||||
// der Wertersatz für Dienstleistungen steht wie in der Gäste-Fassung
|
||||
const w = /Haben Sie verlangt, dass die Dienstleistungen oder Lieferung von Wasser\/Gas\/Strom\/Fernwärme \[Unzutreffendes streichen\] (während[^“]+?)\s*“/.exec(ANLAGE_1)
|
||||
assert.ok(w)
|
||||
assert.ok(text.includes(`Haben Sie verlangt, dass die Dienstleistungen ${w[1]}`))
|
||||
})
|
||||
|
||||
test('belehrung_funktion_formular: Muster-Widerrufsformular der Anlage 2 wörtlich, „An“-Zeile mit WEBklar', () => {
|
||||
const text = funktion()
|
||||
const z = zeilen(ANLAGE_2)
|
||||
const start = z.indexOf('Muster-Widerrufsformular')
|
||||
assert.ok(start >= 0)
|
||||
const inhalt = z.slice(start).filter((s) => s && s !== '–')
|
||||
let anZeile = 0
|
||||
for (const zeile of inhalt) {
|
||||
if (zeile.startsWith('An [hier ist')) {
|
||||
anZeile += 1
|
||||
assert.ok(text.includes(`– ${AN_ZEILE_WEBKLAR}`), '„An“-Zeile mit WEBklar fehlt')
|
||||
} else {
|
||||
assert.ok(text.includes(zeile), `Formularzeile fehlt oder ist verändert: ${zeile}`)
|
||||
}
|
||||
}
|
||||
assert.equal(anZeile, 1)
|
||||
assert.equal(text.split(AN_ZEILE_WEBKLAR).length - 1, 1)
|
||||
})
|
||||
|
||||
test('Belehrung der Funktionen: reiner Text ohne Platzhalter, deterministisch, Sie-Form, nichts zu Waren', () => {
|
||||
const text = funktion()
|
||||
assert.equal(typeof text, 'string')
|
||||
assert.ok(text.startsWith('Widerrufsbelehrung\n\nWiderrufsrecht\n'))
|
||||
assert.ok(text.includes('\n') && !text.includes('\r'))
|
||||
assert.ok(!/\{\{|\}\}|\[|\]|…|\.\.\.|\(\s*\)/.test(text), 'offener Platzhalter')
|
||||
assert.ok(!/undefined|null|NaN/.test(text))
|
||||
assert.ok(!/Rücksendung|zurückzusenden|Besitz genommen|Wir holen die Waren|Sie haben die Waren|elektronisch ausfüllen|Wir können die Rückzahlung verweigern/.test(text))
|
||||
assert.ok(!DU_WOERTER.test(text))
|
||||
assert.ok(!/&|<[a-z/]/i.test(text))
|
||||
assert.equal(funktion(), funktion())
|
||||
// Gäste-Fassung und Funktionen-Fassung unterscheiden sich nur in Kontakt, Widerrufsfunktion und „An“-Zeile
|
||||
const gast = widerrufsbelehrung(WEBKLAR_KONTAKT)
|
||||
assert.notEqual(gast, text)
|
||||
const ohneUnterschied = (t) => t.replace(/uns \([^)]*\)/, 'uns (K)').replace(/Sie können Ihr Widerrufsrecht auch online unter .*? ausüben\./, 'WF').replace(/– An [^\n]*/, '– An A')
|
||||
assert.equal(ohneUnterschied(gast), ohneUnterschied(text))
|
||||
})
|
||||
|
||||
/** Text eines HTML-Ausschnitts: Tags weg, Entitäten der Seite aufgelöst, Leerraum zu einem Leerzeichen. */
|
||||
const klartext = (html) =>
|
||||
html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
test('belehrung_gleich_abo: Belehrung der Funktionen = Belehrung in den Abo-Bedingungen (Drift-Wächter), Muster-Formular ebenso', () => {
|
||||
const html = lies('public/abo-bedingungen.html')
|
||||
const von = html.indexOf('<h3>Widerrufsrecht</h3>')
|
||||
const bis = html.indexOf('Ende der Widerrufsbelehrung')
|
||||
assert.ok(von > 0 && bis > von, 'Aufbau der Abo-Bedingungen hat sich geändert')
|
||||
// Die Kontaktklammer wird vorher in beiden durch einen Platzhalter ersetzt: die Gäste-Bausteine setzen „Telefon: …, E-Mail: …“ mit
|
||||
// Doppelpunkt, die Abo-Bedingungen ohne
|
||||
const klammer = (t) => t.replace(/uns \([^)]*\)/, 'uns (KONTAKT)')
|
||||
const abo = klammer(klartext(html.slice(von, bis)))
|
||||
const text = funktion()
|
||||
const formularKopf = text.indexOf('\n\nMuster-Widerrufsformular\n\n')
|
||||
assert.ok(formularKopf > 0)
|
||||
const textBelehrung = text.slice(text.indexOf('Widerrufsrecht'), formularKopf)
|
||||
assert.equal(klammer(klartext(textBelehrung)), abo)
|
||||
|
||||
// Muster-Widerrufsformular: Kasten der Abo-Bedingungen gegen die Formularzeilen, „An“-Zeile vorher ersetzt
|
||||
const formVon = html.indexOf('<h3 id="formular">Muster-Widerrufsformular</h3>')
|
||||
const formBis = html.indexOf('<h2 id="widerruf-hinweise">')
|
||||
assert.ok(formVon > bis && formBis > formVon)
|
||||
const an = (t) => t.replace(/– An .*?:(?= – Hiermit)/, '– An ANSCHRIFT:')
|
||||
const aboForm = an(klartext(html.slice(formVon, formBis)).replace(/^Muster-Widerrufsformular /, ''))
|
||||
const textForm = an(klartext(text.slice(formularKopf + 2).replace(/^Muster-Widerrufsformular\n+/, '')))
|
||||
assert.equal(textForm, aboForm)
|
||||
assert.ok(aboForm.includes('– An ANSCHRIFT:') && textForm.includes('– An ANSCHRIFT:'))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Dokument widerrufsbelehrung-funktionen.md
|
||||
|
||||
const DOKU = 'docs/rechtliches/widerrufsbelehrung-funktionen.md'
|
||||
|
||||
/** Absätze eines Textes: durch Leerzeilen getrennt, jede Zeile eines Absatzes für sich (Formularzeilen), Überschriften-Zeichen weg. */
|
||||
const absaetze = (text) =>
|
||||
text
|
||||
.replace(/\r/g, '')
|
||||
.split(/\n{2,}/)
|
||||
.flatMap((block) => block.split('\n'))
|
||||
.map((z) => z.replace(/^#+\s+/, '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
test('belehrung_dokument_gepinnt: Textblock der Datei = Ausgabe von widerrufsbelehrungFunktion(), Kopf mit Fassung, keine Platzhalter', () => {
|
||||
const md = lies(DOKU).replace(/\r/g, '')
|
||||
const zeilenMd = md.split('\n')
|
||||
assert.equal(zeilenMd[0], '# Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)')
|
||||
assert.equal(zeilenMd[2], `**Fassung ${config.termine.avvVersion}**`)
|
||||
assert.ok(!/\[|\]|…|\{\{|\}\}/.test(md), 'offener Platzhalter')
|
||||
assert.ok(!/ENTWURF|Entwurf|Prüfpunkt|TODO|<!--/.test(md))
|
||||
assert.ok(md.includes('Das Widerrufsrecht gilt nur für Verbraucher.'))
|
||||
|
||||
// Belehrung und Formular stehen als Überschriften und Absätze (Markdown ohne Codeblock), wörtlich und in Reihenfolge
|
||||
const von = md.indexOf('## Widerrufsbelehrung')
|
||||
const bis = md.indexOf('## Hinweise zum Widerruf')
|
||||
assert.ok(von > 0 && bis > von, 'Aufbau der Datei: Abschnitte „Widerrufsbelehrung“ … „Hinweise zum Widerruf“')
|
||||
assert.deepEqual(absaetze(md.slice(von, bis)), absaetze(widerrufsbelehrungFunktion()))
|
||||
// keine Codeblöcke, Zitate oder Tabellen im Belehrungsteil
|
||||
assert.ok(!/```|^>|^\|/m.test(md.slice(von, bis)))
|
||||
})
|
||||
|
||||
test('belehrung_dokument_hinweise: vier Hinweise zum Widerruf, nicht Teil der gesetzlichen Belehrung', () => {
|
||||
const md = lies(DOKU).replace(/\r/g, '')
|
||||
const hinweise = md.slice(md.indexOf('## Hinweise zum Widerruf'))
|
||||
assert.match(hinweise, /nicht zur gesetzlichen Widerrufsbelehrung/)
|
||||
const punkte = hinweise.split('\n').filter((z) => z.startsWith('- '))
|
||||
assert.equal(punkte.length, 4)
|
||||
assert.match(punkte[0], /jede Bestellung einer Funktion gesondert/)
|
||||
assert.match(punkte[0], /Website-Projekt/)
|
||||
assert.match(punkte[1], /endet diese Funktion/)
|
||||
assert.match(punkte[1], /Wartungs-Abo bleibt davon unberührt/)
|
||||
assert.match(punkte[2], /ausdrücklichen Wunsch/)
|
||||
assert.match(punkte[2], /angemessenen Betrag für die bis dahin erbrachte Leistung/)
|
||||
assert.match(punkte[2], /Anteil am Gesamtpreis/)
|
||||
assert.match(punkte[2], /Arbeitsblatt erfasste Aufwand/)
|
||||
assert.match(punkte[3], /ursprüngliche Zahlungsmittel/)
|
||||
assert.match(punkte[3], /Stripe/)
|
||||
assert.ok(!DU_WOERTER.test(md))
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PLATZHALTER_BETRIEB, archivPfad, baueArchiv, lauf } from '../scripts/termine-texte-archiv.mjs'
|
||||
import {
|
||||
GESUNDHEIT_HINWEIS,
|
||||
RECHTSTEXTE_VERSION,
|
||||
ZUSTIMMUNG_ERINNERUNG,
|
||||
ZUSTIMMUNG_STORNO,
|
||||
ZUSTIMMUNG_VORZEITIG,
|
||||
widerrufsbelehrung,
|
||||
} from '../server/services/termine/rechtstexte.js'
|
||||
|
||||
// Archiv der Gästetexte (AVV Terminplaner Nr. 8 Abs. 5): jede Fassung steht als Datei unter docs/rechtliches/archiv/, erzeugt von
|
||||
// scripts/termine-texte-archiv.mjs. Die Datei einer früheren Fassung wird nie neu erzeugt; ihr Hash ist hier festgenagelt.
|
||||
|
||||
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
|
||||
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
|
||||
const ARCHIV_DIR = 'docs/rechtliches/archiv'
|
||||
|
||||
/** Temporäres Projektverzeichnis mit dem Archiv-Ordner; räumt nach dem Test auf. */
|
||||
function mitTempRoot(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'termine-archiv-'))
|
||||
mkdirSync(join(root, ARCHIV_DIR), { recursive: true })
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }))
|
||||
return root
|
||||
}
|
||||
const leise = { log: () => {}, fehler: () => {} }
|
||||
|
||||
test('archiv_aktuelle_fassung: Skriptausgabe ist die eingecheckte Datei der aktuellen Fassung; Datei und README nennen die Fassung', () => {
|
||||
assert.equal(archivPfad(), `${ARCHIV_DIR}/termine-gaestetexte-${RECHTSTEXTE_VERSION}.md`)
|
||||
const datei = lies(archivPfad())
|
||||
assert.equal(baueArchiv(), datei, 'Archivdatei weicht von rechtstexte.js ab: node scripts/termine-texte-archiv.mjs ausführen (nur bei neuer Fassung)')
|
||||
assert.equal(baueArchiv(), baueArchiv(), 'deterministisch')
|
||||
assert.ok(datei.startsWith(`# Gästetexte des Terminplaners, Fassung ${RECHTSTEXTE_VERSION}\n`))
|
||||
const readme = lies(`${ARCHIV_DIR}/README.md`)
|
||||
const zeile = readme.split('\n').find((z) => z.startsWith('|') && z.includes(RECHTSTEXTE_VERSION) && z.includes(`termine-gaestetexte-${RECHTSTEXTE_VERSION}.md`))
|
||||
assert.ok(zeile, 'README-Tabelle „Terminplaner-Gästetexte“ nennt die Fassung nicht')
|
||||
assert.ok(readme.includes('## Terminplaner-Gästetexte'))
|
||||
})
|
||||
|
||||
test('archiv_aktuelle_fassung: --check vergleicht mit der eingecheckten Datei (Exit 0 / 1 / 2), ohne --check wird geschrieben', (t) => {
|
||||
assert.equal(lauf(['--check'], leise), 0, 'die eingecheckte Datei stimmt')
|
||||
const root = mitTempRoot(t)
|
||||
const ziel = join(root, archivPfad())
|
||||
assert.equal(lauf(['--check'], { ...leise, root }), 1, 'fehlende Datei')
|
||||
assert.equal(lauf([], { ...leise, root }), 0)
|
||||
assert.equal(readFileSync(ziel, 'utf8'), baueArchiv())
|
||||
assert.equal(lauf(['--check'], { ...leise, root }), 0)
|
||||
writeFileSync(ziel, readFileSync(ziel, 'utf8').replace('Gesundheit', 'Gesundheits'))
|
||||
const zeilen = []
|
||||
assert.equal(lauf(['--check'], { root, log: (z) => zeilen.push(z), fehler: () => {} }), 1, 'geänderte Datei')
|
||||
assert.match(zeilen[0], /^ABWEICHT /)
|
||||
assert.equal(readFileSync(ziel, 'utf8').includes('Gesundheits'), true, '--check schreibt nichts')
|
||||
const meldungen = []
|
||||
assert.equal(lauf(['--prüfen'], { ...leise, root, fehler: (m) => meldungen.push(m) }), 2)
|
||||
assert.match(meldungen[0], /Aufruf: node scripts\/termine-texte-archiv\.mjs \[--check\]/)
|
||||
})
|
||||
|
||||
test('archiv_schutz: eine abweichende Datei der aktuellen Fassung wird nur mit --force überschrieben; gleiche oder neue Dateien bleiben erlaubt', (t) => {
|
||||
const root = mitTempRoot(t)
|
||||
const ziel = join(root, archivPfad())
|
||||
const meldungen = []
|
||||
const mitFehler = { log: () => {}, fehler: (m) => meldungen.push(m) }
|
||||
// neue Datei und identische Datei: erlaubt
|
||||
assert.equal(lauf([], { ...mitFehler, root }), 0)
|
||||
assert.equal(readFileSync(ziel, 'utf8'), baueArchiv())
|
||||
assert.equal(lauf([], { ...mitFehler, root }), 0, 'derselbe Inhalt darf erneut geschrieben werden')
|
||||
assert.deepEqual(meldungen, [])
|
||||
// abweichende Datei (zum Beispiel die ausgelieferte Fassung mit einem nachträglich geänderten Text): Exit 1, Datei unverändert
|
||||
const ausgeliefert = `${baueArchiv()}Nachträglich geändert.\n`
|
||||
writeFileSync(ziel, ausgeliefert)
|
||||
assert.equal(lauf([], { ...mitFehler, root }), 1)
|
||||
assert.equal(readFileSync(ziel, 'utf8'), ausgeliefert, 'ohne --force bleibt die Datei, wie sie war')
|
||||
assert.match(meldungen[0], /weicht ab/)
|
||||
assert.match(meldungen[0], /--force/)
|
||||
assert.ok(meldungen[0].includes(archivPfad()), 'die Meldung nennt die Datei')
|
||||
// mit --force wird überschrieben
|
||||
assert.equal(lauf(['--force'], { ...mitFehler, root }), 0)
|
||||
assert.equal(readFileSync(ziel, 'utf8'), baueArchiv())
|
||||
// --check bleibt schreibfrei; --force zusammen mit --check ist ein falscher Aufruf
|
||||
writeFileSync(ziel, ausgeliefert)
|
||||
assert.equal(lauf(['--check'], { ...mitFehler, root }), 1)
|
||||
assert.equal(readFileSync(ziel, 'utf8'), ausgeliefert)
|
||||
assert.equal(lauf(['--check', '--force'], { ...mitFehler, root }), 2)
|
||||
assert.equal(readFileSync(ziel, 'utf8'), ausgeliefert)
|
||||
})
|
||||
|
||||
test('archiv_inhalt: alle Gästetexte stehen wörtlich im Archiv, die Betriebsangaben als Platzhalter', () => {
|
||||
const text = baueArchiv()
|
||||
const zitat = (s) => s.split('\n').map((z) => (z === '' ? '>' : `> ${z}`)).join('\n')
|
||||
// Formular-Hinweis, Sie und Du
|
||||
assert.ok(text.includes('> Datenschutz. Wir verwenden Ihre Angaben, um Ihren Termin zu verwalten'))
|
||||
assert.ok(text.includes('> Datenschutz. Wir verwenden deine Angaben, um deinen Termin zu verwalten'))
|
||||
assert.ok(text.includes('Verantwortlich: {{Name des Betriebs}}, {{Anschrift}}.'))
|
||||
// Haken, Gesundheitshinweis, Belehrung
|
||||
assert.ok(text.includes(zitat(ZUSTIMMUNG_VORZEITIG('{{Name des Betriebs}}'))))
|
||||
assert.ok(text.includes(zitat(ZUSTIMMUNG_STORNO)))
|
||||
assert.ok(text.includes(zitat(ZUSTIMMUNG_ERINNERUNG('{{Stunden}}'))))
|
||||
assert.ok(text.includes(zitat(GESUNDHEIT_HINWEIS)))
|
||||
assert.ok(text.includes(zitat(widerrufsbelehrung(PLATZHALTER_BETRIEB))))
|
||||
assert.ok(text.includes('Telefon: {{Telefon}}, E-Mail: {{E-Mail}}'))
|
||||
// Knopftexte
|
||||
for (const zeile of [
|
||||
'| Sofort buchbar, ohne Preis | Termin buchen | Termin bestätigen |',
|
||||
'| Sofort buchbar, mit Preis | Zahlungspflichtig buchen | Zahlungspflichtig bestätigen |',
|
||||
'| Terminanfrage | Termin anfragen | Anfrage absenden |',
|
||||
]) {
|
||||
assert.ok(text.includes(`${zeile}\n`), `Knopftext-Zeile fehlt: ${zeile}`)
|
||||
}
|
||||
// Datenschutz-Absatz der Website, mit den bedingten Sätzen
|
||||
assert.ok(text.includes('> Online-Terminbuchung'))
|
||||
assert.ok(text.includes('Auf unserer Website können Sie Termine online buchen.'))
|
||||
assert.ok(text.includes('Auf unserer Website kannst du Termine online buchen.'))
|
||||
assert.ok(text.includes('Vertrag widerrufen'))
|
||||
assert.ok(text.includes('Wenn Sie eine Erinnerung vor dem Termin wünschen'))
|
||||
// Fassung 2026-10-05: Widerrufssatz am Erinnerungshaken und Abo-Ende-Satz im Datenschutz-Absatz (Sie und Du)
|
||||
assert.ok(text.includes('> Ich möchte {{Stunden}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.\n'))
|
||||
assert.equal(text.split('zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.').length, 3, 'Abo-Ende-Satz in Sie- und Du-Fassung')
|
||||
assert.ok(!text.includes('löschen wir alle Termindaten 30 Tage'))
|
||||
// Nachweis-Hinweis (⚖ Q26)
|
||||
assert.ok(text.includes('Der Nachweis speichert Hashes der Texte mit den Betriebsangaben zum Buchungszeitpunkt (⚖ Q26).'))
|
||||
// kein Rest, keine echten Betriebsdaten
|
||||
assert.ok(!/undefined|\bnull\b|NaN|\[object/.test(text))
|
||||
assert.ok(!text.includes('\r'))
|
||||
assert.ok(text.endsWith('\n') && !text.endsWith('\n\n'))
|
||||
})
|
||||
|
||||
|
||||
// SHA-256 der eingefrorenen Datei der Fassung 2026-10-02 (aus den unveränderten Texten erzeugt, vor jeder Textänderung eingecheckt)
|
||||
const HASH_2026_10_02 = '920633e2e23c9c98aded0c1509db5cac1465058a025aeddc76a9a1282041a6a9'
|
||||
|
||||
// SHA-256 der Archivdatei der aktuellen Fassung: ändert sich ein Gästetext, ohne dass die Fassung (RECHTSTEXTE_VERSION) ein neues Datum
|
||||
// und diese Datei eine neue Archivdatei bekommt, schlägt der Test an. Bei einer neuen Fassung: neue Datei, neues Datum, neuer Pin.
|
||||
const HASH_AKTUELL = { '2026-10-05': '6bcad4c4a02ea5e39b3ec690d40617fdfb3cb1290aadd3304f0ecabab14c4a59' }
|
||||
|
||||
test('textstand_pin: Hash der aktuellen Archivdatei ist festgenagelt (Textänderung ohne neues Datum fällt auf)', () => {
|
||||
assert.ok(Object.hasOwn(HASH_AKTUELL, RECHTSTEXTE_VERSION), `kein Hash-Pin für die Fassung ${RECHTSTEXTE_VERSION}`)
|
||||
const datei = lies(archivPfad())
|
||||
assert.equal(sha256(datei), HASH_AKTUELL[RECHTSTEXTE_VERSION], 'Gästetext geändert, aber die Fassung ist dieselbe: neues Datum, neue Archivdatei und neuer Pin nötig')
|
||||
assert.equal(sha256(baueArchiv()), HASH_AKTUELL[RECHTSTEXTE_VERSION])
|
||||
assert.notEqual(HASH_AKTUELL[RECHTSTEXTE_VERSION], HASH_2026_10_02, 'die neue Fassung unterscheidet sich von der eingefrorenen')
|
||||
})
|
||||
|
||||
test('archiv_altfassung_eingefroren: Datei 2026-10-02 hat den alten Erinnerungstext ohne den Widerrufssatz; ihr Hash ist festgenagelt', () => {
|
||||
const alt = lies(`${ARCHIV_DIR}/termine-gaestetexte-2026-10-02.md`)
|
||||
assert.ok(alt.startsWith('# Gästetexte des Terminplaners, Fassung 2026-10-02\n'))
|
||||
assert.ok(alt.includes('> Ich möchte {{Stunden}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.\n'), 'alter Erinnerungstext')
|
||||
assert.ok(!alt.includes('Diese Einwilligung kann ich jederzeit widerrufen.'), 'der Widerrufssatz gehört erst zur nächsten Fassung')
|
||||
assert.ok(alt.includes('Wird die Online-Terminbuchung abgeschaltet, löschen wir alle Termindaten 30 Tage nach dem Abschalten.'), 'alter Datenschutz-Absatz')
|
||||
assert.equal(sha256(alt), HASH_2026_10_02, 'Die Datei der Fassung 2026-10-02 darf nie verändert werden; Textänderungen bekommen eine neue Fassung')
|
||||
})
|
||||
@@ -41,9 +41,12 @@ async function freshConfig(env = {}) {
|
||||
|
||||
test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => {
|
||||
const fresh = await freshConfig()
|
||||
// avvVersion: Fassung der Bestellunterlagen (Berliner Tag des Fassungs-Commits), hier nur das Datumsmuster
|
||||
assert.match(fresh.termine.avvVersion, /^\d{4}-\d{2}-\d{2}$/)
|
||||
assert.deepEqual(fresh.termine, {
|
||||
mode: 'off',
|
||||
pilotEmails: [],
|
||||
avvVersion: fresh.termine.avvVersion,
|
||||
holdMinutes: 30,
|
||||
zelleMin: 5,
|
||||
mailsProStunde: 200,
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { test, after, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
||||
|
||||
// Kurzfassung der Einbauanleitung im Einrichtungs-Ticket (TP2 Task D4): termineTicketDetails ist der Inhalt von
|
||||
// config.features.termine.ticket.details. Es wirft nie ("bezahlt ist bezahlt, das Ticket entsteht immer"): bei Lesefehlern
|
||||
// stehen Kopfzeilen, der Hinweis und der Verweis im Ticket. websiteProjects, workorders und customers legt das Setup-Skript nicht
|
||||
// an: ohne Schema im Nachbau.
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { portalFeatures: schemaKeys('portalFeatures'), termineConfig: schemaKeys('termineConfig') },
|
||||
datetimes: TERMINE_DATETIMES,
|
||||
})
|
||||
after(() => aw.close())
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { ausVorlage } = await import('../server/services/termine/vorlagen.js')
|
||||
const { neuerFeedToken } = await import('../server/services/termine/einstellungen.js')
|
||||
const reg = await import('../server/services/featureRegistry.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
const { termineTicketDetails } = await import('../server/services/termine/ticketDetails.js')
|
||||
const { ticketStand, driftWarnung, gesundheitsMeldungen } = await import('../server/services/termine/einbauanleitung.js')
|
||||
|
||||
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin', email: 'kunde1@example.test' })
|
||||
|
||||
const NOW = new Date('2026-10-05T10:00:00Z')
|
||||
const GEKUERZT = '… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen'
|
||||
const HALBES_PAAR = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/
|
||||
const MAX = reg.TICKET_DETAILS_MAX
|
||||
const BETRIEB = {
|
||||
name: 'Salon Haarlinie',
|
||||
email: 'inhaber-intern@haarlinie.example',
|
||||
telefon: '06301 1234',
|
||||
adresse: 'Hauptstraße 1, 67722 Winnweiler',
|
||||
kleinunternehmer: false,
|
||||
impressumUrl: 'https://haarlinie.example/impressum',
|
||||
datenschutzUrl: 'https://haarlinie.example/datenschutz',
|
||||
bundesland: 'RP',
|
||||
}
|
||||
const PROJEKT = { projectName: 'Salon Haarlinie', liveDomain: 'haarlinie.example', subdomain: 'haarlinie' }
|
||||
const SITE_KEY = 'abcdefghijklmnop'
|
||||
const UPDATED_AT = '2026-10-03T08:15:00.000Z'
|
||||
|
||||
const einstellung = () => {
|
||||
const e = ausVorlage('salon', BETRIEB, NOW)
|
||||
e.farbe = '#9d4b6f'
|
||||
return e
|
||||
}
|
||||
|
||||
/** Maximalbefuellung wie im Plan: 50 Leistungen mit 100 Zeichen, 20 Personen, 5 Zusatzfelder, 10 Gesundheitsmeldungen, 6 Warnungen. */
|
||||
function maximalEinstellung() {
|
||||
const e = einstellung()
|
||||
e.betrieb.telefon = ''
|
||||
e.betrieb.adresse = ''
|
||||
e.betrieb.impressumUrl = ''
|
||||
e.betrieb.datenschutzUrl = ''
|
||||
e.farbe = '#ffff00'
|
||||
const vorlage = e.leistungen[0]
|
||||
e.leistungen = Array.from({ length: 50 }, (_, i) => ({
|
||||
...vorlage,
|
||||
id: `s_max${String(i).padStart(5, '0')}`,
|
||||
name: `${String(i + 1).padStart(2, '0')} ${'L'.repeat(97)}`,
|
||||
aktiv: true,
|
||||
ressourcen: [],
|
||||
beschreibung: i < 10 ? 'Physiotherapie' : '',
|
||||
}))
|
||||
e.leistungen[0].name = 'Physiotherapie'
|
||||
e.ressourcen = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `r_max${String(i).padStart(5, '0')}`, typ: 'person', name: `Person ${i + 1} ${'P'.repeat(80)}`, aktiv: true, plan: { 1: [['09:00', '12:00']] }, feedToken: neuerFeedToken(),
|
||||
}))
|
||||
e.gastFelder = {
|
||||
telefon: 'pflicht',
|
||||
nachricht: 'optional',
|
||||
zusatz: Array.from({ length: 5 }, (_, i) => ({ key: `z${i}`, label: `Zusatzfeld ${i + 1} ${'Z'.repeat(90)}`, typ: 'text', optionen: [], pflicht: false })),
|
||||
}
|
||||
return e
|
||||
}
|
||||
const MAX_FRAGEBOGEN = { einbau: { modus: 'knopf', knopfText: 'K'.repeat(40), stelle: 'S'.repeat(300), gestaltung: 'G'.repeat(1000), bemerkung: 'B'.repeat(1000) } }
|
||||
|
||||
let zaehler = 0
|
||||
/** Projekt (Kunde c1) anlegen und den Terminplaner bestellen; mit e (und fb) liegt ein termineConfig-Dokument unter der Funktions-ID. */
|
||||
async function bestelle({ e = null, fb = null, version = 1, projekt = PROJEKT } = {}) {
|
||||
zaehler += 1
|
||||
const projectId = `tt${zaehler}`
|
||||
const sessionId = `cs_tt${zaehler}`
|
||||
aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', ...projekt })
|
||||
const doc = await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} })
|
||||
if (e) {
|
||||
aw.seed('termineConfig', {
|
||||
$id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(e), version, updatedAt: UPDATED_AT, updatedBy: 'kunde:c1',
|
||||
...(fb ? { fragebogen: JSON.stringify(fb) } : {}),
|
||||
})
|
||||
}
|
||||
return { projectId, sessionId, doc }
|
||||
}
|
||||
|
||||
/** ctx genau in der Form, die markPaid uebergibt: { feature: Register-Eintrag, doc, project, customer }. */
|
||||
const ctxFuer = (doc, project = PROJEKT, customer = { name: 'Kunde Eins' }) => ({ feature: reg.featureConfig('termine'), doc, project, customer })
|
||||
const absaetze = (text) => text.split('\n\n')
|
||||
const stumm = () => mock.method(console, 'error', () => {})
|
||||
|
||||
// ------------------------------------------------------------------------------ termineTicketDetails ---
|
||||
test('termineTicketDetails mit Einstellungen: Kopf mit Konfigurationsstand: Version 1, Snippet mit siteKey, Verweis, Stundenkontingent', async () => {
|
||||
const { projectId, doc } = await bestelle({ e: einstellung() })
|
||||
const text = await termineTicketDetails(ctxFuer(doc))
|
||||
const [kopf] = absaetze(text)
|
||||
assert.deepEqual(kopf.split('\n'), [
|
||||
'Funktion: Terminplaner',
|
||||
'Projekt: Salon Haarlinie',
|
||||
'Domain: haarlinie.example',
|
||||
'Kunde: Kunde Eins',
|
||||
`siteKey: ${doc.siteKey}`,
|
||||
'Konfigurationsstand: Version 1, 03.10.2026',
|
||||
])
|
||||
assert.ok(text.includes('Konfigurationsstand: Version 1'))
|
||||
assert.equal(ticketStand(text), 1)
|
||||
assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`))
|
||||
assert.ok(text.endsWith(`Verweis: ${reg.anleitungsLink('c1', projectId)}`))
|
||||
assert.ok(text.includes('Nicht auf das Stundenkontingent anrechnen'))
|
||||
assert.ok(text.includes('Umfang:\n- 4 Leistungen'), text)
|
||||
assert.ok(!text.includes('Online-Terminbuchung') && !text.includes(BETRIEB.email), 'weder Datenschutz-Absatz noch Betriebs-E-Mail')
|
||||
assert.ok(text.length <= MAX)
|
||||
assert.ok(!text.includes('bezahlt'), 'hier wurde nur bestellt: keine Behauptung, die Einrichtung sei bezahlt')
|
||||
})
|
||||
|
||||
test('termineTicketDetails: der Wunsch des Kunden aus dem Fragebogen steht im Ticket, der Modus im Snippet', async () => {
|
||||
const fb = { einbau: { modus: 'knopf', knopfText: 'Termin sichern', stelle: 'Startseite unter dem Hero', gestaltung: 'Schlicht', bemerkung: 'Bitte vor dem 20.10.' } }
|
||||
const { doc } = await bestelle({ e: einstellung(), fb })
|
||||
const text = await termineTicketDetails(ctxFuer(doc))
|
||||
assert.ok(text.includes(' Modus: knopf\n Knopf-Text: Termin sichern\n Stelle: Startseite unter dem Hero\n Gestaltung: Schlicht\n Bemerkung: Bitte vor dem 20.10.'), text)
|
||||
assert.ok(text.includes('data-modus="knopf" data-knopf="Termin sichern"'))
|
||||
})
|
||||
|
||||
test('termineTicketDetails ohne termineConfig: noch keine Einstellungen, Snippet, Verweis', async () => {
|
||||
const { projectId, doc } = await bestelle()
|
||||
const text = await termineTicketDetails(ctxFuer(doc))
|
||||
assert.ok(text.includes('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), text)
|
||||
assert.ok(text.includes('noch keine Einstellungen'))
|
||||
assert.equal(ticketStand(text), 0)
|
||||
assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`))
|
||||
assert.ok(text.endsWith(`Verweis: ${reg.anleitungsLink('c1', projectId)}`))
|
||||
assert.ok(text.includes('Keine Einstellungen gespeichert'))
|
||||
})
|
||||
|
||||
test('termineTicketDetails bei Lesefehler: loest auf, ohne Wurf, mit Kopfzeilen, Hinweis und Link', async () => {
|
||||
const { projectId, doc } = await bestelle({ e: einstellung() })
|
||||
const fehler = stumm()
|
||||
try {
|
||||
aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 })
|
||||
const text = await termineTicketDetails(ctxFuer(doc))
|
||||
const link = reg.anleitungsLink('c1', projectId)
|
||||
for (const teil of ['Projekt: Salon Haarlinie', 'Domain: haarlinie.example', 'Kunde: Kunde Eins', `siteKey: ${doc.siteKey}`, `Verweis: ${link}`, 'Anleitung konnte nicht erzeugt werden, im WOMS öffnen', 'Nicht auf das Stundenkontingent anrechnen']) {
|
||||
assert.ok(text.includes(teil), `${teil}\n---\n${text}`)
|
||||
}
|
||||
assert.equal(ticketStand(text), null, 'ohne Konfigurationsstand-Zeile kein Vergleich')
|
||||
assert.equal(fehler.mock.callCount(), 1)
|
||||
assert.ok(!String(fehler.mock.calls[0].arguments.join(' ')).includes(doc.siteKey), 'das Log nennt keine Daten des Betriebs')
|
||||
} finally {
|
||||
fehler.mock.restore()
|
||||
}
|
||||
// danach funktioniert es wieder (der Fehler war einmalig)
|
||||
assert.ok((await termineTicketDetails(ctxFuer(doc))).includes('Konfigurationsstand: Version 1'))
|
||||
})
|
||||
|
||||
test('termineTicketDetails wirft nie: auch nicht ohne Dokument, ohne ctx oder bei fehlender Collection', async () => {
|
||||
const fehler = stumm()
|
||||
try {
|
||||
for (const ctx of [undefined, null, {}, { doc: null }, { doc: {} }, { doc: { $id: '' } }, { doc: { $id: 7 } }, ctxFuer({ $id: 'f_gibt_es_nicht', customerId: 'c1', projectId: 'p0', siteKey: SITE_KEY })]) {
|
||||
const text = await termineTicketDetails(ctx)
|
||||
assert.equal(typeof text, 'string', JSON.stringify(ctx))
|
||||
assert.ok(text.includes('Terminplaner'), JSON.stringify(ctx))
|
||||
assert.ok(text.length <= MAX && text.isWellFormed())
|
||||
}
|
||||
} finally {
|
||||
fehler.mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('termineTicketDetails: Namen mit Markup bleiben Text, halbe Zeichenpaare werden ersetzt', async () => {
|
||||
const e = einstellung()
|
||||
e.leistungen[0].name = `Schnitt <b>x</b> \uD83D${'x'.repeat(8)}😀`
|
||||
const projekt = { projectName: `Salon \uD83D <i>x</i>`, liveDomain: 'haarlinie.example' }
|
||||
const { doc } = await bestelle({ e, projekt })
|
||||
const text = await termineTicketDetails(ctxFuer(doc, projekt, { name: `Kunde \uDE00 & Co` }))
|
||||
assert.ok(text.isWellFormed() && !HALBES_PAAR.test(text))
|
||||
assert.ok(text.includes('<b>x</b>') && text.includes('<i>x</i>'), 'Klartext, kein HTML')
|
||||
assert.ok(text.includes('😀'))
|
||||
// ohne Kunde: die Kunden-ID wie in den Notfall-Details
|
||||
assert.ok((await termineTicketDetails(ctxFuer(doc, projekt, null))).includes('Kunde: c1'))
|
||||
})
|
||||
|
||||
test('termineTicketDetails: Gesundheit (Physiotherapie) und Maximalbefuellung: enthaelt Physio und bleibt hoechstens 7000 Zeichen', async () => {
|
||||
const e = maximalEinstellung()
|
||||
const meldungen = gesundheitsMeldungen(e)
|
||||
assert.equal(meldungen.length, 10)
|
||||
const { doc } = await bestelle({ e, fb: MAX_FRAGEBOGEN, version: 12 })
|
||||
const text = await termineTicketDetails(ctxFuer(doc))
|
||||
assert.ok(text.length <= MAX, `kurz ${text.length}`)
|
||||
assert.ok(text.includes('Physio'))
|
||||
assert.ok(text.includes(`Gesundheitsbereich prüfen:\n${meldungen.map((m) => `- ${m}`).join('\n')}`), 'alle zehn Meldungen')
|
||||
assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`))
|
||||
assert.ok(text.includes('Nicht auf das Stundenkontingent anrechnen'))
|
||||
assert.equal(ticketStand(text), 12)
|
||||
assert.ok(!text.includes('Online-Terminbuchung'))
|
||||
for (const r of e.ressourcen) assert.ok(!text.includes(r.feedToken))
|
||||
assert.ok(!text.includes(GEKUERZT), 'die Budgetliste laesst Teile weg und schneidet nicht ab')
|
||||
// ticketTexte kuerzt nichts mehr
|
||||
const texte = await reg.ticketTexte(ctxFuer(doc))
|
||||
assert.equal(texte.details, text)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------- Registry ---
|
||||
test('Registry: featureConfig("termine").ticket.details(ctx) liefert den Text von termineTicketDetails, siteKey und $id aus ctx.doc', async () => {
|
||||
const { doc } = await bestelle({ e: einstellung() })
|
||||
const feature = reg.featureConfig('termine')
|
||||
assert.equal(feature.ticket.topic(PROJEKT), 'Terminplaner einbauen – Salon Haarlinie')
|
||||
// das Register-Objekt traegt keinen siteKey: der Text darf nur aus ctx.doc kommen
|
||||
const ctx = { feature: { ...feature, siteKey: 'FALSCH_AUS_REGISTER', $id: 'f_falsch' }, doc, project: PROJEKT, customer: { name: 'Kunde Eins' } }
|
||||
const ueberRegister = await feature.ticket.details(ctx)
|
||||
assert.equal(ueberRegister, await termineTicketDetails(ctx))
|
||||
assert.equal(ueberRegister, await termineTicketDetails(ctxFuer(doc)))
|
||||
assert.ok(ueberRegister.includes(doc.siteKey) && !ueberRegister.includes('FALSCH_AUS_REGISTER'))
|
||||
assert.ok(ueberRegister.includes('Konfigurationsstand: Version 1'), 'gelesen wurde das Dokument der Funktion (doc.$id)')
|
||||
})
|
||||
|
||||
// ----------------------------------------------------------------------- Integration (Review Focus 1) ---
|
||||
const workorder = (doc) => aw.docs('workorders').get(doc.ticketId)
|
||||
|
||||
test('markOrdered + markPaid: das Ticket traegt die Kurzfassung (hoechstens 7000, mit Snippet), die Stand-Zeile und der Vergleich meldet Aenderungen', async () => {
|
||||
const { sessionId, doc: bestellt } = await bestelle({ e: maximalEinstellung(), fb: MAX_FRAGEBOGEN })
|
||||
const { doc, created, fall } = await sf.markPaid({ sessionId })
|
||||
assert.deepEqual([created, fall, doc.state], [true, null, 'setup'])
|
||||
const ticket = workorder(doc)
|
||||
assert.ok(ticket.details.length <= MAX, `details ${ticket.details.length}`)
|
||||
assert.ok(ticket.details.includes(`data-webklar-termine="${bestellt.siteKey}"`))
|
||||
assert.ok(ticket.details.includes('Einrichtung bezahlt, 149 €'), 'markPaid uebergibt das bezahlte Dokument (unlockedByPurchase)')
|
||||
assert.ok(ticket.details.includes('Physio'))
|
||||
assert.ok(!ticket.details.includes(GEKUERZT))
|
||||
assert.equal(ticket.topic, 'Terminplaner einbauen – Salon Haarlinie')
|
||||
// Ticket Version 1; die Einstellungen aendern sich spaeter: der Vergleich (Team-Route, D8) meldet es
|
||||
const ticketVersion = ticketStand(ticket.details)
|
||||
assert.equal(ticketVersion, 1)
|
||||
assert.equal(driftWarnung(ticketVersion, 1), null)
|
||||
assert.equal(driftWarnung(ticketVersion, 4), 'Einstellungen seit der Bestellung geändert (Ticket Version 1, jetzt 4).')
|
||||
})
|
||||
|
||||
test('markPaid mit kaputtem termineConfig-Lesen legt das Ticket trotzdem an: created true, Notfall-Details mit Kopf, Hinweis und Link', async () => {
|
||||
const { projectId, sessionId } = await bestelle({ e: einstellung() })
|
||||
const fehler = stumm()
|
||||
try {
|
||||
aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 })
|
||||
const { doc, created, fall } = await sf.markPaid({ sessionId })
|
||||
assert.deepEqual([created, fall, doc.state], [true, null, 'setup'])
|
||||
assert.ok(doc.ticketId, 'Zahlung nie ohne Ticket')
|
||||
const ticket = workorder(doc)
|
||||
assert.ok(ticket.details.includes(`siteKey: ${doc.siteKey}`))
|
||||
assert.ok(ticket.details.includes('Anleitung konnte nicht erzeugt werden, im WOMS öffnen'))
|
||||
assert.ok(ticket.details.includes(reg.anleitungsLink('c1', projectId)))
|
||||
assert.ok(ticket.details.length <= MAX)
|
||||
} finally {
|
||||
fehler.mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('Webhook-Wiederholung nach einem Lesefehler: ohne Ticket bleibt setup fortsetzbar, die Kurzfassung entsteht beim zweiten Versuch', async () => {
|
||||
const { sessionId } = await bestelle({ e: einstellung() })
|
||||
const fehler = stumm()
|
||||
try {
|
||||
// das Anlegen des Tickets scheitert (nicht das Lesen): setup ohne ticketId
|
||||
aw.failNext({ collection: 'workorders', method: 'POST', status: 500 })
|
||||
await assert.rejects(() => sf.markPaid({ sessionId }))
|
||||
const { doc, created } = await sf.markPaid({ sessionId })
|
||||
assert.equal(created, true)
|
||||
assert.ok(workorder(doc).details.includes('Konfigurationsstand: Version 1'))
|
||||
} finally {
|
||||
fehler.mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('config.features.termine.ticket.details ruft die Datei lazy auf (config.js bleibt ohne statischen Dienst-Import)', async () => {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const src = readFileSync(new URL('../server/config.js', import.meta.url), 'utf8')
|
||||
assert.match(src, /import\('\.\/services\/termine\/ticketDetails\.js'\)/)
|
||||
assert.ok(!/^import .*ticketDetails/m.test(src))
|
||||
assert.equal(typeof config.features.termine.ticket.details, 'function')
|
||||
})
|
||||
@@ -0,0 +1,792 @@
|
||||
import { test, after, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { TERMINE_COLLECTIONS, TERMINE_DATETIMES } from '../server/services/termine/schema.js'
|
||||
|
||||
// Nachbau von Appwrite fuer den Test avv_keine_ip_in_buchung (eine echte Buchung ueber halte); config erst danach importieren
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
termineConfig: schemaKeys('termineConfig'),
|
||||
termineBookings: schemaKeys('termineBookings'),
|
||||
termineClaims: schemaKeys('termineClaims'),
|
||||
termineOutbox: schemaKeys('termineOutbox'),
|
||||
},
|
||||
datetimes: TERMINE_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
})
|
||||
after(() => aw.close())
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const { featureConfig } = await import('../server/services/featureRegistry.js')
|
||||
const { LOESCHFRIST_TAGE } = await import('../server/services/termine/frist.js')
|
||||
const { ENTWURF_TAGE } = await import('../server/services/termine/entwuerfe.js')
|
||||
const { normalisiereEinstellung } = await import('../server/services/termine/einstellungen.js')
|
||||
const { datenschutzAbsatz } = await import('../server/services/termine/rechtstexte.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
|
||||
const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js')
|
||||
const { halte } = await import('../server/services/termine/ablauf.js')
|
||||
const { loeschBezugsfeld } = await import('../server/services/termine/buchungen.js')
|
||||
after(() => setMailTransportForTests(null))
|
||||
|
||||
// Vertragsunterlagen des Terminplaners als Markdown: Leistungsbeschreibung (Task F4), AVV (Task F5).
|
||||
// Die Markdown-Dateien sind die Quelle der ausgelieferten Seiten (scripts/build-legal-html.mjs, Task F6).
|
||||
|
||||
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r/g, '')
|
||||
const norm = (text) => text.replace(/\s+/g, ' ').trim()
|
||||
const V = config.termine.avvVersion
|
||||
|
||||
// ----------------------------------------------------------------------------------------- Aufbau eines Dokuments
|
||||
|
||||
/** Zerlegt eine Vertragsunterlage: h1, Kopfzeile und Abschnitte (## N. Titel) mit ihren Absätzen "(n) …". */
|
||||
function lese(md) {
|
||||
const zeilen = md.split('\n')
|
||||
const h1 = zeilen.filter((z) => /^# /.test(z))
|
||||
const abschnitte = []
|
||||
let aktuell = null
|
||||
for (const zeile of zeilen) {
|
||||
const h2 = /^## (.+)$/.exec(zeile)
|
||||
if (h2) {
|
||||
aktuell = { titel: h2[1], zeilen: [] }
|
||||
abschnitte.push(aktuell)
|
||||
} else if (aktuell) {
|
||||
aktuell.zeilen.push(zeile)
|
||||
}
|
||||
}
|
||||
for (const a of abschnitte) {
|
||||
a.nummer = /^(\d+)\. /.exec(a.titel)?.[1] ?? null
|
||||
a.text = a.zeilen.join('\n')
|
||||
a.absaetze = [...a.text.matchAll(/^\((\d+)\) /gm)].map((m) => Number(m[1]))
|
||||
}
|
||||
return { h1, kopf: zeilen[2], abschnitte, md }
|
||||
}
|
||||
|
||||
const LB_MD = lies('docs/rechtliches/leistungsbeschreibung-terminplaner.md')
|
||||
const LB = lese(LB_MD)
|
||||
|
||||
const abschnitt = (doc, nummer) => doc.abschnitte.find((a) => a.nummer === String(nummer))
|
||||
|
||||
// ----------------------------------------------------------------------------------------- Leistungsbeschreibung
|
||||
|
||||
test('lb_aufbau: Titel, Kopf mit Fassung und die elf Abschnitte in fester Nummerierung', () => {
|
||||
assert.deepEqual(LB.h1, ['# Leistungsbeschreibung Terminplaner'])
|
||||
assert.equal(LB_MD.split('\n')[0], '# Leistungsbeschreibung Terminplaner')
|
||||
assert.equal(LB.kopf, `**Fassung ${V}** · Zusatzbedingungen zum Wartungs-Abo`)
|
||||
assert.deepEqual(LB.abschnitte.map((a) => a.titel), [
|
||||
'1. Geltung',
|
||||
'2. Leistung',
|
||||
'3. Nicht enthalten',
|
||||
'4. Voraussetzungen und Pflichten des Kunden',
|
||||
'5. Einrichtung und Preis',
|
||||
'6. Verfügbarkeit und Störungen',
|
||||
'7. Haftung für verpasste Termine',
|
||||
'8. Ende der Leistung',
|
||||
'9. Änderungen und Einstellung',
|
||||
'10. Widerruf',
|
||||
'11. Hinweise',
|
||||
])
|
||||
// Absätze sind lückenlos nummeriert: (1), (2), …
|
||||
for (const a of LB.abschnitte) {
|
||||
assert.ok(a.absaetze.length >= 1, `${a.titel}: keine Absätze`)
|
||||
assert.deepEqual(a.absaetze, a.absaetze.map((_, i) => i + 1), `${a.titel}: Absätze nicht lückenlos`)
|
||||
}
|
||||
})
|
||||
|
||||
test('lb_sauber: keine Platzhalter, Entwurfsmarken, Kommentare oder Kanzlei-Marker', () => {
|
||||
assert.doesNotMatch(LB_MD, /ENTWURF|Entwurf|Prüfpunkt|TODO|\{\{|\}\}|<!--|⚖|\bQ\d+\b/)
|
||||
assert.doesNotMatch(LB_MD, /\[|\]|…\s*$/m, 'offene Klammer oder Auslassung am Zeilenende')
|
||||
assert.doesNotMatch(LB_MD, /undefined|null|NaN/)
|
||||
assert.ok(!/```|^>|<[a-z]/im.test(LB_MD), 'nur die Markdown-Teilmenge: kein Codeblock, Zitat oder rohes HTML')
|
||||
})
|
||||
|
||||
test('lb_festwerte: Preis, Umsatzsteuer-Hinweis, Voraussetzung Wartungs-Abo und die Sätze von Nr. 8 wörtlich', () => {
|
||||
const text = norm(LB_MD)
|
||||
assert.ok(text.includes('149 €'))
|
||||
assert.ok(text.includes('§ 19 UStG'))
|
||||
assert.ok(text.includes(config.ustHinweis), 'Nr. 5: Hinweis wörtlich aus config.ustHinweis')
|
||||
assert.ok(text.includes('ein Hosting-Abo genügt nicht'))
|
||||
assert.ok(text.includes('Zusatzbedingungen zum Wartungs-Abo'))
|
||||
const nr8 = norm(abschnitt(LB, 8).text)
|
||||
for (const satz of [
|
||||
'Mit dem Ende des Wartungs-Abos endet der Terminplaner. WEBklar schaltet ihn spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo.',
|
||||
'WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten.',
|
||||
'Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Einrichtung nötig (neuer Fragebogen, neuer Preis nach Nr. 5).',
|
||||
'Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.',
|
||||
]) {
|
||||
assert.ok(nr8.includes(satz), `Nr. 8: Satz fehlt oder ist verändert: ${satz}`)
|
||||
}
|
||||
// Absatz 1, 3, 4, 5 stehen an der festgelegten Stelle
|
||||
const absatz = (n) => norm(abschnitt(LB, 8).text.split(/^\(\d+\) /m)[n])
|
||||
assert.ok(absatz(1).startsWith('Mit dem Ende des Wartungs-Abos endet der Terminplaner.'))
|
||||
assert.ok(absatz(3).startsWith('WEBklar löscht alle Termindaten'))
|
||||
assert.ok(absatz(4).startsWith('Beginnt innerhalb dieser 30 Tage'))
|
||||
assert.ok(absatz(5).startsWith('Endet das Wartungs-Abo, bevor'))
|
||||
// keine Einstellung ohne Grund
|
||||
assert.ok(norm(abschnitt(LB, 9).text).includes('Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen.'))
|
||||
})
|
||||
|
||||
test('lb_preis_gleich_register: Preis der LB = priceLabel des Registers (Nr. 5)', () => {
|
||||
const nr5 = norm(abschnitt(LB, 5).text)
|
||||
const preis = /kostet (\d+ €) einmalig/.exec(nr5)
|
||||
assert.ok(preis, 'Nr. 5 nennt den Preis')
|
||||
assert.equal(preis[1], featureConfig('termine').priceLabel)
|
||||
assert.equal(config.termineSetup.amountCents, 14900)
|
||||
assert.equal(`${config.termineSetup.amountCents / 100} €`, preis[1])
|
||||
// der Preis steht nur einmal in der LB
|
||||
assert.equal(norm(LB_MD).split('149 €').length - 1, 1)
|
||||
})
|
||||
|
||||
test('lb_fristen_gleich_code: 30 Tage bis zur Löschung = LOESCHFRIST_TAGE (termine/frist.js)', () => {
|
||||
assert.equal(LOESCHFRIST_TAGE, 30)
|
||||
const nr8 = norm(abschnitt(LB, 8).text)
|
||||
const loeschung = /löscht alle Termindaten und die Einstellungen (\d+) Tage nach dem Abschalten/.exec(nr8)
|
||||
const rueckkehr = /Beginnt innerhalb dieser (\d+) Tage wieder/.exec(nr8)
|
||||
assert.ok(loeschung && rueckkehr)
|
||||
assert.equal(Number(loeschung[1]), LOESCHFRIST_TAGE)
|
||||
assert.equal(Number(rueckkehr[1]), LOESCHFRIST_TAGE)
|
||||
// die Abschalt-Frist "spätestens am folgenden Tag" steht nur in Nr. 8 Abs. 1
|
||||
assert.equal(norm(LB_MD).split('spätestens am folgenden Tag').length - 1, 1)
|
||||
})
|
||||
|
||||
/**
|
||||
* Verweise: "Nr. N [Abs. M]" auf diese LB (ohne Zusatz), "Nummer N des AVV Terminplaner", "Leistungsbeschreibung B Nr. N der
|
||||
* Abo-Bedingungen" und "§ N [Abs. M] der Abo-Bedingungen" müssen auf vorhandene Abschnitte zeigen.
|
||||
*/
|
||||
test('lb_verweise: jedes „Nr. N“ auf LB oder AVV und jedes „§ N der Abo-Bedingungen“ existiert', () => {
|
||||
const text = norm(LB_MD)
|
||||
|
||||
// 1. Verweise auf diese Leistungsbeschreibung: "Nr. N", "Nr. N Abs. M", "nach Nr. N"
|
||||
const eigene = []
|
||||
for (const m of text.matchAll(/\bNr\. (\d+)(?: Abs\. (\d+))?(?! der Abo-Bedingungen)/g)) {
|
||||
const davor = text.slice(Math.max(0, m.index - 40), m.index)
|
||||
if (/Leistungsbeschreibung B $/.test(davor)) continue // Abo-Bedingungen, siehe unten
|
||||
eigene.push({ nr: m[1], abs: m[2], zitat: m[0] })
|
||||
}
|
||||
assert.ok(eigene.length >= 8, `zu wenige interne Verweise gefunden: ${eigene.length}`)
|
||||
for (const v of eigene) {
|
||||
const a = abschnitt(LB, v.nr)
|
||||
assert.ok(a, `„${v.zitat}“: Abschnitt ${v.nr} gibt es nicht`)
|
||||
if (v.abs) assert.ok(a.absaetze.includes(Number(v.abs)), `„${v.zitat}“: Absatz ${v.abs} gibt es nicht in Nr. ${v.nr}`)
|
||||
}
|
||||
|
||||
// 2. Verweise auf den AVV Terminplaner (Vorbemerkung, Nr. 1 bis 12, Anlage): Nummernbereich; ist der AVV schon geschrieben, auch seine Überschriften
|
||||
const avvNummern = [...text.matchAll(/Nummer (\d+)(?: Abs\. (\d+))? (?:des )?AVV Terminplaner|AVV Terminplaner Nummer (\d+)(?: Abs\. (\d+))?/g)]
|
||||
assert.ok(avvNummern.length >= 2)
|
||||
const avvPfad = 'docs/rechtliches/avv-terminplaner.md'
|
||||
const avv = existsSync(new URL(`../${avvPfad}`, import.meta.url)) ? lese(lies(avvPfad)) : null
|
||||
for (const m of avvNummern) {
|
||||
const n = Number(m[1] ?? m[3])
|
||||
assert.ok(n >= 1 && n <= 12, `„${m[0]}“: der AVV hat Nr. 1 bis 12`)
|
||||
if (avv) assert.ok(abschnitt(avv, n), `„${m[0]}“: Abschnitt ${n} fehlt im AVV`)
|
||||
}
|
||||
|
||||
// 3. Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen: "Nicht enthalten" im Wartungs-Abo
|
||||
const abo = lies('public/abo-bedingungen.html')
|
||||
const wartung = abo.slice(abo.indexOf('<h2>Wartungs-Abo</h2>'), abo.indexOf('<h2 id="widerruf">'))
|
||||
assert.ok(wartung.includes('<h3>3. Nicht enthalten</h3>'))
|
||||
assert.ok(text.includes('Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“)'))
|
||||
assert.ok(/Texte, Bilder und Rechtstexte \(Impressum, Datenschutzerklärung\)/.test(wartung.replace(/<[^>]+>/g, '')))
|
||||
|
||||
// 4. Paragrafen der Abo-Bedingungen: § N existiert, Absatz M existiert (Aufzählungspunkte bzw. Überschriften "(M) …" des Paragrafen)
|
||||
const paragrafen = [...text.matchAll(/§§? (\d+)(?: Abs\. (\d+)(?: und (\d+))?)?(?: und (\d+))? der Abo-Bedingungen/g)]
|
||||
assert.ok(paragrafen.length >= 7, `zu wenige Verweise auf die Abo-Bedingungen: ${paragrafen.length}`)
|
||||
const nummern = new Set()
|
||||
for (const m of paragrafen) {
|
||||
for (const n of [m[1], m[4]].filter(Boolean)) nummern.add(n)
|
||||
const start = abo.indexOf(`<h2 id="p${m[1]}">§ ${m[1]} `)
|
||||
assert.ok(start > 0, `„${m[0]}“: § ${m[1]} gibt es in den Abo-Bedingungen nicht`)
|
||||
const ende = abo.indexOf('<h2', start + 10)
|
||||
const stueck = abo.slice(start, ende)
|
||||
const anzahl = Math.max((stueck.match(/<li>/g) || []).length, (stueck.match(/<h3>\(\d+\)/g) || []).length)
|
||||
for (const abs of [m[2], m[3]].filter(Boolean)) {
|
||||
assert.ok(Number(abs) <= anzahl, `„${m[0]}“: § ${m[1]} hat keinen Absatz ${abs}`)
|
||||
}
|
||||
}
|
||||
// "§§ 12 und 13 der Abo-Bedingungen" nennt beide Paragrafen
|
||||
assert.ok(nummern.has('12') && nummern.has('13'))
|
||||
for (const n of ['3', '4', '5', '6', '12', '13']) assert.ok(nummern.has(n), `§ ${n} der Abo-Bedingungen wird zitiert`)
|
||||
// § 327r BGB und § 3 BFSG sind Gesetzesverweise, keine Verweise in die Abo-Bedingungen
|
||||
assert.ok(text.includes('§ 327r Abs. 3 BGB') && text.includes('§ 3 Abs. 3 BFSG'))
|
||||
|
||||
// 5. Die Anlage zur Widerrufsbelehrung heißt wie die Überschrift der Datei
|
||||
const anlage = lies('docs/rechtliches/widerrufsbelehrung-funktionen.md').split('\n')[0].replace(/^# /, '')
|
||||
assert.ok(text.includes(`„${anlage}“`), 'Nr. 10 nennt die Anlage mit dem Titel der Widerrufsbelehrung')
|
||||
})
|
||||
|
||||
test('lb_ohne_du: Dritte Person und Sie-Form, keine Du-Form', () => {
|
||||
assert.doesNotMatch(LB_MD, /\bdein|\bdu\b|\bdir\b|\bdich\b|\beuch\b|\beuer/i)
|
||||
assert.ok(/\bder Kunde\b/.test(LB_MD))
|
||||
})
|
||||
|
||||
test('lb_bfsg_ohne_konformitaetszusage: keine Zusage, das Widget erfülle WCAG oder EN 301 549', () => {
|
||||
const nr11 = norm(abschnitt(LB, 11).text)
|
||||
assert.ok(nr11.includes('Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu.'))
|
||||
assert.ok(nr11.includes('Ob der Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde.'))
|
||||
assert.ok(nr11.includes('Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region).'))
|
||||
const alles = norm(LB_MD)
|
||||
assert.doesNotMatch(alles, /WCAG[^.]*\b(entspricht|erfüllt|erfüllen|barrierefrei|konform ist|sind konform)\b/i)
|
||||
assert.doesNotMatch(alles, /\b(entspricht|erfüllt|erfüllen)\b[^.]*(WCAG|EN 301 549|BFSG)/i)
|
||||
assert.doesNotMatch(alles, /vollständig barrierefrei|barrierefrei nach/i)
|
||||
})
|
||||
|
||||
test('lb_inhalt: Rangfolge, Gästetexte, Gesundheitsbereich, Datenschutz-Absatz, Zahlung nur vor Ort', () => {
|
||||
const nr1 = norm(abschnitt(LB, 1).text)
|
||||
assert.ok(nr1.includes('Bei Widersprüchen geht für den Terminplaner diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Terminplaner.'))
|
||||
assert.ok(nr1.includes('Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“) gilt nicht für den Datenschutz-Absatz'))
|
||||
const nr2 = norm(abschnitt(LB, 2).text)
|
||||
assert.ok(nr2.includes('Bezahlt wird nur vor Ort.'))
|
||||
assert.ok(nr2.includes('Mustertexte der Standardkonfiguration'))
|
||||
for (const wort of ['geheimen Kalender-Link (ICS)', 'CSV-Datei', 'Missbrauchsschutz', 'Bestätigungslink', 'Erinnerung (nur mit seinem Haken)', 'Widerrufs']) {
|
||||
assert.ok(nr2.includes(wort), `Nr. 2 nennt ${wort}`)
|
||||
}
|
||||
const nr3 = norm(abschnitt(LB, 3).text)
|
||||
for (const wort of ['SMS', 'Online-Zahlung', 'Kalender-Abgleich', 'Konten für Gäste', 'Marketing-E-Mails', 'Gruppentermine', 'Mehrsprachigkeit', 'Gesundheitsbereich', 'zweites Widget', 'Sonderdesign', '§ 4 der Abo-Bedingungen']) {
|
||||
assert.ok(nr3.includes(wort), `Nr. 3 nennt ${wort}`)
|
||||
}
|
||||
const nr4 = norm(abschnitt(LB, 4).text)
|
||||
assert.ok(nr4.includes('Der Terminplaner ist nicht für Angebote im Gesundheitsbereich bestimmt'))
|
||||
assert.ok(nr4.includes('WEBklar darf die Einrichtung für eine solche Website ablehnen oder den Terminplaner dort abschalten.'))
|
||||
assert.ok(nr4.includes('Kinder unter 16 Jahren'))
|
||||
assert.ok(nr4.includes('wird nicht auf das Stundenkontingent angerechnet'))
|
||||
assert.ok(nr4.includes('Textbaustein'))
|
||||
// Keine Gesundheitsangaben anfordern: der Text fordert nirgends welche an
|
||||
assert.doesNotMatch(LB_MD, /Gesundheitsdaten (erfassen|speichern)|Diagnose|Patient/i)
|
||||
const nr5 = norm(abschnitt(LB, 5).text)
|
||||
for (const wort of ['Fragebogen', 'Einbau', 'Abnahme', 'Freischaltung', 'Stripe', 'Rechnung', 'keine verbindliche Zusage eines Termins', 'weder auf das Kontingent angerechnet noch damit bezahlt']) {
|
||||
assert.ok(nr5.includes(wort), `Nr. 5 nennt ${wort}`)
|
||||
}
|
||||
const nr7 = norm(abschnitt(LB, 7).text)
|
||||
assert.ok(nr7.includes('§ 13 Abs. 1 und 2 der Abo-Bedingungen'))
|
||||
const nr10 = norm(abschnitt(LB, 10).text)
|
||||
assert.ok(nr10.includes('Mit dem Widerruf endet der Terminplaner.'))
|
||||
})
|
||||
|
||||
// ============================================================================================ AVV Terminplaner (Task F5)
|
||||
|
||||
const AVV_PFAD = 'docs/rechtliches/avv-terminplaner.md'
|
||||
const AVV_MD = lies(AVV_PFAD)
|
||||
const AVV = lese(AVV_MD)
|
||||
const ABO = lies('public/abo-bedingungen.html')
|
||||
const JOBS = lies('server/services/termine/jobs.js')
|
||||
const OUTBOX = lies('server/services/termine/outbox.js')
|
||||
|
||||
const avvNr = (n) => norm(abschnitt(AVV, n).text)
|
||||
const absatzTexte = (a) => a.text.split(/^\(\d+\) /m).slice(1).map(norm)
|
||||
|
||||
test('avv_aufbau: Titel, Kopf mit Fassung, Parteien, Vorbemerkung, Nr. 1 bis 12 und Anlage', () => {
|
||||
assert.deepEqual(AVV.h1, ['# Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO)'])
|
||||
assert.equal(AVV_MD.split('\n')[0], '# Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO)')
|
||||
assert.equal(AVV.kopf, `**Fassung ${V}**`)
|
||||
assert.equal(
|
||||
AVV_MD.split('\n')[4],
|
||||
'zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).',
|
||||
)
|
||||
assert.deepEqual(AVV.abschnitte.map((a) => a.titel), [
|
||||
'Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen',
|
||||
'1. Gegenstand und Dauer',
|
||||
'2. Art und Zweck der Verarbeitung',
|
||||
'3. Datenarten und Betroffene',
|
||||
'4. Weisungen',
|
||||
'5. Vertraulichkeit',
|
||||
'6. Technische und organisatorische Maßnahmen',
|
||||
'7. Unterauftragsverarbeiter',
|
||||
'8. Unterstützung des Kunden, Betroffenenrechte',
|
||||
'9. Löschung und Rückgabe',
|
||||
'10. Nachweise und Kontrollen',
|
||||
'11. Pflichten des Kunden',
|
||||
'12. Haftung, Änderungen, Schlussbestimmungen',
|
||||
'Anlage: Technische und organisatorische Maßnahmen des Terminplaners (Art. 32 DSGVO)',
|
||||
])
|
||||
// Absätze sind lückenlos nummeriert; Nr. 5 und Nr. 10 bestehen aus einem Absatz ohne Nummer, die Anlage ist eine Tabelle
|
||||
for (const a of AVV.abschnitte) {
|
||||
if (['5', '10'].includes(a.nummer) || a.titel.startsWith('Anlage:')) {
|
||||
assert.deepEqual(a.absaetze, [], `${a.titel}: kein nummerierter Absatz`)
|
||||
} else {
|
||||
assert.ok(a.absaetze.length >= 1, `${a.titel}: keine Absätze`)
|
||||
assert.deepEqual(a.absaetze, a.absaetze.map((_, i) => i + 1), `${a.titel}: Absätze nicht lückenlos`)
|
||||
}
|
||||
}
|
||||
// Vorbemerkung hat drei Absätze (Abschluss, Mitarbeiter, Verhältnis zu Anlage 1)
|
||||
assert.deepEqual(AVV.abschnitte[0].absaetze, [1, 2, 3])
|
||||
})
|
||||
|
||||
test('avv_sauber: keine Platzhalter, Entwurfsmarken, Kommentare oder Kanzlei-Marker', () => {
|
||||
assert.doesNotMatch(AVV_MD, /ENTWURF|Entwurf|Prüfpunkt|TODO|\{\{|\}\}|<!--|⚖|\bQ\d+\b/)
|
||||
assert.doesNotMatch(AVV_MD, /\[|\]|…\s*$/m, 'offene Klammer oder Auslassung am Zeilenende')
|
||||
assert.doesNotMatch(AVV_MD, /undefined|null|NaN/)
|
||||
assert.ok(!/```|^>|<[a-z]/im.test(AVV_MD), 'nur die Markdown-Teilmenge: kein Codeblock, Zitat oder rohes HTML')
|
||||
// keine Du-Form: der AVV spricht vom Kunden in der dritten Person
|
||||
assert.doesNotMatch(AVV_MD, /\bdein|\bdu\b|\bdir\b|\bdich\b|\beuch\b|\beuer/i)
|
||||
assert.ok(/\bder Kunde\b/.test(AVV_MD))
|
||||
// Tabelle: genau zwei Zellen je Zeile, kein weiteres | im Zelleninhalt
|
||||
const zeilen = AVV_MD.split('\n').filter((z) => z.startsWith('|'))
|
||||
assert.ok(zeilen.length >= 13, 'Kopf, Trennzeile und die Zeilen der Anlage')
|
||||
for (const z of zeilen) assert.equal(z.split('|').length, 4, `Tabellenzeile mit mehr als zwei Zellen: ${z.slice(0, 40)}`)
|
||||
assert.equal(zeilen[0], '| Bereich | Maßnahmen |')
|
||||
assert.equal(zeilen[1], '|---|---|')
|
||||
})
|
||||
|
||||
test('avv_unterauftragsverarbeiter: Nr. 7 nennt Hetzner mit Anschrift, Falkenstein und Nürnberg, kein Drittland', () => {
|
||||
const nr7 = avvNr(7)
|
||||
const abs1 = absatzTexte(abschnitt(AVV, 7))[0]
|
||||
assert.ok(abs1.includes('Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen'))
|
||||
assert.ok(abs1.includes('Falkenstein') && abs1.includes('Nürnberg'))
|
||||
assert.match(abs1, /Betrieb des Servers[\s\S]*Sicherung[\s\S]*Falkenstein[\s\S]*Versand der Buchungs-E-Mails[\s\S]*Webhostings[\s\S]*Nürnberg/)
|
||||
assert.ok(abs1.includes('Eine Übermittlung in Drittländer findet nicht statt.'))
|
||||
assert.doesNotMatch(nr7, /\bUSA\b|Vereinigte(n)? Staaten|Standardvertragsklauseln|Angemessenheitsbeschluss|Data Privacy Framework|Drittland(?!länder)/)
|
||||
// Muster Analyse-AVV Nr. 7: vier Wochen Vorlauf, zwei Wochen Widerspruch, Verpflichtung nach Art. 28 Abs. 4
|
||||
assert.ok(nr7.includes('mindestens vier Wochen vorher in Textform'))
|
||||
assert.ok(nr7.includes('innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen'))
|
||||
assert.ok(nr7.includes('Art. 28 Abs. 4 DSGVO'))
|
||||
// Stripe betrifft nur den Vertrag zwischen Kunde und WEBklar
|
||||
assert.ok(nr7.includes('Die Zahlung der Einrichtung über Stripe betrifft nur den Vertrag zwischen dem Kunden und WEBklar.'))
|
||||
assert.ok(nr7.includes('Stripe ist deshalb kein Unterauftragsverarbeiter dieses Vertrags.'))
|
||||
// dieselben Orte wie Anlage 3 der Abo-Bedingungen (Server: Falkenstein, E-Mail-Postfächer/Webhosting: Nürnberg)
|
||||
assert.ok(ABO.includes('Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen'))
|
||||
assert.ok(ABO.includes('Deutschland (Rechenzentrum Falkenstein)') && ABO.includes('Deutschland (Rechenzentrum Nürnberg)'))
|
||||
})
|
||||
|
||||
/** Die Konstante `const NAME = N * (STUNDE|TAG)_MS` aus einem Quelltext als { n, einheit }. */
|
||||
function konstante(quelle, name) {
|
||||
const m = new RegExp(`const ${name} = (\\d+) \\* (STUNDE|TAG)_MS`).exec(quelle)
|
||||
assert.ok(m, `${name} fehlt im Quelltext`)
|
||||
return { n: Number(m[1]), einheit: m[2] === 'STUNDE' ? 'Stunden' : 'Tage' }
|
||||
}
|
||||
|
||||
/** Eingabe einer gültigen Einstellung (wie test/termineAblaufGast.test.js, ohne aufbewahrungTage). */
|
||||
const R1 = 'r_aaaaaaa1'
|
||||
const S_SOFORT = 's_bbbbbbb1'
|
||||
const eingabeEinstellung = (over = {}) => ({
|
||||
betrieb: {
|
||||
name: 'Salon Müller',
|
||||
email: 'info@salon.de',
|
||||
telefon: '06301 1234',
|
||||
adresse: 'Hauptstr. 1, 67722 Winnweiler',
|
||||
websiteUrl: 'https://salon.de',
|
||||
impressumUrl: 'https://salon.de/impressum',
|
||||
datenschutzUrl: 'https://salon.de/datenschutz',
|
||||
},
|
||||
anrede: 'sie',
|
||||
bundesland: 'RP',
|
||||
ressourcen: [{ id: R1, name: 'Anna', plan: { 1: [['09:00', '18:00']] } }],
|
||||
leistungen: [{ id: S_SOFORT, name: 'Damenschnitt', dauer: 45, pufferNach: 5, preisArt: 'fest', preisCent: 4500, buchungsart: 'sofort', ressourcen: [R1] }],
|
||||
regeln: { raster: 15, vorlaufMin: 120, horizontTage: 90, absageBisStd: 24, maxOffeneJeEmail: 3, personWaehlbar: true },
|
||||
gastFelder: { telefon: 'optional', nachricht: 'aus', zusatz: [] },
|
||||
erinnerung: { anbieten: true, stundenVorher: 24 },
|
||||
storno: { text: 'Kostenlose Absage bis 24 Stunden vor dem Termin.' },
|
||||
recht: { gaesteSind: 'verbraucher', freizeit: false },
|
||||
zahlung: { modus: 'vorort', vorOrtArten: ['bar', 'ec'] },
|
||||
...over,
|
||||
})
|
||||
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin
|
||||
const einstellungMit = (over = {}) => normalisiereEinstellung(eingabeEinstellung(over), { now: JETZT })
|
||||
|
||||
test('avv_loeschfristen_gleich_code: Nr. 9 nennt genau die Fristen, die der Löschlauf anwendet', () => {
|
||||
const nr9 = avvNr(9)
|
||||
const zahlen = (re) => {
|
||||
const m = re.exec(nr9)
|
||||
assert.ok(m, `Nr. 9: Satz fehlt oder ist verändert: ${re}`)
|
||||
return m.slice(1).map(Number)
|
||||
}
|
||||
|
||||
// (1) nicht bestätigt: 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten (jobs.js VERFALLEN_BEHALTEN_MS, config.termine.holdMinutes)
|
||||
const [verfall, haltezeit] = zahlen(/Nicht bestätigte Buchungen löscht WEBklar (\d+) Stunden nach Ablauf der Bestätigungsfrist von (\d+) Minuten\./)
|
||||
assert.deepEqual(konstante(JOBS, 'VERFALLEN_BEHALTEN_MS'), { n: verfall, einheit: 'Stunden' })
|
||||
assert.equal(config.termine.holdMinutes, haltezeit)
|
||||
|
||||
// (2) abgesagt/abgelehnt: 30 Tage (jobs.js ABGESAGT_BEHALTEN_MS)
|
||||
const [abgesagt] = zahlen(/Abgesagte und abgelehnte Termine löscht WEBklar (\d+) Tage nach der Absage oder Ablehnung\./)
|
||||
assert.deepEqual(konstante(JOBS, 'ABGESAGT_BEHALTEN_MS'), { n: abgesagt, einheit: 'Tage' })
|
||||
|
||||
// (3) bestätigt/erledigt: Vorgabe 90, einstellbar 14 bis 365 Tage nach Terminende (jobs.js AUFBEWAHRUNG_*, einstellungen.js)
|
||||
const [vorgabe, minimum, maximum] = zahlen(/die Vorgabe sind (\d+) Tage, einstellbar sind (\d+) bis (\d+) Tage\./)
|
||||
const konst = (name) => Number(new RegExp(`const ${name} = (\\d+)`).exec(JOBS)?.[1])
|
||||
assert.deepEqual([konst('AUFBEWAHRUNG_VORGABE'), konst('AUFBEWAHRUNG_MIN'), konst('AUFBEWAHRUNG_MAX')], [vorgabe, minimum, maximum])
|
||||
assert.equal(einstellungMit().einstellung.aufbewahrungTage, vorgabe, 'Vorgabe der Einstellung')
|
||||
for (const tage of [minimum, maximum]) assert.equal(einstellungMit({ aufbewahrungTage: tage }).einstellung.aufbewahrungTage, tage)
|
||||
for (const tage of [minimum - 1, maximum + 1]) assert.ok(einstellungMit({ aufbewahrungTage: tage }).fehler?.aufbewahrungTage, `${tage} Tage sind nicht erlaubt`)
|
||||
assert.ok(nr9.includes('gerechnet ab dem Ende des Termins'))
|
||||
|
||||
// (4) Versandwarteschlange: gesendet 7 Tage, nicht zustellbar 48 Stunden (outbox.js GESENDET_BEHALTEN_MS, FEHLER_BEHALTEN_MS)
|
||||
const [gesendet, fehler] = zahlen(/löscht den Eintrag (\d+) Tage nach dem Versand\. E-Mails, die nicht zugestellt werden können, löscht WEBklar mit Empfängeradresse und Inhalt (\d+) Stunden nach dem endgültigen Fehlschlag\./)
|
||||
assert.deepEqual(konstante(OUTBOX, 'GESENDET_BEHALTEN_MS'), { n: gesendet, einheit: 'Tage' })
|
||||
assert.deepEqual(konstante(OUTBOX, 'FEHLER_BEHALTEN_MS'), { n: fehler, einheit: 'Stunden' })
|
||||
assert.ok(nr9.includes('leert WEBklar die Empfängeradresse und den Inhalt einer E-Mail nach dem Versand'))
|
||||
|
||||
// (6) Abschalten: LOESCHFRIST_TAGE (termine/frist.js), dieselbe Zahl wie in der Leistungsbeschreibung Nr. 8
|
||||
const [abschalten] = zahlen(/löscht WEBklar alle Termindaten und die Einstellungen (\d+) Tage danach\./)
|
||||
assert.equal(abschalten, LOESCHFRIST_TAGE)
|
||||
assert.equal(LOESCHFRIST_TAGE, 30)
|
||||
assert.ok(JOBS.includes('LOESCHFRIST_TAGE * TAG_MS'), 'jobs.js rechnet mit LOESCHFRIST_TAGE')
|
||||
assert.ok(nr9.includes('Beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen wieder ein (Nr. 8 Abs. 4 der Leistungsbeschreibung).'))
|
||||
assert.ok(nr9.includes('kann der Kunde die Termine im Kundenportal als CSV-Datei exportieren'))
|
||||
|
||||
// (7) Entwürfe: ENTWURF_TAGE (entwuerfe.js)
|
||||
const [entwurf] = zahlen(/Entwürfe aus dem Fragebogen, aus denen keine Bestellung geworden ist, löscht WEBklar (\d+) Tage nach der letzten Änderung\./)
|
||||
assert.equal(ENTWURF_TAGE, entwurf)
|
||||
|
||||
// (8) Sicherung: bis zu sieben Tage (§ 3 Abs. 5 der Abo-Bedingungen)
|
||||
assert.ok(nr9.includes('Gelöschte Daten bleiben dort bis zu sieben Tage erhalten'))
|
||||
assert.ok(ABO.includes('Verfügbar sind jeweils die letzten sieben Tagessicherungen.'))
|
||||
|
||||
// der Löschlauf läuft täglich und löscht nie vor der Frist (Deckel: der Rest folgt am nächsten Tag)
|
||||
assert.ok(nr9.includes('Der Löschlauf läuft täglich; gelöscht wird frühestens nach Ablauf der jeweiligen Frist.'))
|
||||
assert.ok(lies('server/index.js').includes('setInterval(termineTagesJob, 24 * 60 * 60 * 1000)'))
|
||||
})
|
||||
|
||||
test('avv_gleich_datenschutz_absatz: Fristen und Orte stimmen mit dem Datenschutz-Absatz überein, den der Kunde veröffentlicht', () => {
|
||||
const { einstellung } = einstellungMit()
|
||||
const absatz = norm(datenschutzAbsatz(einstellung, 'sie'))
|
||||
assert.ok(absatz.includes(`Bestätigungslink gilt ${config.termine.holdMinutes} Minuten`))
|
||||
assert.ok(absatz.includes('löschen wir 48 Stunden nach Ablauf dieser Frist'))
|
||||
assert.ok(absatz.includes(`Bestätigte Termine löschen wir ${einstellung.aufbewahrungTage} Tage nach dem Termin`))
|
||||
assert.ok(absatz.includes('abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung'))
|
||||
assert.ok(absatz.includes(`zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten ${LOESCHFRIST_TAGE} Tage nach dem Abschalten gelöscht`))
|
||||
assert.ok(absatz.includes('nicht zustellbare E-Mails nach 48 Stunden'))
|
||||
assert.ok(absatz.includes('Rechenzentren Falkenstein und Nürnberg') && absatz.includes('Eine Übermittlung in Drittländer findet nicht statt.'))
|
||||
assert.ok(absatz.includes('Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach 14 Tagen'))
|
||||
// unbeantwortete Terminanfragen: derselbe Satz im Datenschutz-Absatz des Kunden und im AVV (Nr. 9 Abs. 2), und der Code tut es
|
||||
assert.ok(absatz.includes('Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt.'))
|
||||
const nr9abs2 = absatzTexte(abschnitt(AVV, 9))[1]
|
||||
assert.ok(nr9abs2.startsWith('Abgesagte und abgelehnte Termine löscht WEBklar 30 Tage nach der Absage oder Ablehnung.'))
|
||||
assert.ok(nr9abs2.includes('Terminanfragen, die der Kunde bis zum angefragten Terminbeginn nicht beantwortet, behandelt der Terminplaner als abgelehnt; die 30 Tage laufen dann ab diesem Zeitpunkt.'))
|
||||
assert.ok(JOBS.includes("angefragt mit Beginn in der Vergangenheit => abgelehnt vom System"), 'jobs.js lehnt verstrichene Anfragen ab')
|
||||
assert.equal(loeschBezugsfeld('angefragt'), undefined, 'eine Anfrage wird nie unmittelbar gelöscht, erst als abgelehnte nach 30 Tagen')
|
||||
assert.equal(loeschBezugsfeld('abgelehnt'), 'updatedAt', 'die 30 Tage laufen ab der letzten Änderung, also ab der Ablehnung durch das System')
|
||||
// der AVV nennt dieselben Zahlen
|
||||
const nr3 = avvNr(3)
|
||||
assert.ok(nr3.includes('In den Server-Protokollen stehen sie 14 Tage (Anlage 2 der Abo-Bedingungen).'))
|
||||
})
|
||||
|
||||
test('avv_keine_ip_in_buchung: weder das Schema noch eine über halte gehaltene Buchung enthält IP-Adresse oder Browser-Kennzeichen', async () => {
|
||||
const nameIp = /^(ip|ipAdresse|clientIp|userAgent)$/i
|
||||
for (const [collection, def] of Object.entries(TERMINE_COLLECTIONS)) {
|
||||
const treffer = def.attributes.map((a) => a.key).filter((k) => nameIp.test(k))
|
||||
assert.deepEqual(treffer, [], `${collection}: Attribut für IP-Adresse oder Browser-Kennzeichen`)
|
||||
}
|
||||
// ein loses /ip/ träfe stripeSessionId und stripePaymentIntent: das Muster oben ist absichtlich genau
|
||||
assert.ok(TERMINE_COLLECTIONS.termineBookings.attributes.some((a) => a.key === 'stripeSessionId'))
|
||||
|
||||
// eine echte gehaltene Buchung: der Nachweis trägt keinen Schlüssel ip oder userAgent (auch nicht verschachtelt)
|
||||
const KUNDE = 'c_kunde01'
|
||||
const PROJEKT = 'p_projekt01'
|
||||
const FEATURE = 'f_p_projekt01_termine'
|
||||
const vorher = { mode: config.termine.mode, from: config.smtp.from, basis: config.publicBaseUrl }
|
||||
const log = { error: mock.method(console, 'error', () => {}), warn: mock.method(console, 'warn', () => {}) }
|
||||
try {
|
||||
config.termine.mode = 'pilot'
|
||||
config.smtp.from = 'noreply@webklar.com'
|
||||
config.publicBaseUrl = 'https://project.webklar.com'
|
||||
resetKontextForTests()
|
||||
resetOutboxForTests()
|
||||
const sink = mailSink()
|
||||
setMailTransportForTests(sink)
|
||||
const feature = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' }
|
||||
aw.seed('portalFeatures', feature)
|
||||
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, subdomain: 'salon' })
|
||||
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@example.com' })
|
||||
const { einstellung, fehler } = einstellungMit()
|
||||
assert.equal(fehler, undefined)
|
||||
await speichereEinstellung(feature, einstellung, { version: 0, now: JETZT })
|
||||
const ctx = { feature, project: { $id: PROJEKT, subdomain: 'salon' }, einstellung, version: 1 }
|
||||
const erg = await halte(
|
||||
ctx,
|
||||
{
|
||||
leistung: S_SOFORT, ressource: 'egal', tag: '2026-10-26', zeit: '10:00', name: 'Max Mustermann', email: 'max@example.com',
|
||||
telefon: '', nachricht: '', zusatz: {}, erinnerung: true, zustimmungen: { storno: true, vorzeitig: false },
|
||||
},
|
||||
JETZT,
|
||||
)
|
||||
await warteAufVersand()
|
||||
assert.equal(erg.status, 'gehalten')
|
||||
const buchungen = [...aw.docs('termineBookings').values()]
|
||||
assert.equal(buchungen.length, 1)
|
||||
const nachweis = JSON.parse(buchungen[0].nachweis)
|
||||
assert.ok(nachweis.fassung && nachweis.datenschutz, 'der Nachweis enthält Fassung und Hash des Datenschutz-Hinweises')
|
||||
// genau die Bestandteile, die AVV Nr. 3 Abs. 1 und Nr. 8 Abs. 5 nennen: Fassung, Zeitpunkt, Knopftext, Anrede, Hashes (keine weiteren Klartexte)
|
||||
const erlaubt = new Set(['fassung', 'am', 'knopf', 'anrede', 'datenschutz', 'storno', 'widerruf', 'vorzeitig', 'erinnerung'])
|
||||
assert.deepEqual(Object.keys(nachweis).filter((k) => !erlaubt.has(k)), [])
|
||||
for (const k of ['fassung', 'am', 'knopf', 'anrede']) assert.equal(typeof nachweis[k], 'string', k)
|
||||
assert.deepEqual(Object.keys(nachweis.storno), ['version', 'hash', 'haken', 'am'])
|
||||
assert.deepEqual(Object.keys(nachweis.erinnerung), ['stunden', 'hash', 'am'])
|
||||
const schluessel = (wert) => (wert && typeof wert === 'object' ? Object.entries(wert).flatMap(([k, v]) => [k, ...schluessel(v)]) : [])
|
||||
assert.deepEqual(schluessel(nachweis).filter((k) => nameIp.test(k)), [])
|
||||
// der Nachweis hält Hashes, keine Texte: jeder Hash ist 64 Hexzeichen
|
||||
for (const hash of [nachweis.datenschutz, nachweis.storno?.hash, nachweis.widerruf?.hash, nachweis.erinnerung?.hash]) assert.match(hash, /^[0-9a-f]{64}$/)
|
||||
// auch sonst enthält kein Feld der Buchung eine Adresse der Form n.n.n.n
|
||||
assert.doesNotMatch(JSON.stringify(buchungen[0]), /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/)
|
||||
} finally {
|
||||
log.error.mock.restore()
|
||||
log.warn.mock.restore()
|
||||
setMailTransportForTests(null)
|
||||
config.termine.mode = vorher.mode
|
||||
config.smtp.from = vorher.from
|
||||
config.publicBaseUrl = vorher.basis
|
||||
}
|
||||
})
|
||||
|
||||
/** Jedes Attribut einer Buchung ist in AVV Nr. 3 Abs. 1 genannt (Wortlaut) oder eine technische Kennung/Eigenschaft des Dienstes. */
|
||||
const BUCHUNG_IM_AVV = {
|
||||
gastName: 'Name und E-Mail-Adresse', gastEmail: 'Name und E-Mail-Adresse', gastTelefon: 'Telefonnummer', gastNachricht: 'eine Nachricht', gastZusatz: 'Zusatzfelder',
|
||||
leistungName: 'die gebuchte Leistung', ressourceName: 'die Person oder der Platz', startsAt: 'der Termin', endsAt: 'der Termin', blockEndsAt: 'der Termin',
|
||||
status: 'der Status der Buchung mit den Zeitpunkten', heldUntil: 'der Status der Buchung mit den Zeitpunkten', bestaetigtAt: 'der Status der Buchung mit den Zeitpunkten',
|
||||
angenommenAt: 'der Status der Buchung mit den Zeitpunkten', abgesagtAt: 'der Status der Buchung mit den Zeitpunkten', abgesagtVon: 'der Status der Buchung mit den Zeitpunkten',
|
||||
widerrufenAt: 'der Status der Buchung mit den Zeitpunkten', createdAt: 'der Status der Buchung mit den Zeitpunkten', updatedAt: 'der Status der Buchung mit den Zeitpunkten',
|
||||
absageGrund: 'Absagegrund und Vermerke zu einer Absage oder einem Widerruf', nachweis: 'der Nachweis der Zustimmungen',
|
||||
preisCent: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde', zahlart: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde',
|
||||
zahlStatus: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde', verbraucher: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde',
|
||||
erinnerung: 'der Wunsch nach einer Erinnerung', erinnerungGesendetAt: 'der Wunsch nach einer Erinnerung',
|
||||
}
|
||||
const BUCHUNG_TECHNISCH = ['featureId', 'customerId', 'projectId', 'leistungId', 'ressourceId', 'preisArt', 'buchungsart', 'quelle', 'verifyHash', 'manageHash', 'stripeSessionId', 'stripePaymentIntent', 'icsSequenz']
|
||||
|
||||
test('avv_datenarten_decken_schema: jedes Attribut einer Buchung steht in Nr. 3 Abs. 1 oder ist eine technische Kennung', () => {
|
||||
const nr3abs1 = norm(absatzTexte(abschnitt(AVV, 3))[0])
|
||||
const attribute = TERMINE_COLLECTIONS.termineBookings.attributes.map((a) => a.key)
|
||||
const unbekannt = attribute.filter((k) => !Object.hasOwn(BUCHUNG_IM_AVV, k) && !BUCHUNG_TECHNISCH.includes(k))
|
||||
assert.deepEqual(unbekannt, [], 'neues Attribut der Buchung: in AVV Nr. 3 Abs. 1 aufnehmen oder als technisch einstufen')
|
||||
for (const [k, wort] of Object.entries(BUCHUNG_IM_AVV)) {
|
||||
assert.ok(attribute.includes(k), `${k} gibt es nicht mehr im Schema`)
|
||||
assert.ok(nr3abs1.includes(wort), `${k}: „${wort}“ fehlt in Nr. 3 Abs. 1`)
|
||||
}
|
||||
for (const k of BUCHUNG_TECHNISCH) assert.ok(attribute.includes(k), `${k} gibt es nicht mehr im Schema`)
|
||||
})
|
||||
|
||||
test('avv_verweise_auf_lb: jedes „Nr. N Abs. M der Leistungsbeschreibung“ und jedes „Nummer N“ im AVV zeigt auf Vorhandenes', () => {
|
||||
const text = norm(AVV_MD)
|
||||
|
||||
// 1. Verweise auf die Leistungsbeschreibung Terminplaner
|
||||
const lbVerweise = [...text.matchAll(/\b(?:Nr\.|Nummer) (\d+)(?: Abs\. (\d+))? der Leistungsbeschreibung/g)]
|
||||
assert.ok(lbVerweise.length >= 8, `zu wenige Verweise auf die Leistungsbeschreibung: ${lbVerweise.length}`)
|
||||
for (const m of lbVerweise) {
|
||||
const a = abschnitt(LB, m[1])
|
||||
assert.ok(a, `„${m[0]}“: Abschnitt ${m[1]} gibt es in der Leistungsbeschreibung nicht`)
|
||||
if (m[2]) assert.ok(a.absaetze.includes(Number(m[2])), `„${m[0]}“: Absatz ${m[2]} gibt es in Nr. ${m[1]} nicht`)
|
||||
}
|
||||
// der Wortlaut der Verweise passt zum Inhalt der Stelle
|
||||
const lb = (n, abs) => absatzTexte(abschnitt(LB, n))[abs - 1]
|
||||
assert.ok(lb(8, 1).startsWith('Mit dem Ende des Wartungs-Abos endet der Terminplaner.'), 'Nr. 1 Abs. 2: Nr. 8 Abs. 1')
|
||||
assert.ok(lb(8, 4).startsWith('Beginnt innerhalb dieser 30 Tage'), 'Nr. 9 Abs. 6: Rückkehr nach Nr. 8 Abs. 4')
|
||||
assert.ok(lb(8, 6).startsWith('Auf Wunsch des Kunden in Textform schaltet WEBklar den Terminplaner jederzeit ab.'), 'Nr. 4 Abs. 2: Nr. 8 Abs. 6')
|
||||
assert.ok(lb(4, 9).startsWith('Liegt die Datenschutzerklärung in der von WEBklar betreuten Website'), 'Nr. 11 Abs. 2: Datenschutz-Absatz')
|
||||
assert.ok(lb(4, 7).startsWith('Er informiert seine Beschäftigten'), 'Nr. 11 Abs. 3: Beschäftigte')
|
||||
assert.ok(lb(4, 3).startsWith('Der Terminplaner ist nicht für Angebote im Gesundheitsbereich bestimmt'), 'Nr. 11 Abs. 4: Gesundheitsbereich')
|
||||
assert.ok(lb(4, 6).startsWith('Er lässt die Texte der Haken und Hinweise im Widget unverändert.'), 'Nr. 11 Abs. 5: Haken unverändert')
|
||||
assert.ok(lb(9, 2).startsWith('WEBklar darf den Terminplaner für eine Website mit einer Frist von vier Wochen beenden'), 'Nr. 12 Abs. 3: Beenden')
|
||||
assert.ok(lb(9, 3).startsWith('Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig'), 'Nr. 12 Abs. 3: Aussetzen')
|
||||
|
||||
// 2. Verweise innerhalb des AVV: „Nummer N [Abs. M]“ (ohne Zusatz „der Leistungsbeschreibung“)
|
||||
const eigene = [...text.matchAll(/\bNummer (\d+)(?: Abs\. (\d+))?(?! der Leistungsbeschreibung)/g)]
|
||||
assert.ok(eigene.length >= 6, `zu wenige interne Verweise: ${eigene.length}`)
|
||||
for (const m of eigene) {
|
||||
const a = abschnitt(AVV, m[1])
|
||||
assert.ok(a, `„${m[0]}“: Abschnitt ${m[1]} gibt es nicht`)
|
||||
if (m[2]) assert.ok(a.absaetze.includes(Number(m[2])), `„${m[0]}“: Absatz ${m[2]} gibt es in Nummer ${m[1]} nicht`)
|
||||
}
|
||||
// die Verweise der Leistungsbeschreibung auf den AVV zeigen auf Löschfristen (Nr. 9) und Änderungen (Nr. 12)
|
||||
assert.match(avvNr(9), /Löschung und Rückgabe|Nicht bestätigte Buchungen/)
|
||||
assert.match(avvNr(12), /neue Fassung/)
|
||||
|
||||
// 3. Paragrafen und Anlagen der Abo-Bedingungen
|
||||
const paragrafen = [...text.matchAll(/§ (\d+)(?: Abs\. (\d+))? der Abo-Bedingungen/g)]
|
||||
assert.ok(paragrafen.length >= 3)
|
||||
for (const m of paragrafen) {
|
||||
const start = ABO.indexOf(`<h2 id="p${m[1]}">§ ${m[1]} `)
|
||||
assert.ok(start > 0, `„${m[0]}“: § ${m[1]} gibt es in den Abo-Bedingungen nicht`)
|
||||
const stueck = ABO.slice(start, ABO.indexOf('<h2', start + 10))
|
||||
const anzahl = Math.max((stueck.match(/<li>/g) || []).length, (stueck.match(/<h3>\(\d+\)/g) || []).length)
|
||||
if (m[2]) assert.ok(Number(m[2]) <= anzahl, `„${m[0]}“: § ${m[1]} hat keinen Absatz ${m[2]}`)
|
||||
}
|
||||
assert.deepEqual([...new Set(paragrafen.map((m) => m[1]))].sort(), ['13', '16', '3'])
|
||||
assert.ok(ABO.includes('id="avv"') && ABO.includes('id="tom"'), 'Anlage 1 und Anlage 2 der Abo-Bedingungen')
|
||||
assert.ok(text.includes('Anlage 1 der Abo-Bedingungen') && text.includes('Anlage 2 der Abo-Bedingungen'))
|
||||
})
|
||||
|
||||
test('avv_ohne_analyse_bezug: Analyse-Kunden müssen nicht neu zustimmen, der AVV der Analyse bleibt unverändert', () => {
|
||||
// der Terminplaner-AVV ist ein eigenes Geschwister: kein Wort zur Analyse, kein Nachtrag
|
||||
assert.doesNotMatch(AVV_MD, /Analyse|Nachtrag|Zusatz zum|Änderung des Auftragsverarbeitungsvertrags/i)
|
||||
// der AVV der Analyse kennt den Terminplaner nicht und trägt weiter seine eigene Fassung
|
||||
const analyse = lies('docs/rechtliches/avv-website-analyse.md')
|
||||
assert.doesNotMatch(analyse, /Terminplaner|Termine/)
|
||||
assert.equal(analyse.split('\n')[2], `**Fassung ${config.analyse.avvVersion}**`)
|
||||
assert.notEqual(config.analyse.avvVersion, V, 'die Fassung der Analyse ändert sich nicht mit der des Terminplaners')
|
||||
// die beiden Dokumentsätze sind getrennt
|
||||
assert.notEqual(featureConfig('termine').avv, undefined)
|
||||
assert.ok(!String(featureConfig('termine').avv).includes('website-analyse'))
|
||||
})
|
||||
|
||||
test('avv_inhalt: Abschluss, Zwecke ohne Eigennutz, Datenarten, Weisungen, Datenpanne, Änderungen', () => {
|
||||
const vorbemerkung = norm(AVV.abschnitte[0].text)
|
||||
assert.ok(vorbemerkung.includes('Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person.'))
|
||||
assert.ok(vorbemerkung.includes('Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte und die Widerrufsbelehrung in dieser Fassung als PDF enthält'))
|
||||
assert.ok(vorbemerkung.includes('Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform'))
|
||||
assert.ok(vorbemerkung.includes('Der Kunde erhält die E-Mail nach Absatz 1 anschließend.'))
|
||||
assert.ok(vorbemerkung.includes('Für den Terminplaner geht er Anlage 1 vor'))
|
||||
assert.ok(vorbemerkung.includes('Anlage 1 der Abo-Bedingungen'))
|
||||
|
||||
const nr1 = avvNr(1)
|
||||
assert.ok(nr1.includes('Er endet mit dem Abschalten, spätestens mit dem Ende des Wartungs-Abos; den Terminplaner schaltet WEBklar dann spätestens am folgenden Tag ab'))
|
||||
assert.ok(nr1.includes('Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.'))
|
||||
|
||||
const nr2 = avvNr(2)
|
||||
assert.ok(nr2.includes('WEBklar verwendet die Daten für keine eigenen Zwecke'))
|
||||
assert.ok(nr2.includes('WEBklar bildet keine Profile einzelner Gäste.'))
|
||||
assert.ok(nr2.includes('Missbrauch'))
|
||||
|
||||
const nr3 = avvNr(3)
|
||||
for (const wort of [
|
||||
'Name und E-Mail-Adresse', 'Telefonnummer', 'Zusatzfelder', 'die gebuchte Leistung, die Person oder der Platz und der Termin', 'der Status der Buchung mit den Zeitpunkten',
|
||||
'Fassung der Gästetexte, Zeitpunkt, Text des Buchungsknopfs, Anrede und Hashes (Prüfsummen) der gezeigten Texte', 'der Wunsch nach einer Erinnerung', 'der Zustellstatus der E-Mails an den Gast',
|
||||
'Absagegrund und Vermerke zu einer Absage oder einem Widerruf', 'auch mit dem Namen aus einer Widerrufserklärung', 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde',
|
||||
'Namen oder Bezeichnungen der Personen oder Plätze', 'Arbeitszeiten und Abwesenheiten', 'geheimer Kalender-Link',
|
||||
]) assert.ok(nr3.includes(wort), `Nr. 3 nennt ${wort}`)
|
||||
assert.ok(nr3.includes('Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet.'))
|
||||
assert.ok(nr3.includes('Mit der Buchung wird keine IP-Adresse und kein Browser-Kennzeichen gespeichert.'))
|
||||
assert.ok(nr3.includes('nur im Arbeitsspeicher des Servers'))
|
||||
assert.ok(nr3.includes('Der Terminplaner setzt keine Cookies und speichert nichts im Browser des Gastes.'))
|
||||
|
||||
const nr4 = avvNr(4)
|
||||
assert.ok(nr4.includes('Mit der Bestellung weist der Kunde WEBklar an'))
|
||||
assert.ok(nr4.includes('Sonstige Weisungen erteilt der Kunde in Textform'))
|
||||
assert.ok(nr4.includes('Die Löschung auf Weisung nach Nummer 9 Abs. 6 setzt WEBklar immer um.'))
|
||||
|
||||
const nr8 = avvNr(8)
|
||||
assert.ok(nr8.includes('Gastdaten löschen') && nr8.includes('CSV-Datei'))
|
||||
assert.ok(nr8.includes('möglichst innerhalb von 24 Stunden nach Kenntnis'))
|
||||
assert.ok(nr8.includes('bis drei Jahre nach dem Ende des Terminplaners'))
|
||||
assert.ok(nr8.includes('die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der übrigen Texte'))
|
||||
assert.doesNotMatch(nr8, /nur den Zeitpunkt/)
|
||||
|
||||
const nr11 = avvNr(11)
|
||||
assert.ok(nr11.includes('Art. 13 DSGVO') && nr11.includes('Datenschutz-Absatz') && nr11.includes('Beschäftigten'))
|
||||
assert.ok(nr11.includes('nicht im Gesundheitsbereich'))
|
||||
assert.ok(nr11.includes('Haken für die Erinnerung'))
|
||||
|
||||
const nr12 = avvNr(12)
|
||||
assert.ok(nr12.includes('§ 13 der Abo-Bedingungen') && nr12.includes('Art. 82 DSGVO'))
|
||||
assert.ok(nr12.includes('mindestens sechs Wochen vor ihrem Wirksamwerden in Textform'))
|
||||
assert.ok(nr12.includes('Im Übrigen gilt § 16 der Abo-Bedingungen.'))
|
||||
})
|
||||
|
||||
/**
|
||||
* Fundstellen der Anlage TOM: jede Zeile der Tabelle nennt hier, wo im Code, in den Tests oder in den Abo-Bedingungen ihre
|
||||
* Aussage gesichert ist. { datei, hat } = die Datei enthält das Muster; { datei, nicht } = die Datei enthält es nicht.
|
||||
*/
|
||||
const TOM_FUNDSTELLEN = {
|
||||
'Übertragung': [
|
||||
{ datei: 'server/config.js', hat: /publicBaseUrl: \(process\.env\.PORTAL_PUBLIC_URL \|\| 'https:\/\/project\.webklar\.com'\)/ },
|
||||
{ datei: 'server/services/termine/einbauanleitung.js', hat: /beginnen mit https:\/\// },
|
||||
{ datei: 'test/termineEmbedStatic.test.js', hat: /keine festen Fremdadressen im Code/ },
|
||||
],
|
||||
'Speicher im Browser': [
|
||||
{ datei: 'test/termineEmbedStatic.test.js', hat: /'sessionStorage', 'document\.cookie', 'indexedDB', 'document\.referrer'/ }, // termineEmbedStatic.test.js:24
|
||||
{ datei: 'test/termineEmbedStatic.test.js', hat: /'resolvedOptions', 'getTimezoneOffset'/ },
|
||||
{ datei: 'public/embed/termine.js', hat: /credentials: 'omit'/ },
|
||||
{ datei: 'public/termin.js', nicht: /localStorage|sessionStorage|document\.cookie|indexedDB/ },
|
||||
{ datei: 'public/termin.html', nicht: /localStorage|sessionStorage|document\.cookie|indexedDB/ },
|
||||
{ datei: 'server/routes/publicTermine.js', nicht: /res\.cookie|Set-Cookie/i },
|
||||
],
|
||||
'Zugriffskontrolle': [
|
||||
{ datei: 'server/routes/termine.js', hat: /assertOwnProject\(customerId, projectId\)/ },
|
||||
{ datei: 'server/routes/termine.js', hat: /ctx\.feature\.customerId !== customerId/ },
|
||||
{ datei: 'server/routes/publicTermine.js', hat: /timingSafeEqual\(sha256\(r\.feedToken\), gegeben\)/ },
|
||||
{ datei: 'server/routes/publicTermine.js', hat: /POST und Preflight nur von Seiten des Betriebs/ },
|
||||
{ datei: 'server/services/termine/token.js', hat: /timingSafeEqual\(gegeben, erwartet\)/ },
|
||||
],
|
||||
'Missbrauchsschutz': [
|
||||
{ datei: 'server/routes/publicTermine.js', hat: /Honeypot kontakt_url und Formular-Token/ },
|
||||
{ datei: 'server/routes/publicTermine.js', hat: /config\.termine\.minAntwortMs/ },
|
||||
{ datei: 'server/services/termine/token.js', hat: /config\.termine\.formMinSekunden/ },
|
||||
{ datei: 'server/services/termine/limits.js', hat: /const MAX_SCHLUESSEL = 10000/ },
|
||||
{ datei: 'server/services/termine/limits.js', hat: /Ein Neustart des Prozesses leert alles/ },
|
||||
{ datei: 'server/services/termine/outbox.js', hat: /Stundenbudget \(config\.termine\.mailsProStunde\)/ },
|
||||
{ datei: 'server/services/termine/outbox.js', hat: /config\.termine\.mailsProFunktion/ },
|
||||
],
|
||||
'Datenminimierung': [
|
||||
{ datei: 'test/termineVertragsunterlagen.test.js', hat: /avv_keine_ip_in_buchung/ },
|
||||
{ datei: 'server/services/termine/ablauf.js', hat: /datenschutz: sha256\(formularHinweis\(e\)\)/ },
|
||||
{ datei: 'server/services/termine/einstellungen.js', hat: /wahl\(fehl, 'gastFelder\.telefon'/ },
|
||||
{ datei: 'server/services/termine/outbox.js', hat: /status: 'gesendet',[\s\S]*?an: '',\s*html: '',\s*text: '',\s*ics: ''/ },
|
||||
],
|
||||
'Trennung': [
|
||||
{ datei: 'server/services/termine/schema.js', hat: /Eine Einstellung je Funktion; Dokument-ID = ID des portalFeatures-Dokuments/ },
|
||||
{ datei: 'server/services/termine/schema.js', hat: /str\('featureId', 64\)/ },
|
||||
{ datei: 'server/services/termine/buchungen.js', hat: /Query\.equal\('featureId', featureId\)/ },
|
||||
// die Hintergrundläufe arbeiten über alle Funktionen (Löschlauf ohne Funktionsfilter, Aufräumen der Warteschlange nach Status) und löschen Dokument für Dokument
|
||||
{ datei: 'server/services/termine/buchungen.js', hat: /featureId === null \? \[\] : \[Query\.equal\('featureId', featureId\)\]/ },
|
||||
{ datei: 'server/services/termine/outbox.js', hat: /Query\.equal\('status', \['gesendet', 'fehler', 'verworfen'\]\)/ },
|
||||
],
|
||||
'Löschung': [
|
||||
{ datei: 'server/index.js', hat: /setInterval\(termineTagesJob, 24 \* 60 \* 60 \* 1000\)/ },
|
||||
{ datei: 'server/services/termine/jobs.js', hat: /Status und Bezugszeit noch einmal frisch geprueft/ },
|
||||
{ datei: 'server/services/termine/jobs.js', hat: /im Zweifel wird nichts geloescht/ },
|
||||
{ datei: 'server/services/termine/jobs.js', hat: /ABGESCHALTET_BEHALTEN_MS = LOESCHFRIST_TAGE \* TAG_MS/ },
|
||||
{ datei: 'server/services/termine/entwuerfe.js', hat: /export const ENTWURF_TAGE = 90/ },
|
||||
{ datei: 'server/services/termine/entwuerfe.js', hat: /const funktion = await holeOderNull\(config\.collections\.portalFeatures, id\)/ },
|
||||
// die Warteschlange löscht die gelisteten Einträge ohne erneutes Lesen (nur Buchungen, abgeschaltete Funktionen und Entwürfe lesen frisch)
|
||||
{ datei: 'server/services/termine/outbox.js', hat: /await deleteDocument\(col\(\), eintrag\.\$id\)/ },
|
||||
],
|
||||
'Protokolle': [
|
||||
{ datei: 'test/termineJobs.test.js', hat: /kein Gastname und keine Gastadresse im Log/ },
|
||||
{ datei: 'test/termineBetrieb.test.js', hat: /keine Gastadresse und kein Gastname im Log/ },
|
||||
{ datei: 'test/termineStatus.test.js', hat: /keine Gastadresse und kein Gastname im Log/ },
|
||||
{ datei: 'public/abo-bedingungen.html', hat: /Server-Protokolle werden nach 14 Tagen gelöscht/ },
|
||||
],
|
||||
'Datensicherung': [
|
||||
{ datei: 'public/abo-bedingungen.html', hat: /Verfügbar sind jeweils die letzten sieben Tagessicherungen/ },
|
||||
],
|
||||
'Rechenzentrum': [
|
||||
{ datei: 'public/abo-bedingungen.html', hat: /Hetzner ist nach ISO\/IEC 27001 zertifiziert/ },
|
||||
{ datei: 'public/abo-bedingungen.html', hat: /E-Mail-Postfächer \(Webhosting\)<\/td><td>Deutschland \(Rechenzentrum Nürnberg\)/ },
|
||||
],
|
||||
'Organisation': [
|
||||
{ datei: 'public/abo-bedingungen.html', hat: /Verpflichtung aller Beteiligten auf Vertraulichkeit/ },
|
||||
{ datei: 'public/abo-bedingungen.html', hat: /Die Maßnahmen werden mindestens einmal im Jahr überprüft/ },
|
||||
],
|
||||
}
|
||||
|
||||
/** Die Zeilen der Anlage-Tabelle als { bereich: text }. */
|
||||
function tomZeilen() {
|
||||
const anlage = AVV.abschnitte.find((a) => a.nummer === null && a.titel.startsWith('Anlage:'))
|
||||
assert.ok(anlage)
|
||||
const zeilen = anlage.zeilen.filter((z) => z.startsWith('|')).slice(2)
|
||||
return Object.fromEntries(zeilen.map((z) => {
|
||||
const [, bereich, text] = z.split('|').map((t) => t.trim())
|
||||
return [bereich, text]
|
||||
}))
|
||||
}
|
||||
|
||||
test('avv_tom_fundstellen: jede Zeile der Anlage hat ihre Fundstellen im Code, in den Tests oder in den Abo-Bedingungen', () => {
|
||||
const zeilen = tomZeilen()
|
||||
assert.deepEqual(Object.keys(zeilen), Object.keys(TOM_FUNDSTELLEN), 'Zeilen der Anlage und Fundstellen-Tabelle stimmen überein (Reihenfolge eingeschlossen)')
|
||||
for (const [bereich, stellen] of Object.entries(TOM_FUNDSTELLEN)) {
|
||||
assert.ok(stellen.length >= 1, `${bereich}: keine Fundstelle`)
|
||||
assert.ok(zeilen[bereich].length > 40, `${bereich}: Text der Zeile fehlt`)
|
||||
for (const stelle of stellen) {
|
||||
const pfad = stelle.datei
|
||||
assert.ok(existsSync(new URL(`../${pfad}`, import.meta.url)), `${bereich}: ${pfad} gibt es nicht`)
|
||||
const quelle = lies(pfad)
|
||||
if (stelle.hat) assert.match(quelle, stelle.hat, `${bereich}: Fundstelle in ${pfad} fehlt (${stelle.hat})`)
|
||||
if (stelle.nicht) assert.doesNotMatch(quelle, stelle.nicht, `${bereich}: ${pfad} darf ${stelle.nicht} nicht enthalten`)
|
||||
}
|
||||
}
|
||||
// Aussagen mit Zahlen stehen in der Zeile und in der Fundstelle gleich
|
||||
assert.ok(zeilen['Protokolle'].includes('nach 14 Tagen gelöscht'))
|
||||
assert.ok(zeilen['Datensicherung'].includes('die letzten sieben Tagessicherungen'))
|
||||
assert.ok(zeilen['Rechenzentrum'].includes('ISO/IEC 27001') && zeilen['Rechenzentrum'].includes('Falkenstein und Nürnberg'))
|
||||
assert.ok(zeilen['Löschung'].includes('30 Tage nach dem Abschalten'))
|
||||
assert.ok(zeilen['Löschung'].includes('Vor der Löschung einer Buchung, einer abgeschalteten Funktion oder eines nicht bestellten Fragebogens wird der Zustand frisch gelesen'))
|
||||
assert.doesNotMatch(zeilen['Löschung'], /Vor jeder Löschung/)
|
||||
assert.ok(zeilen['Trennung'].includes('das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten der jeweiligen Funktion.'))
|
||||
assert.ok(zeilen['Trennung'].includes('Die Hintergrundläufe (Löschung, Versand der E-Mails, Erinnerungen) gehen die Dokumente aller Funktionen nacheinander durch und schreiben jeweils nur in das Dokument, um das es geht.'))
|
||||
assert.doesNotMatch(zeilen['Trennung'], /Abfragen erfolgen nur je Funktion/)
|
||||
assert.ok(zeilen['Datenminimierung'].includes('der Fassung der Gästetexte, dem Zeitpunkt, dem Text des Buchungsknopfs, der Anrede und Hashes der übrigen gezeigten Texte'))
|
||||
assert.ok(zeilen['Speicher im Browser'].includes('keine Cookies'))
|
||||
assert.ok(zeilen['Zugriffskontrolle'].includes('konstanter Zeit'))
|
||||
assert.ok(zeilen['Organisation'].includes('Nummer 8 Abs. 3') && zeilen['Organisation'].includes('Nummer 8 Abs. 5'))
|
||||
})
|
||||
+111
-2
@@ -10,7 +10,10 @@ import {
|
||||
aktivSeitText, centZuEuro, eindeutigeId, euroZuCent, ganzzahl, neueId, optionenAusText, optionenText, ordneNachIds, planAusTexten, planTexte, preisAnzeige, verschiebeEintrag, zusatzSchluessel,
|
||||
} from '../public/termine-view.js'
|
||||
import { ANREDE_WAHL, stornoMuster } from '../public/termine-gaestetexte.js'
|
||||
import { exportUrl, request } from '../public/termine-dom.js'
|
||||
import { exportUrl, h, request } from '../public/termine-dom.js'
|
||||
import { installFakeDom } from './support/fakeDom.js'
|
||||
import * as einstellungenTab from '../public/termine-tab-einstellungen.js'
|
||||
import { baueArchiv } from '../scripts/termine-texte-archiv.mjs'
|
||||
import { beschrifteFehler } from '../public/termine-speichern.js'
|
||||
import { BUNDESLAENDER as SERVER_BUNDESLAENDER } from '../server/services/termine/feiertage.js'
|
||||
import { normalisiereEinstellung, preisText, zahlungText } from '../server/services/termine/einstellungen.js'
|
||||
@@ -312,6 +315,8 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminp
|
||||
const fn = lies('public/funktionen.js')
|
||||
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"])
|
||||
assert.doesNotMatch(fn, /from '\/termine/)
|
||||
// dazu genau ein relativer Import: die gemeinsame Quelle des Links „Zu Verträge“ (Reiner Datenmodul, kein Terminplaner-Modul; C10b)
|
||||
assert.deepEqual(fn.match(/from '\.[^']+'/g), ["from './vertraege-link.js'"])
|
||||
assert.doesNotMatch(fn, /import\s*\(/, 'auch kein dynamischer Import')
|
||||
})
|
||||
|
||||
@@ -365,10 +370,33 @@ test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kenn
|
||||
}
|
||||
})
|
||||
|
||||
test('Ansicht nach Zustand (TP2 C10b): termine.js lädt Fragebogen, Statuskarte und die Quelle des Links relativ, kennt keinen Abo-Text und liest die Rückkehr von Stripe einmal', () => {
|
||||
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
|
||||
const huelle = ohneKommentare(lies('public/termine.js'))
|
||||
assert.ok(huelle.includes("import { renderFragebogen } from './termine-fragebogen.js'"))
|
||||
assert.ok(huelle.includes("import { renderBestellen, statusKarte } from './funktion-bestellen.js'"))
|
||||
assert.ok(huelle.includes("import { ZU_VERTRAEGE } from './vertraege-link.js'"))
|
||||
assert.doesNotMatch(huelle, /from '\/|import\s*\(/, 'nur relative, statische Importe')
|
||||
// R111: der Abo-Hinweis kommt vom Server (GET /api/funktionen, termine.hinweis), nie als Text aus dem Browser; R112: die alten Texte sind weg
|
||||
assert.doesNotMatch(huelle, /Wartungs-Abo|gekündigt|nicht aktiv|nicht geprüft|brauchst du ein laufendes/, 'keine Kopie der Abo-Hinweise')
|
||||
assert.doesNotMatch(huelle, /noch nicht freigeschaltet|Wir schalten den Terminplaner für dich frei/, 'die Texte der Ansicht vor C10b')
|
||||
assert.match(huelle, /s\.termine\??\.hinweis/, 'der Text kommt aus der Antwort von /api/funktionen')
|
||||
assert.match(huelle, /ZU_VERTRAEGE\.view/, 'der Link nimmt Ziel, Text und Klasse aus der gemeinsamen Quelle')
|
||||
assert.doesNotMatch(huelle, /'vertraege'|"vertraege"|Zu Verträge/, 'und schreibt sie nicht noch einmal hin')
|
||||
// Rückkehr von Stripe: die Adresse wird an genau einer Stelle gelesen und bereinigt
|
||||
assert.equal(huelle.match(/history\.replaceState/g).length, 1)
|
||||
assert.equal(huelle.match(/location\.hash/g).length, 1)
|
||||
assert.match(huelle, /bestellt'\) === '1'/, 'danke nur mit bestellt=1')
|
||||
// die Quelle des Links ist reine Daten ohne Import
|
||||
const link = lies('public/vertraege-link.js')
|
||||
assert.doesNotMatch(ohneKommentare(link), /\bimport\b/)
|
||||
assert.match(link, /export const ZU_VERTRAEGE = Object\.freeze\(\{ text: 'Zu Verträge', view: 'vertraege', klasse: 'kb-link' \}\)/)
|
||||
})
|
||||
|
||||
test('Fragebogen (TP2 C9a): renderFragebogen wird nur über termine.js geladen, nie absolut, nie von einem Reiter oder von funktionen.js', () => {
|
||||
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /\.(js|html)$/.test(f))
|
||||
const ladende = dateien.filter((f) => /termine-fragebogen\.js/.test(lies(`public/${f}`)))
|
||||
assert.ok(ladende.every((f) => f === 'termine.js'), `nur die Huelle darf den Fragebogen laden: ${ladende.join(', ')}`)
|
||||
assert.deepEqual(ladende, ['termine.js'], `nur die Huelle darf den Fragebogen laden (C10b verdrahtet ihn): ${ladende.join(', ')}`)
|
||||
for (const f of dateien) assert.doesNotMatch(lies(`public/${f}`), /['"]\/termine-fragebogen\.js['"]/, `${f}: kein absoluter Import`)
|
||||
assert.match(lies('public/termine-fragebogen.js'), /export function renderFragebogen\(root, ctx\)/)
|
||||
})
|
||||
@@ -707,3 +735,84 @@ test('Mustertext Storno (Gästetext): wie die Vorlagen des Servers, Frist einset
|
||||
assert.equal(normalisiereEinstellung(e, { now: JETZT_SERVER }).einstellung.storno.text, stornoMuster(36), 'der Server ändert den Text nicht')
|
||||
assert.deepEqual(ANREDE_WAHL.map(([, name]) => name), ['Sie (förmlich)', 'Du (persönlich)'])
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Hinweistexte nach Recherche (TP2 F11)
|
||||
|
||||
const mitFakeDom = (fn) => async () => {
|
||||
const fx = installFakeDom()
|
||||
try {
|
||||
await fn(fx)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
}
|
||||
const SIE_WOERTER = /\bSie\b|\bIhr(e[nmrs]?)?\b/
|
||||
|
||||
test('freizeit_hinweis_text: Hinweis zur Freizeitleistung nach der Recherche (Du-Form, keine Rechtsberatung, kein „in der Regel keine Freizeitleistung“)', mitFakeDom((fx) => {
|
||||
const text = einstellungenTab.FREIZEIT_HINWEIS
|
||||
assert.equal(text, 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend, ein Kurs oder ein Escape Room. '
|
||||
+ 'Nach unserer Einschätzung gilt bei Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung in der Regel ein Widerrufsrecht; bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht. '
|
||||
+ 'Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. '
|
||||
+ 'Das ist keine Rechtsberatung: Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.')
|
||||
assert.ok(!text.includes('Weiterbildung'), 'Weiterbildung trägt die Recherche nicht')
|
||||
assert.ok(!/keine Freizeitleistung/.test(text), 'keine Liste „keine Freizeitleistung“ mehr')
|
||||
assert.ok(!/Massage[^.;]*keine Freizeit|Freizeit[^.;]*Massage/.test(text), 'Massage steht nicht als Beispiel für „keine Freizeitleistung“')
|
||||
assert.ok(text.includes('keine Rechtsberatung'))
|
||||
assert.ok(text.includes('bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht'))
|
||||
assert.doesNotMatch(text, SIE_WOERTER, 'Portaltexte in der Du-Form')
|
||||
assert.match(text, /\bdeine\b/)
|
||||
// der Reiter zeigt genau diesen Text am Haken „Freizeitangebote“
|
||||
const wurzel = einstellungenTab.rechtliches({ recht: { gaesteSind: 'verbraucher', freizeit: false } }).el
|
||||
const klein = fx.finde(wurzel, 'label.tm-haken small')
|
||||
assert.equal(fx.text(klein), text)
|
||||
}))
|
||||
|
||||
test('widerruf_hinweis_wertersatz: nach dem dritten Satz steht der Satz zum Wertersatz (§ 357a Abs. 2 BGB), Rest unverändert', mitFakeDom((fx) => {
|
||||
const text = einstellungenTab.WIDERRUF_HINWEIS
|
||||
assert.equal(text, 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). '
|
||||
+ 'Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. '
|
||||
+ 'Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. '
|
||||
+ 'Hat ein Gast ausdrücklich verlangt, dass du vor Ablauf der Frist beginnst, und widerruft er danach, kann er für die bis dahin erbrachte Leistung zu einem angemessenen Betrag verpflichtet sein (Wertersatz, § 357a Abs. 2 BGB). '
|
||||
+ 'Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.')
|
||||
assert.doesNotMatch(text, SIE_WOERTER)
|
||||
const wurzel = einstellungenTab.rechtliches({ recht: { gaesteSind: 'verbraucher', freizeit: false } }).el
|
||||
assert.equal(fx.text(fx.finde(wurzel, '.tm-box-info')), text)
|
||||
}))
|
||||
|
||||
test('storno_abschnitt_text: der Abschnitt „Stornobedingungen“ sagt, dass sie nur für Leistungen mit Preis gelten', mitFakeDom((fx) => {
|
||||
const e = basisEinstellung()
|
||||
const frist = h('input', { type: 'number', value: '24' })
|
||||
const { el } = einstellungenTab.storno({ nurLesen: false }, e, frist)
|
||||
const einleitung = fx.text(fx.finde(el, '.tm-abschnitt-text'))
|
||||
assert.ok(einleitung.includes('Lässt du das Feld leer, gibt es keine Stornobedingungen und keinen Haken dazu.'), 'bisheriger Text bleibt')
|
||||
assert.ok(einleitung.endsWith('Die Stornobedingungen gelten nur für Leistungen mit Preis; bei kostenlosen Leistungen fragt der Terminplaner Gäste nicht danach.'))
|
||||
assert.doesNotMatch(einleitung, SIE_WOERTER)
|
||||
}))
|
||||
|
||||
test('storno_muster_gekennzeichnet: der Hinweis steht wörtlich und in der Du-Form neben dem Stornofeld, nicht in den Gästetexten und nicht im Nachweis', mitFakeDom((fx) => {
|
||||
const hinweis = einstellungenTab.STORNO_MUSTER_HINWEIS
|
||||
assert.equal(hinweis, 'Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.')
|
||||
assert.doesNotMatch(hinweis, SIE_WOERTER)
|
||||
const e = basisEinstellung()
|
||||
const frist = h('input', { type: 'number', value: '24' })
|
||||
const { el } = einstellungenTab.storno({ nurLesen: false }, e, frist)
|
||||
const feldEl = fx.finde(el, '.tm-feld')
|
||||
const textfeld = fx.finde(feldEl, 'textarea')
|
||||
assert.equal(textfeld.dataset.feld, 'storno.text')
|
||||
const gekennzeichnet = fx.alle(el, '[data-hinweis="storno-muster"]')
|
||||
assert.equal(gekennzeichnet.length, 1)
|
||||
assert.equal(fx.text(gekennzeichnet[0]), hinweis, 'wörtlich, ohne Zusatz')
|
||||
assert.equal(gekennzeichnet[0].parentNode, feldEl, 'im Block des Stornofelds')
|
||||
const kinder = [...feldEl.children]
|
||||
assert.equal(kinder.indexOf(gekennzeichnet[0]), kinder.indexOf(textfeld) + 1, 'unmittelbar beim Textfeld')
|
||||
// schreibgeschützt (abgeschaltete Funktion) bleibt der Hinweis stehen
|
||||
assert.equal(fx.alle(einstellungenTab.storno({ nurLesen: true }, e, frist).el, '[data-hinweis="storno-muster"]').length, 1)
|
||||
// der Hinweis ist ein Portaltext: er steht in keinem Gästetext, in keiner Vorlage und im Widget nicht
|
||||
for (const gaestetext of [baueArchiv(), lies('server/services/termine/rechtstexte.js'), lies('public/termine-gaestetexte.js'), lies('public/embed/termine.js'), lies('server/services/termine/vorlagen.js')]) {
|
||||
assert.ok(!gaestetext.includes(hinweis))
|
||||
}
|
||||
assert.ok(!stornoMuster(24).includes('ist ein Muster'))
|
||||
// der Text im Feld und damit der Hash des Nachweises (sha256 von storno.text) bleiben vom Hinweis unberührt
|
||||
assert.equal(textfeld.value, e.storno.text)
|
||||
assert.ok(!fx.text(textfeld).includes(hinweis))
|
||||
}))
|
||||
@@ -0,0 +1,739 @@
|
||||
// Playwright-Checks Terminplaner TP2, Verkauf: vom Fragebogen bis zum Bestellklick (public/funktionen.js Kachel, termine.js Huelle,
|
||||
// termine-fragebogen.js Schritte, funktion-bestellen.js Bestellseite und Statuskarte). Nicht Teil von npm test.
|
||||
// Aufruf: NODE_OPTIONS=--max-old-space-size=1024 nice node test/ui/termine-fragebogen.ui.mjs (vorher free -m; es laeuft nur ein Browser)
|
||||
// Screenshots: /root/reports/terminplaner-ui/fragebogen-*.png (bei Fehlern fehler-fragebogen-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
|
||||
//
|
||||
// Alles laeuft gegen die ECHTEN Router (/api/termine, /api/funktionen) hinter dem Nachbau von Appwrite und Stripe (test/ui/termineTestServer.mjs);
|
||||
// nur die uebrigen Portal-Schnittstellen (/api/auth/me usw.) mockt page.route, und die Zahlungsseite (https://checkout.stripe.test/...) faengt
|
||||
// page.route ab: dorthin geht kein Netzverkehr. Hosts ausser 127.0.0.1, localhost und checkout.stripe.test sind Fehler, ebenso Seitenfehler und
|
||||
// Browser-Dialoge. Uhr: Server und Browser stehen auf Montag 2026-10-05 10:00 Berliner Zeit (context.clock.setFixedTime).
|
||||
// NUR=<Teil des Namens> fuehrt nur passende Pruefungen aus (zum Entwickeln); STACK=1 zeigt bei Fehlern den Aufrufweg.
|
||||
import { mkdir, readdir, rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { startTermineUiServer, salonEingabe, FEATURE, KUNDE, PROJEKT } from './termineTestServer.mjs'
|
||||
|
||||
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
|
||||
const { chromium } = await import(PW)
|
||||
|
||||
const SHOTS = '/root/reports/terminplaner-ui'
|
||||
await mkdir(SHOTS, { recursive: true })
|
||||
for (const datei of await readdir(SHOTS)) if (/^(fragebogen|fehler-fragebogen)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
|
||||
|
||||
const srv = await startTermineUiServer()
|
||||
// erst nach dem Start des Testservers importieren (config.js liest die Umgebung beim ersten Import)
|
||||
const { featureConfig, featureHaken } = await import('../../server/services/featureRegistry.js')
|
||||
const { aboMeldung } = await import('../../server/services/featureOrder.js')
|
||||
const { leseZustimmung, nachweisTexte, texteHash, zustimmungsfassungen } = await import('../../server/services/featureConsents.js')
|
||||
const REGISTER = featureConfig('termine')
|
||||
const JETZT = new Date('2026-10-05T08:00:00.000Z')
|
||||
const ERLAUBTE_HOSTS = new Set(['127.0.0.1', 'localhost', 'checkout.stripe.test'])
|
||||
const STRIPE_SEITE = /^https:\/\/checkout\.stripe\.test\/c\/pay\/(cs_test_\w+)$/
|
||||
|
||||
// ---------------------------------------------------------------- Helfer
|
||||
|
||||
function assert(cond, msg) { if (!cond) throw new Error(msg) }
|
||||
function eq(ist, soll, msg) {
|
||||
const a = JSON.stringify(ist)
|
||||
const b = JSON.stringify(soll)
|
||||
if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`)
|
||||
}
|
||||
const pause = (ms) => new Promise((fertig) => { setTimeout(fertig, ms) })
|
||||
|
||||
/** Screenshot der ganzen Seite: das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile; darum Fenster auf Seitenhoehe (hoechstens 3200 px). */
|
||||
async function shot(page, name) {
|
||||
// Einblend-Animationen der Seite (kb-fade-in, kb-view-in) abwarten, sonst ist der Inhalt noch durchsichtig
|
||||
await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))))
|
||||
const fenster = page.viewportSize()
|
||||
const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3200))
|
||||
await page.setViewportSize({ width: fenster.width, height: hoehe })
|
||||
await page.screenshot({ path: `${SHOTS}/fragebogen-${name}.png` })
|
||||
await page.setViewportSize(fenster)
|
||||
}
|
||||
/** Kein Streifen rechts: die Seite passt in die Breite. */
|
||||
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
|
||||
/** Die uebrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/termine-portal.ui.mjs). */
|
||||
function portalHilfe(p, json) {
|
||||
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Silke Müller', email: 'silke@salon-mueller.example' } })
|
||||
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Salon Müller', status: 'live' }] })
|
||||
if (p === '/api/features') return json({ features: [] })
|
||||
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
|
||||
if (p === '/api/chat/unread-count') return json({ unread: 0 })
|
||||
return json({})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Ablauf der Pruefungen
|
||||
|
||||
const results = []
|
||||
let browser = null
|
||||
const hosts = new Set()
|
||||
|
||||
/**
|
||||
* Eine Pruefung in eigenem Browser-Kontext, ausgehend von einem Kunden vor der ersten Branchenwahl (srv.zuruecksetzen({ entwurf: true })).
|
||||
* optionen: context (Playwright), abo (srv.abo: 'wartung' | 'hosting' | 'keins' | 'gekuendigt' | 'nicht_aktiv'), cookie ('kunde' | 'kundenansicht').
|
||||
* fn(page, t, ctx): t.anfragen sind alle Aufrufe der Seite an /api/ (Methode, Pfad, Koerper), t.stripe die Aufrufe der Zahlungsseite.
|
||||
*/
|
||||
async function check(name, fn, { context = {}, abo = 'wartung', cookie = 'kunde' } = {}) {
|
||||
if (process.env.NUR && !name.includes(process.env.NUR)) return
|
||||
await srv.zuruecksetzen({ entwurf: true })
|
||||
if (abo !== 'wartung') await srv.abo(abo)
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, ...context })
|
||||
await ctx.clock.setFixedTime(JETZT)
|
||||
await ctx.addCookies([cookie === 'kundenansicht' ? await srv.kundenansichtCookie() : await srv.portalCookie()])
|
||||
ctx.setDefaultTimeout(8000)
|
||||
const fehler = []
|
||||
const t = { anfragen: [], stripe: [] }
|
||||
// jede Seite des Kontexts wird beobachtet
|
||||
ctx.on('page', (seite) => {
|
||||
seite.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
|
||||
seite.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
|
||||
seite.on('request', (r) => {
|
||||
let u
|
||||
try { u = new URL(r.url()) } catch { return } // data: u. a.
|
||||
if (!u.protocol.startsWith('http')) return
|
||||
hosts.add(u.hostname)
|
||||
if (!ERLAUBTE_HOSTS.has(u.hostname)) fehler.push(`Anfrage an fremden Host: ${u.host}`)
|
||||
if (u.pathname.startsWith('/api/')) {
|
||||
let body = null
|
||||
try { body = r.postDataJSON() } catch { body = r.postData() }
|
||||
t.anfragen.push({ methode: r.method(), pfad: u.pathname, body })
|
||||
}
|
||||
})
|
||||
})
|
||||
await ctx.route('**/api/**', async (route) => {
|
||||
const p = new URL(route.request().url()).pathname
|
||||
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
if (p.startsWith('/api/funktionen') || p.startsWith('/api/termine')) return route.continue()
|
||||
return portalHilfe(p, json)
|
||||
})
|
||||
// die Zahlungsseite: nur eine Attrappe; festgehalten wird, wann sie aufgerufen wurde und wie viele Nachweise zu diesem Zeitpunkt schon lagen
|
||||
await ctx.route('https://checkout.stripe.test/**', async (route) => {
|
||||
t.stripe.push({ url: route.request().url(), at: Date.now(), nachweise: srv.aw.docs('featureConsents').size })
|
||||
return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: '<!doctype html><meta charset="utf-8"><title>Zahlungsseite (Attrappe)</title><p>Zahlungsseite</p>' })
|
||||
})
|
||||
const page = await ctx.newPage()
|
||||
try {
|
||||
await fn(page, t, ctx)
|
||||
assert(fehler.length === 0, fehler.join(' | '))
|
||||
results.push([name, true])
|
||||
console.log(`ok ${name}`)
|
||||
} catch (e) {
|
||||
results.push([name, false])
|
||||
console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`)
|
||||
if (process.env.STACK) console.log(e.stack)
|
||||
await page.screenshot({ path: `${SHOTS}/fehler-fragebogen-${name}.png` }).catch(() => {})
|
||||
} finally {
|
||||
await ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Seite und Fragebogen
|
||||
/** Das Portal frisch laden: ein Wechsel nur der Adresse nach # laedt die Daten nicht neu, darum erst eine leere Seite. */
|
||||
async function oeffne(page, hash) {
|
||||
if (page.url().startsWith(srv.url)) await page.goto('about:blank')
|
||||
await page.goto(`${srv.url}/dashboard.html${hash}`)
|
||||
}
|
||||
const warteFragebogen = (page) => page.locator('#view-termine.active .tm-fragebogen').waitFor()
|
||||
const markierung = (page, key) => page.locator(`.tm-schritt[aria-current="step"][data-schritt="${key}"]`)
|
||||
const warteSchritt = (page, key) => markierung(page, key).waitFor()
|
||||
/** „Schritt n von 8“ (Text steht immer im DOM, sichtbar ist er nur am Handy). */
|
||||
const standText = (page) => page.locator('.tm-schritt-stand').textContent()
|
||||
const schrittKnopf = (page, key) => page.locator(`.tm-schritt[data-schritt="${key}"] button`)
|
||||
const weiterKnopf = (page) => page.locator('.tm-fb-form [data-aktion="weiter"]')
|
||||
const feldIn = (page, name) => page.locator(`[data-feld="${name}"]`)
|
||||
/** Der Kunde mit Entwurf kommt ueber die Kachel in Funktionen in die Ansicht (die Kachel merkt sich das Projekt). */
|
||||
async function ueberKachelStarten(page, knopfText) {
|
||||
await oeffne(page, '#funktionen')
|
||||
const kachel = page.locator('[data-fn-tile="termine"]')
|
||||
await kachel.waitFor()
|
||||
await kachel.getByRole('button', { name: knopfText, exact: true }).click()
|
||||
await warteFragebogen(page)
|
||||
}
|
||||
const GEBUCHT = { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Auf der Seite Kontakt unter dem Text', gestaltung: '', bemerkung: '' }
|
||||
/** Alle acht Schritte bestaetigt und alles ausgefuellt: der Entwurf, der direkt auf der Bestellseite landet. */
|
||||
const vollstaendig = (extra = {}) => ({ bestaetigt: srv.SCHRITTE, bundeslandGewaehlt: true, preiseGeprueft: true, einbau: GEBUCHT, ...extra })
|
||||
|
||||
// Zaehler fuer "die Version steigt nur durch PATCH": diese Aufrufe der Seite schreiben die Einstellung (und erhoehen deren Version)
|
||||
const schreibt = (a) => (a.methode === 'PATCH' && a.pfad === '/api/termine/einstellungen') || (a.methode === 'POST' && a.pfad === '/api/termine/einstellungen/vorlage')
|
||||
const zaehleSchreiber = (t) => t.anfragen.filter(schreibt).length
|
||||
const zaehlePuts = (t) => t.anfragen.filter((a) => a.methode === 'PUT' && a.pfad === '/api/termine/fragebogen').length
|
||||
const schreibaufrufe = (t) => t.anfragen.filter((a) => a.methode !== 'GET')
|
||||
|
||||
// Eingaben der acht Schritte (ohne „Weiter“) und was danach gespeichert sein muss
|
||||
const FELD_NAME = 'Salon Müller'
|
||||
const FELD_ADRESSE = 'Hauptstr. 1, 67722 Winnweiler'
|
||||
const FELD_TELEFON = '06301 1234'
|
||||
const IMPRESSUM = 'https://salon-mueller.example/impressum'
|
||||
const DATENSCHUTZ = 'https://salon-mueller.example/datenschutz'
|
||||
const EINGABE = {
|
||||
async wer(page) {
|
||||
await page.locator('input[name="termine-wer-art"][value="team"]').check()
|
||||
await page.locator('.tm-fb-schritt[data-schritt="wer"] textarea').fill('Anna\nBen')
|
||||
},
|
||||
async leistungen(page) {
|
||||
await feldIn(page, 'preiseGeprueft').check()
|
||||
},
|
||||
async zeiten(page) {
|
||||
await feldIn(page, 'bundesland').selectOption({ label: 'Rheinland-Pfalz' })
|
||||
},
|
||||
async regeln(page) {
|
||||
await page.getByRole('button', { name: 'Alle sofort buchbar', exact: true }).click()
|
||||
},
|
||||
async gastfelder() {},
|
||||
async benachrichtigung() {},
|
||||
async website(page) {
|
||||
await feldIn(page, 'betrieb.telefon').fill(FELD_TELEFON)
|
||||
await feldIn(page, 'betrieb.impressumUrl').fill(IMPRESSUM)
|
||||
await feldIn(page, 'betrieb.datenschutzUrl').fill(DATENSCHUTZ)
|
||||
await page.locator('input[name="termine-einbau-modus"][value="knopf"]').check()
|
||||
await feldIn(page, 'einbau.knopfText').fill('Termin buchen')
|
||||
},
|
||||
}
|
||||
const NACH_SCHRITT = {
|
||||
wer: () => eq(srv.einstellung().ressourcen.map((r) => r.name), ['Anna', 'Ben'], 'Team mit zwei Namen gespeichert'),
|
||||
leistungen: () => eq(srv.fragebogen().preiseGeprueft, true, 'Preise geprueft gespeichert'),
|
||||
zeiten: () => {
|
||||
eq(srv.fragebogen().bundeslandGewaehlt, true, 'Bundesland gewaehlt gespeichert')
|
||||
eq(srv.einstellung().bundesland, 'RP', 'Bundesland Rheinland-Pfalz')
|
||||
},
|
||||
regeln: () => assert(srv.einstellung().leistungen.every((l) => l.buchungsart === 'sofort'), 'alle Leistungen sofort buchbar'),
|
||||
gastfelder: () => {},
|
||||
benachrichtigung: () => {},
|
||||
website: () => {
|
||||
eq(srv.einstellung().betrieb.telefon, FELD_TELEFON, 'Telefon gespeichert')
|
||||
eq(srv.einstellung().betrieb.impressumUrl, IMPRESSUM, 'Impressum gespeichert')
|
||||
eq(srv.einstellung().betrieb.datenschutzUrl, DATENSCHUTZ, 'Datenschutz gespeichert')
|
||||
const e = srv.fragebogen().einbau
|
||||
eq([e.modus, e.knopfText], ['knopf', 'Termin buchen'], 'Einbauwunsch gespeichert')
|
||||
},
|
||||
}
|
||||
const FOLGE = srv.SCHRITTE
|
||||
|
||||
/**
|
||||
* Schritt 1 ohne Entwurf: Branche „Friseur und Salon“ waehlen, Name und Anschrift stehen aus dem Konto da, „Mit dieser Branche starten“.
|
||||
* Danach gibt es Funktionsdokument (off) und Einstellung, und der Schritt ist bestaetigt.
|
||||
*/
|
||||
async function brancheStarten(page, t) {
|
||||
await warteSchritt(page, 'branche')
|
||||
eq(await standText(page), 'Schritt 1 von 8', 'Fortschritt vor dem Start')
|
||||
eq(await feldIn(page, 'betrieb.name').inputValue(), FELD_NAME, 'Name aus dem Konto')
|
||||
eq(await feldIn(page, 'betrieb.adresse').inputValue(), FELD_ADRESSE, 'Anschrift aus dem Konto')
|
||||
eq(srv.aw.docs('portalFeatures').size, 0, 'vor dem Start kein Funktionsdokument')
|
||||
await page.locator('label.tm-vorlage', { hasText: 'Friseur und Salon' }).locator('input').check()
|
||||
const s0 = zaehleSchreiber(t)
|
||||
await page.getByRole('button', { name: 'Mit dieser Branche starten', exact: true }).click()
|
||||
await warteSchritt(page, 'wer')
|
||||
const feature = srv.aw.docs('portalFeatures').get(FEATURE)
|
||||
assert(feature, 'Funktionsdokument angelegt')
|
||||
eq([feature.state, feature.featureKey, feature.customerId, feature.projectId], ['off', 'termine', KUNDE, PROJEKT], 'Funktionsdokument im Zustand off')
|
||||
assert(/^[a-z2-7]{16}$/.test(feature.siteKey), 'Funktionsdokument hat einen siteKey')
|
||||
assert(srv.einstellung(), 'Einstellung angelegt')
|
||||
eq(srv.einstellung().vorlage, 'salon', 'Vorlage der Branche')
|
||||
eq(srv.einstellung().betrieb.name, FELD_NAME, 'Name in der Einstellung')
|
||||
eq(srv.einstellung().betrieb.adresse, FELD_ADRESSE, 'Anschrift in der Einstellung')
|
||||
eq(srv.fragebogen().branche, 'salon', 'Branche im Fragebogen')
|
||||
eq(srv.fragebogen().bestaetigt, ['branche'], 'Schritt 1 bestaetigt')
|
||||
// die Version steigt durch die Vorlage (POST) und hoechstens einen Nachtrag (PATCH), nie durch den PUT
|
||||
eq(srv.version(), zaehleSchreiber(t) - s0, 'Version = Zahl der schreibenden Aufrufe (POST vorlage, PATCH)')
|
||||
eq(zaehlePuts(t), 1, 'ein PUT bestaetigt Schritt 1')
|
||||
}
|
||||
|
||||
/**
|
||||
* „Weiter“ in einem Schritt mit Feldern: danach steht der Schritt in fragebogen.bestaetigt (aus termineConfig gelesen), der naechste Schritt
|
||||
* ist da, es gab genau einen PUT und die Version der Einstellung ist nur um die PATCH-Aufrufe gestiegen.
|
||||
*/
|
||||
async function weiter(page, t, key, naechster) {
|
||||
const v0 = srv.version()
|
||||
const s0 = zaehleSchreiber(t)
|
||||
const p0 = zaehlePuts(t)
|
||||
await weiterKnopf(page).click()
|
||||
await warteSchritt(page, naechster)
|
||||
assert(srv.fragebogen().bestaetigt.includes(key), `${key} steht in bestaetigt: ${JSON.stringify(srv.fragebogen().bestaetigt)}`)
|
||||
eq(zaehlePuts(t) - p0, 1, `${key}: genau ein PUT`)
|
||||
const patches = zaehleSchreiber(t) - s0
|
||||
assert(patches <= 1, `${key}: hoechstens ein PATCH (${patches})`)
|
||||
eq(srv.version() - v0, patches, `${key}: die Version steigt nur durch PATCH`)
|
||||
}
|
||||
|
||||
/** Schritte 2 bis bis ausfuellen und bestaetigen (Schritt 1 ist schon gestartet). */
|
||||
async function schritteAusfuellen(page, t, bis) {
|
||||
const ende = FOLGE.indexOf(bis)
|
||||
for (let i = 1; i <= ende; i += 1) {
|
||||
const key = FOLGE[i]
|
||||
await warteSchritt(page, key)
|
||||
eq(await standText(page), `Schritt ${i + 1} von 8`, `Fortschritt in ${key}`)
|
||||
await EINGABE[key](page)
|
||||
await weiter(page, t, key, FOLGE[i + 1] ?? 'bestellen')
|
||||
NACH_SCHRITT[key]()
|
||||
}
|
||||
}
|
||||
|
||||
/** Bestellseite: alle Haken. */
|
||||
const hakenSetzen = async (page) => {
|
||||
const haken = page.locator('.tm-bestellen [data-haken]')
|
||||
const n = await haken.count()
|
||||
assert(n > 0, 'die Bestellseite hat Haken')
|
||||
for (let i = 0; i < n; i += 1) await haken.nth(i).check()
|
||||
return n
|
||||
}
|
||||
const bestellKnopf = (page) => page.locator('.tm-bestellen [data-aktion="bestellen"]')
|
||||
|
||||
async function pruefungen() {
|
||||
// ============================================================ ohne Abo
|
||||
await check('ohne_abo', async (page, t) => {
|
||||
const aus = [['hosting', 'kein_abo'], ['keins', 'kein_abo'], ['gekuendigt', 'gekuendigt'], ['nicht_aktiv', 'nicht_aktiv']]
|
||||
for (const [i, [abo, grund]] of aus.entries()) {
|
||||
if (i > 0) await srv.abo(abo)
|
||||
const soll = aboMeldung(REGISTER, grund)
|
||||
assert(soll.includes('Wartungs-Abo'), `Hinweis des Servers nennt das Wartungs-Abo: ${soll}`)
|
||||
await oeffne(page, '#funktionen')
|
||||
const kachel = page.locator('[data-fn-tile="termine"]')
|
||||
await kachel.waitFor()
|
||||
const kachelText = await kachel.innerText()
|
||||
assert(kachelText.includes(soll), `Kachel zeigt den Hinweis (${abo}): ${kachelText}`)
|
||||
eq(await kachel.locator('.fn-tile-cta').count(), 0, `keine Knoepfe in der Kachel (${abo})`)
|
||||
eq(await kachel.locator('.fn-pill').innerText(), 'Aus', `Pille Aus (${abo})`)
|
||||
if (grund === 'kein_abo') assert(kachelText.includes('Zu Verträge'), 'Link zu den Vertraegen bei kein_abo')
|
||||
// die Ansicht zeigt denselben Hinweis, keinen Fragebogen und keine Vorlagenauswahl
|
||||
await oeffne(page, '#termine')
|
||||
const hinweis = page.locator('#view-termine.active [data-hinweis="abo"]')
|
||||
await hinweis.waitFor()
|
||||
assert((await hinweis.innerText()).includes(soll), `Ansicht zeigt den Hinweis (${abo}): ${await hinweis.innerText()}`)
|
||||
eq(await page.locator('.tm-fragebogen, .tm-vorlagen, .tm-bestellen, .tm-reiterleiste').count(), 0, `kein Fragebogen (${abo})`)
|
||||
if (i === 0) await shot(page, 'ohne-abo')
|
||||
}
|
||||
eq(srv.aw.docs('portalFeatures').size, 0, 'portalFeatures bleibt leer')
|
||||
eq(srv.aw.docs('termineConfig').size, 0, 'termineConfig bleibt leer')
|
||||
eq(schreibaufrufe(t), [], 'kein einziger Schreibaufruf')
|
||||
}, { abo: 'hosting' })
|
||||
|
||||
// ============================================================ Gesundheitsbereich blockt
|
||||
await check('gesundheit_blockt', async (page, t) => {
|
||||
await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €')
|
||||
await warteSchritt(page, 'branche')
|
||||
await page.locator('label.tm-vorlage', { hasText: 'Gesundheitsbereich' }).locator('input').check()
|
||||
const hinweis = page.locator('[data-hinweis="gesundheit"]')
|
||||
await hinweis.waitFor()
|
||||
const text = await hinweis.innerText()
|
||||
assert(text.includes('Gesundheitsbereich') && text.includes('nicht verfügbar'), `Erklaertext: ${text}`)
|
||||
eq(await page.getByRole('button', { name: /Weiter/ }).count(), 0, 'kein „Weiter“')
|
||||
eq(await page.locator('[data-aktion="branche-starten"], .tm-fb-form [type="submit"]').count(), 0, 'kein Knopf zum Starten')
|
||||
await shot(page, 'gesundheit')
|
||||
// auch ein erzwungenes Absenden (Enter im Feld, requestSubmit) sendet nichts
|
||||
await feldIn(page, 'betrieb.name').press('Enter')
|
||||
await page.evaluate(() => document.querySelector('.tm-fb-form').requestSubmit())
|
||||
await pause(500)
|
||||
eq(schreibaufrufe(t), [], 'kein einziger Schreibaufruf')
|
||||
for (const sammlung of ['portalFeatures', 'termineConfig', 'featureConsents']) eq(srv.aw.docs(sammlung).size, 0, `kein Dokument in ${sammlung}`)
|
||||
// ein anderer Branchenwunsch hebt die Sperre wieder auf: der Knopf kommt zurueck (nichts gesendet)
|
||||
await page.locator('label.tm-vorlage', { hasText: 'Friseur und Salon' }).locator('input').check()
|
||||
await page.locator('[data-aktion="branche-starten"]').waitFor()
|
||||
eq(await hinweis.count(), 0, 'Erklaertext weg bei anderer Branche')
|
||||
eq(schreibaufrufe(t), [], 'weiterhin kein Schreibaufruf')
|
||||
})
|
||||
|
||||
// ============================================================ komplett bis zum Bestellklick
|
||||
await check('fragebogen_komplett_bis_bestellklick', async (page, t) => {
|
||||
await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €')
|
||||
await shot(page, 'schritt-1-start')
|
||||
await brancheStarten(page, t)
|
||||
await schritteAusfuellen(page, t, 'website') // 2 bis 8; nach dem letzten „Weiter“ steht die Bestellseite da
|
||||
await warteSchritt(page, 'bestellen')
|
||||
eq(srv.fragebogen().bestaetigt, FOLGE, 'alle acht Schritte bestaetigt')
|
||||
eq(await standText(page), 'Prüfen und bestellen', 'Kopfzeile der Bestellseite')
|
||||
|
||||
// ---- Bestellseite
|
||||
const seite = page.locator('.tm-bestellen')
|
||||
await seite.waitFor()
|
||||
eq(await seite.locator('.tm-bestellen-offen').count(), 0, 'keine offenen Punkte')
|
||||
const knopf = bestellKnopf(page)
|
||||
assert(await knopf.isDisabled(), 'Knopf gesperrt, solange Haken fehlen')
|
||||
const knopfText = (await knopf.innerText()).trim()
|
||||
eq(knopfText, REGISTER.consent.knopf, 'Knopf traegt die Beschriftung des Registers')
|
||||
const belehrung = await featureHaken('termine').belehrung()
|
||||
const region = seite.locator('.tm-bestellen-belehrung .tm-rechtstext')
|
||||
assert(await region.isVisible(), 'Widerrufsbelehrung ist sichtbar (nicht eingeklappt)')
|
||||
const belehrungImDom = (await region.textContent()).trim()
|
||||
eq(belehrungImDom, belehrung.trim(), 'Widerrufsbelehrung mit Muster-Formular wie im Register (bis F7: Platzhalter)')
|
||||
const reihenfolge = await page.evaluate(() => {
|
||||
const b = document.querySelector('.tm-bestellen-belehrung .tm-rechtstext')
|
||||
const k = document.querySelector('.tm-bestellen [data-aktion="bestellen"]')
|
||||
return { vorKnopf: Boolean(b.compareDocumentPosition(k) & Node.DOCUMENT_POSITION_FOLLOWING), umgekehrt: Boolean(b.compareDocumentPosition(k) & Node.DOCUMENT_POSITION_PRECEDING) }
|
||||
})
|
||||
eq(reihenfolge, { vorKnopf: true, umgekehrt: false }, 'Belehrung steht im DOM vor dem Knopf')
|
||||
// die gezeigten Texte der Zustimmungen: genau sie muessen im Nachweis stehen
|
||||
const gezeigt = await page.evaluate(() => [...document.querySelectorAll('.tm-bestellen-haken > *')].map((el) => ({
|
||||
haken: el.matches('label') ? el.querySelector('input').getAttribute('data-haken') : null,
|
||||
text: el.textContent.trim(),
|
||||
})))
|
||||
assert(gezeigt.length > 0, 'Zustimmungen sind zu sehen')
|
||||
await shot(page, 'bestellseite-gesperrt')
|
||||
const hakenZahl = await hakenSetzen(page)
|
||||
assert(hakenZahl > 0 && (await knopf.isEnabled()), 'Knopf aktiv nach allen Haken')
|
||||
await shot(page, 'bestellseite-bereit')
|
||||
// ein einzelner Haken weniger sperrt wieder
|
||||
await page.locator('.tm-bestellen [data-haken]').first().uncheck()
|
||||
assert(await knopf.isDisabled(), 'Knopf wieder gesperrt bei fehlendem Haken')
|
||||
await page.locator('.tm-bestellen [data-haken]').first().check()
|
||||
|
||||
// ---- Klick
|
||||
const vorKlick = Date.now()
|
||||
const checkoutAnfrage = page.waitForRequest((r) => r.method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout')
|
||||
await knopf.click()
|
||||
const body = (await checkoutAnfrage).postDataJSON()
|
||||
await page.waitForURL(STRIPE_SEITE)
|
||||
const sitzungId = STRIPE_SEITE.exec(page.url())[1]
|
||||
eq(t.stripe.length, 1, 'die Zahlungsseite wurde genau einmal aufgerufen')
|
||||
const aufruf = t.stripe[0]
|
||||
|
||||
// der Koerper der Bestellung: Projekt, Fassung und die IDs der Haken (nie Texte)
|
||||
eq(Object.keys(body).sort(), ['projectId', 'zustimmung'], 'Koerper der Bestellung')
|
||||
eq(body.projectId, PROJEKT, 'Projekt der Bestellung')
|
||||
eq(body.zustimmung.version, REGISTER.consent.version, 'Fassung der Zustimmung')
|
||||
eq(body.zustimmung.haken, REGISTER.consent.items.filter((i) => i.haken).map((i) => i.id), 'IDs der Haken in Registerreihenfolge')
|
||||
|
||||
// Funktionsdokument ordered mit der Sitzung
|
||||
const feature = srv.aw.docs('portalFeatures').get(FEATURE)
|
||||
eq([feature.state, feature.stripeSessionId], ['ordered', sitzungId], 'Funktionsdokument ordered mit stripeSessionId')
|
||||
// genau eine Checkout-Sitzung bei Stripe, fuer den Terminplaner und das Projekt
|
||||
const sitzungen = srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions')
|
||||
eq(sitzungen.length, 1, 'genau ein POST /v1/checkout/sessions')
|
||||
eq([sitzungen[0].body['metadata[featureKey]'], sitzungen[0].body['metadata[projectId]']], ['termine', PROJEKT], 'Metadaten der Sitzung')
|
||||
|
||||
// genau ein Nachweis mit den gezeigten Texten
|
||||
const nachweise = [...srv.aw.docs('featureConsents').values()]
|
||||
eq(nachweise.length, 1, 'genau ein featureConsents-Dokument')
|
||||
const n = leseZustimmung(nachweise[0])
|
||||
eq([n.customerId, n.projectId, n.featureKey, n.stripeSessionId], [KUNDE, PROJEKT, 'termine', sitzungId], 'Nachweis gehoert zu dieser Sitzung')
|
||||
eq(n.fassungen, zustimmungsfassungen(REGISTER), 'Fassungen der Unterlagen im Nachweis')
|
||||
for (const eintrag of n.texte.filter((x) => x.haken !== undefined && x.id !== 'belehrung' && x.id !== 'knopf')) {
|
||||
const sichtbar = gezeigt.find((g) => (eintrag.haken ? g.haken === eintrag.id : g.haken === null) && g.text === eintrag.text)
|
||||
assert(sichtbar, `Text im Nachweis wurde so gezeigt: ${eintrag.id}`)
|
||||
}
|
||||
eq(n.texte, nachweisTexte(REGISTER, { belehrung }), 'Texte im Nachweis = Texte des Registers samt Belehrung und Knopf, in Anzeigereihenfolge')
|
||||
eq(n.texte.find((x) => x.id === 'belehrung').text.trim(), belehrungImDom, 'Belehrung im Nachweis = die gezeigte Belehrung')
|
||||
const gespeicherterKnopf = n.texte.find((x) => x.id === 'knopf').text
|
||||
eq([gespeicherterKnopf, REGISTER.consent.knopf, knopfText], [knopfText, knopfText, knopfText], 'Knopf: gespeichert = Register = Beschriftung des geklickten Knopfes')
|
||||
eq(nachweise[0].textHash, texteHash(n.texte), 'Pruefsumme der Texte')
|
||||
// Zeit des Klicks: nach dem Mausklick des Skripts, und gespeichert, BEVOR die Zahlungsseite aufgerufen wurde
|
||||
const zeit = Date.parse(n.acceptedAt)
|
||||
assert(Number.isFinite(zeit) && zeit >= vorKlick - 1000, `Zeit des Klicks liegt nach dem Start des Skripts: ${n.acceptedAt}`)
|
||||
assert(zeit < aufruf.at, `Nachweis (${n.acceptedAt}) kleiner als der Aufruf der Zahlungsseite (${new Date(aufruf.at).toISOString()})`)
|
||||
assert(Date.parse(nachweise[0].$createdAt) <= aufruf.at, 'Nachweis war angelegt, bevor die Zahlungsseite aufgerufen wurde')
|
||||
eq(aufruf.nachweise, 1, 'beim Aufruf der Zahlungsseite lag der Nachweis schon in der Datenbank')
|
||||
})
|
||||
|
||||
// ============================================================ offene Punkte sperren
|
||||
await check('offene_punkte_sperren', async (page) => {
|
||||
// (a) Impressum fehlt
|
||||
await srv.entwurf({
|
||||
eingabe: salonEingabe({ betrieb: { ...salonEingabe().betrieb, impressumUrl: '' } }),
|
||||
fragebogen: vollstaendig(),
|
||||
})
|
||||
await oeffne(page, '#termine')
|
||||
await warteFragebogen(page)
|
||||
await warteSchritt(page, 'bestellen')
|
||||
const offen = page.locator('.tm-bestellen-offen')
|
||||
await offen.waitFor()
|
||||
const punkt = offen.locator('li', { hasText: 'Impressum' })
|
||||
eq(await punkt.count(), 1, 'genau ein Punkt zum Impressum')
|
||||
eq(await offen.locator('li').count(), 1, 'sonst nichts offen')
|
||||
eq(await punkt.getByRole('button', { name: 'Zum Schritt „Website“' }).innerText(), 'Zum Schritt', 'Knopf „Zum Schritt“')
|
||||
await hakenSetzen(page)
|
||||
assert(await bestellKnopf(page).isDisabled(), 'Knopf bleibt trotz aller Haken gesperrt')
|
||||
assert((await page.locator('[data-hinweis="bestellen"]').innerText()).includes('offenen Punkte'), 'Hinweis am Knopf nennt die offenen Punkte')
|
||||
await shot(page, 'offen-impressum')
|
||||
await punkt.getByRole('button', { name: 'Zum Schritt „Website“' }).click()
|
||||
await warteSchritt(page, 'website')
|
||||
eq(await page.locator('.tm-fb-schritt').getAttribute('data-schritt'), 'website', 'Schritt 8 geoeffnet')
|
||||
eq(await standText(page), 'Schritt 8 von 8', 'Fortschritt Schritt 8')
|
||||
eq(await feldIn(page, 'betrieb.impressumUrl').inputValue(), '', 'Impressum-Feld ist leer')
|
||||
// nachtragen: danach ist der Punkt weg, der Knopf nach den Haken aktiv
|
||||
await feldIn(page, 'betrieb.impressumUrl').fill(IMPRESSUM)
|
||||
await weiterKnopf(page).click()
|
||||
await warteSchritt(page, 'bestellen')
|
||||
eq(await page.locator('.tm-bestellen-offen').count(), 0, 'Punkt erledigt')
|
||||
await hakenSetzen(page)
|
||||
assert(await bestellKnopf(page).isEnabled(), 'Knopf aktiv, wenn nichts mehr offen ist')
|
||||
|
||||
// (b) Bundesland nie gewaehlt, obwohl der Server RP gespeichert hat
|
||||
await srv.entwurf({ fragebogen: vollstaendig({ bundeslandGewaehlt: false }) })
|
||||
eq(srv.einstellung().bundesland, 'RP', 'der Serverstandard RP ist gespeichert')
|
||||
await oeffne(page, '#termine')
|
||||
await warteFragebogen(page)
|
||||
await warteSchritt(page, 'bestellen')
|
||||
const offenLand = page.locator('.tm-bestellen-offen')
|
||||
await offenLand.waitFor()
|
||||
eq(await offenLand.locator('li').count(), 1, 'nur ein Punkt offen')
|
||||
const landPunkt = offenLand.locator('li', { hasText: 'Bundesland' })
|
||||
eq(await landPunkt.count(), 1, 'Punkt zum Bundesland')
|
||||
await hakenSetzen(page)
|
||||
assert(await bestellKnopf(page).isDisabled(), 'Knopf gesperrt ohne Bundesland')
|
||||
await shot(page, 'offen-bundesland')
|
||||
await landPunkt.getByRole('button', { name: 'Zum Schritt „Zeiten“' }).click()
|
||||
await warteSchritt(page, 'zeiten')
|
||||
eq(await standText(page), 'Schritt 4 von 8', 'Punkt fuehrt in den Schritt Zeiten')
|
||||
eq(await feldIn(page, 'bundesland').inputValue(), '', 'Bundesland ist nicht vorgewaehlt (RP ist keine Wahl)')
|
||||
})
|
||||
|
||||
// ============================================================ Rueckkehr und Zahlung fortsetzen
|
||||
await check('rueckkehr_und_zahlung_fortsetzen', async (page, t) => {
|
||||
await srv.entwurf({ fragebogen: vollstaendig() })
|
||||
const sitzung = await srv.bestellt()
|
||||
const sitzungsUrl = `https://checkout.stripe.test/c/pay/${sitzung}`
|
||||
|
||||
// ---- Rueckkehr nach der Zahlung: Statuskarte mit „Danke!“, Adresse bereinigt, Einstellungen gesperrt
|
||||
await oeffne(page, '#termine?bestellt=1&projekt=p1')
|
||||
const karte = page.locator('.tm-statuskarte')
|
||||
await karte.waitFor()
|
||||
eq(await karte.getAttribute('data-state'), 'ordered', 'Statuskarte im Zustand ordered')
|
||||
assert((await karte.innerText()).includes('Zahlung offen'), `Karte: ${await karte.innerText()}`)
|
||||
assert((await karte.locator('[data-danke]').innerText()).startsWith('Danke!'), 'Danke! nach der Rueckkehr')
|
||||
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
|
||||
const reiter = await page.locator('[data-reiter]').evaluateAll((els) => els.map((el) => el.dataset.reiter))
|
||||
eq(reiter, ['leistungen', 'team', 'einstellungen'], 'Reiter ohne „Termine“')
|
||||
await page.locator('[data-reiter="einstellungen"]').click()
|
||||
await page.locator('#tm-panel .tm-abschnitt').first().waitFor()
|
||||
const felder = await page.locator('#tm-panel').evaluate((panel) => {
|
||||
const alle = [...panel.querySelectorAll('input, select, textarea')]
|
||||
return { alle: alle.length, offen: alle.filter((el) => !el.disabled && !el.matches(':disabled')).length }
|
||||
})
|
||||
assert(felder.alle > 0, 'Einstellungen zeigen Felder')
|
||||
eq(felder.offen, 0, 'alle Felder der Einstellungen sind gesperrt')
|
||||
await shot(page, 'rueckkehr-zahlung-offen')
|
||||
|
||||
// ---- Zahlung fortsetzen: dieselbe Sitzung, kein zweiter Nachweis
|
||||
const fortsetzen = page.waitForRequest((r) => r.method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout')
|
||||
await karte.getByRole('button', { name: 'Zahlung fortsetzen', exact: true }).click()
|
||||
eq((await fortsetzen).postDataJSON(), { projectId: PROJEKT, fortsetzen: true }, 'Koerper von „Zahlung fortsetzen“')
|
||||
await page.waitForURL(sitzungsUrl)
|
||||
eq(t.stripe.map((s) => s.url), [sitzungsUrl], 'Navigation zur selben Sitzung')
|
||||
eq(srv.st.checkoutSessions.size, 1, 'weiterhin genau eine Checkout-Sitzung')
|
||||
eq(srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions').length, 0, 'keine neue Sitzung angelegt')
|
||||
eq(srv.aw.docs('featureConsents').size, 1, 'weiterhin genau ein featureConsents-Dokument')
|
||||
|
||||
// ---- Abbruch bei Stripe: Rueckkehr ohne „Danke!“
|
||||
await oeffne(page, '#termine?projekt=p1')
|
||||
await karte.waitFor()
|
||||
assert((await karte.innerText()).includes('Zahlung offen'), 'Abbruch: Karte Zahlung offen')
|
||||
eq(await page.locator('[data-danke]').count(), 0, 'Abbruch: kein „Danke!“')
|
||||
eq(await page.evaluate(() => window.location.hash), '#termine', 'Abbruch: Adresse bereinigt')
|
||||
|
||||
// ---- ein PATCH direkt am Router ist gesperrt
|
||||
const patch = await page.evaluate(async (version) => {
|
||||
const res = await fetch('/api/termine/einstellungen', { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: 'p1', version, einstellung: {} }) })
|
||||
return { status: res.status, code: (await res.json()).code }
|
||||
}, srv.version())
|
||||
eq(patch, { status: 409, code: 'gesperrt' }, 'PATCH bei ordered ist 409 gesperrt')
|
||||
|
||||
// ---- ohne Nachweis gibt es keine Weiterleitung: das Bestellformular kommt wieder
|
||||
await srv.loescheZustimmungen()
|
||||
await oeffne(page, '#termine')
|
||||
await karte.waitFor()
|
||||
const antwort = page.waitForResponse((r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout')
|
||||
const naviVorher = t.stripe.length
|
||||
await karte.getByRole('button', { name: 'Zahlung fortsetzen', exact: true }).click()
|
||||
const res = await antwort
|
||||
eq(res.status(), 409, 'ohne Nachweis: 409')
|
||||
eq((await res.json()).code, 'zustimmung_fehlt', 'ohne Nachweis: zustimmung_fehlt')
|
||||
await page.locator('.tm-bestellen').waitFor()
|
||||
eq(await page.locator('.tm-statuskarte').count(), 0, 'die Karte ist durch das Bestellformular ersetzt')
|
||||
assert((await page.locator('#view-termine').innerText()).includes('Bitte stimme der Erklärung zu'), 'Meldung zur fehlenden Zustimmung')
|
||||
assert(page.url().startsWith(srv.url), `keine Navigation: ${page.url()}`)
|
||||
eq(t.stripe.length, naviVorher, 'die Zahlungsseite wurde nicht aufgerufen')
|
||||
eq(srv.st.checkoutSessions.size, 1, 'weiterhin genau eine Checkout-Sitzung')
|
||||
eq(srv.aw.docs('featureConsents').size, 0, 'kein Nachweis ohne Zustimmung des Kunden')
|
||||
await shot(page, 'zahlung-fortsetzen-ohne-nachweis')
|
||||
|
||||
// ---- Zustand setup: Karte mit Ticketnummer und Rechnungslink
|
||||
await srv.einrichtung()
|
||||
await oeffne(page, '#termine')
|
||||
await karte.waitFor()
|
||||
eq(await karte.getAttribute('data-state'), 'setup', 'Karte im Zustand setup')
|
||||
const text = await karte.innerText()
|
||||
assert(text.includes('In Einrichtung') && text.includes('#100342'), `Karte mit Ticketnummer: ${text}`)
|
||||
const rechnung = karte.getByRole('link', { name: 'Rechnung ansehen' })
|
||||
eq(await rechnung.getAttribute('href'), 'https://invoice.stripe.test/i/in_ui1', 'Rechnungslink')
|
||||
eq(await rechnung.getAttribute('rel'), 'noopener noreferrer', 'Rechnungslink mit rel')
|
||||
eq(await page.getByRole('button', { name: 'Zahlung fortsetzen' }).count(), 0, 'in der Einrichtung gibt es kein „Zahlung fortsetzen“')
|
||||
await shot(page, 'in-einrichtung')
|
||||
})
|
||||
|
||||
// ============================================================ Entwurf bleibt beim Neuladen
|
||||
await check('entwurf_bleibt_beim_neuladen', async (page, t) => {
|
||||
await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €')
|
||||
await brancheStarten(page, t)
|
||||
await schritteAusfuellen(page, t, 'zeiten') // Schritte 2 bis 4
|
||||
await warteSchritt(page, 'regeln')
|
||||
|
||||
// ---- neu laden: Start bei Schritt 5, Eingaben der Schritte 1 bis 4 stehen in den Feldern
|
||||
await page.reload()
|
||||
await warteFragebogen(page)
|
||||
await warteSchritt(page, 'regeln')
|
||||
eq(await standText(page), 'Schritt 5 von 8', 'nach dem Neuladen Schritt 5')
|
||||
assert((await page.locator('.tm-schritt[data-erledigt="true"]').count()) === 4, 'vier Schritte als erledigt markiert')
|
||||
await schrittKnopf(page, 'branche').click()
|
||||
await warteSchritt(page, 'branche')
|
||||
eq(await feldIn(page, 'betrieb.name').inputValue(), FELD_NAME, 'Schritt 1: Name')
|
||||
eq(await feldIn(page, 'betrieb.adresse').inputValue(), FELD_ADRESSE, 'Schritt 1: Anschrift')
|
||||
assert((await page.locator('.tm-fb-branche').innerText()).includes('Friseur und Salon'), 'Schritt 1: Branche')
|
||||
await schrittKnopf(page, 'wer').click()
|
||||
await warteSchritt(page, 'wer')
|
||||
assert(await page.locator('input[name="termine-wer-art"][value="team"]').isChecked(), 'Schritt 2: Team gewaehlt')
|
||||
eq(await page.locator('.tm-fb-schritt[data-schritt="wer"] textarea').inputValue(), 'Anna\nBen', 'Schritt 2: Namen')
|
||||
await schrittKnopf(page, 'leistungen').click()
|
||||
await warteSchritt(page, 'leistungen')
|
||||
assert(await feldIn(page, 'preiseGeprueft').isChecked(), 'Schritt 3: Preise geprueft')
|
||||
await schrittKnopf(page, 'zeiten').click()
|
||||
await warteSchritt(page, 'zeiten')
|
||||
eq(await feldIn(page, 'bundesland').inputValue(), 'RP', 'Schritt 4: Bundesland')
|
||||
await shot(page, 'nach-neuladen')
|
||||
|
||||
// ---- Schritt 8 vorab ausfuellen und bestaetigen: der Einbauwunsch muss den Wechsel der Branche ueberstehen
|
||||
await schrittKnopf(page, 'website').click()
|
||||
await warteSchritt(page, 'website')
|
||||
await EINGABE.website(page)
|
||||
await feldIn(page, 'einbau.stelle').fill('Auf der Seite Kontakt unter dem Text')
|
||||
await weiter(page, t, 'website', 'bestellen')
|
||||
NACH_SCHRITT.website()
|
||||
eq(srv.fragebogen().bestaetigt, ['branche', 'wer', 'leistungen', 'zeiten', 'website'], 'fuenf Schritte bestaetigt')
|
||||
const einbau = srv.fragebogen().einbau
|
||||
eq(einbau.stelle, 'Auf der Seite Kontakt unter dem Text', 'Einbauwunsch mit Stelle gespeichert')
|
||||
|
||||
// ---- Branche wechseln mit Rueckfrage
|
||||
await schrittKnopf(page, 'branche').click()
|
||||
await warteSchritt(page, 'branche')
|
||||
await page.locator('[data-aktion="branche-wechseln"]').click()
|
||||
const rueckfrage = page.locator('.tm-frage-box')
|
||||
await rueckfrage.waitFor()
|
||||
assert((await rueckfrage.innerText()).includes('Alle Bestätigungen gehen zurück'), 'Rueckfrage warnt vor dem Zuruecksetzen')
|
||||
eq(srv.fragebogen().branche, 'salon', 'vor der Bestaetigung ist nichts geaendert')
|
||||
await rueckfrage.locator('label.tm-vorlage', { hasText: 'Kosmetik und Nagelstudio' }).locator('input').check()
|
||||
await shot(page, 'branche-wechseln-rueckfrage')
|
||||
const v0 = srv.version()
|
||||
await rueckfrage.getByRole('button', { name: 'Ja, Branche wechseln', exact: true }).click()
|
||||
await page.locator('.tm-fb-branche', { hasText: 'Kosmetik und Nagelstudio' }).waitFor()
|
||||
eq(srv.fragebogen().branche, 'kosmetik', 'neue Branche gespeichert')
|
||||
eq(srv.fragebogen().bestaetigt, [], 'Bestaetigungen zurueckgesetzt')
|
||||
eq(srv.fragebogen().bundeslandGewaehlt, false, 'Bundesland muss neu gewaehlt werden')
|
||||
eq(srv.fragebogen().preiseGeprueft, false, 'Preise muessen neu geprueft werden')
|
||||
eq(srv.fragebogen().einbau, einbau, 'der Einbauwunsch bleibt')
|
||||
eq(srv.einstellung().vorlage, 'kosmetik', 'Einstellung aus der neuen Vorlage')
|
||||
assert(srv.version() > v0, 'Version der Einstellung gestiegen (Ersetzen ist ein Schreiben der Einstellung)')
|
||||
eq(srv.einstellung().betrieb.name, FELD_NAME, 'Betriebsname bleibt')
|
||||
eq(srv.einstellung().betrieb.impressumUrl, IMPRESSUM, 'Impressum bleibt')
|
||||
eq(await page.locator('.tm-schritt[data-erledigt="true"]').count(), 0, 'kein Schritt mehr als erledigt markiert')
|
||||
await shot(page, 'nach-branchenwechsel')
|
||||
})
|
||||
|
||||
// ============================================================ Kundenansicht
|
||||
await check('kundenansicht', async (page, t) => {
|
||||
await srv.entwurf({ fragebogen: vollstaendig({ bestaetigt: ['branche'] }) })
|
||||
await oeffne(page, '#termine')
|
||||
await warteFragebogen(page)
|
||||
await warteSchritt(page, 'wer')
|
||||
const hinweis = page.locator('.tm-fragebogen .tm-box-info')
|
||||
assert((await hinweis.innerText()).includes('Kundenansicht'), 'Hinweis auf die Kundenansicht')
|
||||
// alle Schritte lassen sich lesen und mit „Weiter“ durchklicken, bestaetigt wird nichts
|
||||
for (const [i, key] of FOLGE.slice(1).entries()) {
|
||||
await warteSchritt(page, key)
|
||||
eq(await standText(page), `Schritt ${i + 2} von 8`, `Fortschritt in ${key}`)
|
||||
await weiterKnopf(page).click()
|
||||
await warteSchritt(page, FOLGE[i + 2] ?? 'bestellen')
|
||||
eq(srv.fragebogen().bestaetigt, ['branche'], `nach „Weiter“ in ${key} bleibt bestaetigt unveraendert`)
|
||||
}
|
||||
eq(zaehlePuts(t), 0, 'die Seite sendet in der Kundenansicht keinen PUT')
|
||||
// Bestellseite: gesperrt mit Hinweis (hier zusaetzlich offene Punkte, weil nichts bestaetigt wurde)
|
||||
assert((await page.locator('.tm-bestellen-offen li').count()) >= 7, 'sieben unbestaetigte Schritte stehen als offene Punkte da')
|
||||
await hakenSetzen(page)
|
||||
assert(await bestellKnopf(page).isDisabled(), 'Bestellknopf gesperrt')
|
||||
eq((await page.locator('[data-hinweis="bestellen"]').innerText()).trim(), 'In der Kundenansicht kann nicht bestellt werden.', 'Hinweis am Knopf')
|
||||
await shot(page, 'kundenansicht-bestellseite')
|
||||
|
||||
// ---- nichts offen, trotzdem gesperrt: nur die Kundenansicht haelt den Knopf zu
|
||||
await srv.entwurf({ fragebogen: vollstaendig() })
|
||||
await page.reload()
|
||||
await warteFragebogen(page)
|
||||
await warteSchritt(page, 'bestellen')
|
||||
eq(await page.locator('.tm-bestellen-offen').count(), 0, 'nichts offen')
|
||||
await hakenSetzen(page)
|
||||
assert(await bestellKnopf(page).isDisabled(), 'Knopf gesperrt, obwohl nichts offen ist und alle Haken gesetzt sind')
|
||||
eq((await page.locator('[data-hinweis="bestellen"]').innerText()).trim(), 'In der Kundenansicht kann nicht bestellt werden.', 'Hinweis am Knopf (nichts offen)')
|
||||
|
||||
// ---- die Router selbst: PUT /fragebogen und der Checkout sind 403
|
||||
const vorher = srv.fragebogen()
|
||||
const direkt = await page.evaluate(async ({ fragebogen }) => {
|
||||
const sende = async (url, method, body) => {
|
||||
const res = await fetch(url, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
||||
return { status: res.status, body: await res.json() }
|
||||
}
|
||||
return {
|
||||
put: await sende('/api/termine/fragebogen', 'PUT', { projectId: 'p1', fragebogen: { ...fragebogen, bestaetigt: [] } }),
|
||||
checkout: await sende('/api/funktionen/termine/checkout', 'POST', { projectId: 'p1', zustimmung: { version: 'x', haken: [] } }),
|
||||
}
|
||||
}, { fragebogen: vorher })
|
||||
eq(direkt.put.status, 403, 'PUT /fragebogen in der Kundenansicht')
|
||||
eq(direkt.checkout.status, 403, 'Checkout in der Kundenansicht')
|
||||
eq(srv.fragebogen(), vorher, 'der Fragebogen blieb unveraendert')
|
||||
eq(srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions').length, 0, 'keine Stripe-Sitzung')
|
||||
eq(srv.aw.docs('featureConsents').size, 0, 'kein Nachweis')
|
||||
}, { cookie: 'kundenansicht' })
|
||||
|
||||
// ============================================================ Handy 390 px
|
||||
await check('handy_390', async (page) => {
|
||||
const pruefe = async (name, soll) => {
|
||||
assert(await passtInBreite(page), `${name}: seitliche Rollleiste (scrollWidth ${await page.evaluate(() => document.documentElement.scrollWidth)})`)
|
||||
const stand = page.locator('.tm-schritt-stand')
|
||||
assert(await stand.isVisible(), `${name}: Fortschritt ist am Handy sichtbar`)
|
||||
eq((await stand.innerText()).trim(), soll, `${name}: Fortschritt`)
|
||||
}
|
||||
// Schritt 1 ohne Entwurf
|
||||
await oeffne(page, '#termine')
|
||||
await warteFragebogen(page)
|
||||
await warteSchritt(page, 'branche')
|
||||
await pruefe('Schritt 1 (Start)', 'Schritt 1 von 8')
|
||||
await shot(page, '390-1-start')
|
||||
// mit Entwurf: Bestellseite, Schritt 4, Schritt 8, Schritt 1
|
||||
await srv.entwurf({ fragebogen: vollstaendig() })
|
||||
await page.reload()
|
||||
await warteFragebogen(page)
|
||||
await warteSchritt(page, 'bestellen')
|
||||
assert(await passtInBreite(page), 'Bestellseite: seitliche Rollleiste')
|
||||
await hakenSetzen(page)
|
||||
assert(await passtInBreite(page), 'Bestellseite mit Haken: seitliche Rollleiste')
|
||||
await shot(page, '390-2-bestellseite')
|
||||
await schrittKnopf(page, 'zeiten').click()
|
||||
await warteSchritt(page, 'zeiten')
|
||||
await pruefe('Schritt 4', 'Schritt 4 von 8')
|
||||
await shot(page, '390-3-zeiten')
|
||||
await schrittKnopf(page, 'website').click()
|
||||
await warteSchritt(page, 'website')
|
||||
await pruefe('Schritt 8', 'Schritt 8 von 8')
|
||||
await shot(page, '390-4-website')
|
||||
await schrittKnopf(page, 'branche').click()
|
||||
await warteSchritt(page, 'branche')
|
||||
await pruefe('Schritt 1 (Entwurf)', 'Schritt 1 von 8')
|
||||
await shot(page, '390-5-branche-entwurf')
|
||||
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Lauf
|
||||
|
||||
let abbruch = null
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
await pruefungen()
|
||||
const fremd = [...hosts].filter((h) => !ERLAUBTE_HOSTS.has(h))
|
||||
results.push(['nur_eigene_herkunft', fremd.length === 0])
|
||||
console.log(fremd.length ? `FAIL nur_eigene_herkunft: Anfragen an fremde Hosts: ${fremd.join(', ')}` : 'ok nur_eigene_herkunft')
|
||||
} catch (e) {
|
||||
abbruch = e
|
||||
console.log(`ABBRUCH: ${e.stack || e.message}`)
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {})
|
||||
await srv.stop().catch((e) => console.log(`Server beenden: ${e.message}`))
|
||||
}
|
||||
|
||||
const fehlgeschlagen = results.filter(([, ok]) => !ok)
|
||||
console.log(`${results.length - fehlgeschlagen.length}/${results.length} Checks bestanden`)
|
||||
process.exit(abbruch || fehlgeschlagen.length ? 1 : 0)
|
||||
@@ -68,6 +68,21 @@ function portalHilfe(p, json) {
|
||||
return json({})
|
||||
}
|
||||
|
||||
// Branchen und Bestellsicht, wie GET /api/termine/einstellungen sie liefert (Server: BRANCHEN, bestellungAnsicht); feste Texte der Fixture
|
||||
const FX_BRANCHEN = [
|
||||
{ key: 'salon', name: 'Friseur und Salon', gesundheit: false },
|
||||
{ key: 'kosmetik', name: 'Kosmetik und Wellness', gesundheit: false },
|
||||
{ key: 'gesundheit', name: 'Gesundheitsbereich', gesundheit: true },
|
||||
]
|
||||
const FX_BESTELLUNG = {
|
||||
preis: '149 €',
|
||||
preisHinweis: 'ohne Umsatzsteuer gemäß § 19 UStG',
|
||||
einrichtung: 'meist innerhalb von 1–2 Wochen',
|
||||
consent: { version: 'fx-1', knopf: 'Zahlungspflichtig bestellen · 149 €', items: [{ id: 'verfuegbar', text: 'Ich bestelle den Terminplaner verbindlich.', haken: true }] },
|
||||
dokumente: [],
|
||||
belehrung: 'Widerrufsbelehrung der Fixture. Du hast das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.',
|
||||
}
|
||||
|
||||
/** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/termine liefert. */
|
||||
function neueFixture() {
|
||||
const fx = {
|
||||
@@ -82,14 +97,25 @@ function neueFixture() {
|
||||
{ key: 'beratung', name: 'Beratung und Coaching', beschreibung: 'Gespräche vor Ort oder online.' },
|
||||
],
|
||||
fehlt: [],
|
||||
fragebogen: { schema: 1, branche: 'salon', bestaetigt: [], bundeslandGewaehlt: false, preiseGeprueft: false, einbau: { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' } },
|
||||
offen: [],
|
||||
checkout: { status: 200, body: { url: 'https://checkout.stripe.test/c/pay/cs_test_fx' } }, // Antwort auf POST /api/funktionen/termine/checkout
|
||||
anfragenOffen: 0,
|
||||
buchungen: [],
|
||||
aufrufe: [],
|
||||
}
|
||||
// GET /api/termine/einstellungen. off: Entwurf mit Abo (abo), offen und Bestellsicht; ordered: Bestellsicht und offen, aber kein abo (R110)
|
||||
fx.daten = (state = fx.projekte[0].features.termine.state) => ({
|
||||
state, aktivSeit: '2026-10-01T09:00:00.000Z', einstellung: fx.einstellung, version: fx.einstellung ? 3 : 0, vorlagen: fx.vorlagen, feeds: {}, siteKey: KEY,
|
||||
einbau: { snippet: '' }, anfragenOffen: fx.buchungen.filter((b) => b.status === 'angefragt').length || fx.anfragenOffen, fehlt: fx.fehlt,
|
||||
abgeschaltet: state === 'disabled' ? fx.abgeschaltet ?? { grund: null, seit: null, loeschungAm: null } : null,
|
||||
branchen: FX_BRANCHEN,
|
||||
fragebogen: fx.fragebogen,
|
||||
kundenansicht: false,
|
||||
...(fx.einstellung ? {} : { betriebVorschlag: { name: 'Salon Müller', adresse: 'Hauptstr. 1, 67722 Winnweiler', email: 'info@salon-mueller.example', telefon: '', websiteUrl: '' } }),
|
||||
abo: state === 'off' ? (fx.bestellbar === true ? { ok: true } : fx.bestellbar === false ? { ok: false, grund: fx.aboGrund } : null) : null,
|
||||
offen: state === 'off' || state === 'ordered' ? fx.offen : null,
|
||||
bestellung: state === 'off' || state === 'ordered' ? FX_BESTELLUNG : null,
|
||||
})
|
||||
return fx
|
||||
}
|
||||
@@ -125,6 +151,15 @@ function alleStatus() {
|
||||
]
|
||||
}
|
||||
|
||||
// Der Abo-Text kommt in der Antwort von /api/funktionen vom Server (termine.hinweis, aboMeldung in featureOrder.js); die Fixture
|
||||
// liefert feste Texte, die Kachel zeigt sie nur an
|
||||
const FX_ABO_HINWEISE = {
|
||||
kein_abo: 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.',
|
||||
gekuendigt: 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.',
|
||||
nicht_aktiv: 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.',
|
||||
unbekannt: 'Das Abo konnte gerade nicht geprüft werden.',
|
||||
}
|
||||
|
||||
/** Antworten der Fixture auf /api/funktionen und /api/termine (Teil von page.route). */
|
||||
async function fixtureAntwort(fx, route, req, url, json) {
|
||||
const p = url.pathname
|
||||
@@ -132,14 +167,25 @@ async function fixtureAntwort(fx, route, req, url, json) {
|
||||
const m = req.method()
|
||||
if (p === '/api/funktionen') {
|
||||
const termine = fx.verfuegbar
|
||||
? { verfuegbar: true, bestellbar: fx.bestellbar, grund: fx.aboGrund, preis: '149 €', einrichtung: 'meist innerhalb von 1–2 Wochen' }
|
||||
? {
|
||||
verfuegbar: true,
|
||||
bestellbar: fx.bestellbar,
|
||||
grund: fx.aboGrund,
|
||||
preis: '149 €',
|
||||
einrichtung: 'meist innerhalb von 1–2 Wochen',
|
||||
hinweis: fx.bestellbar === true ? null : FX_ABO_HINWEISE[fx.aboGrund ?? 'unbekannt'],
|
||||
}
|
||||
: { verfuegbar: false }
|
||||
return json({ projects: fx.projekte, termine })
|
||||
}
|
||||
if (p === '/api/funktionen/termine/checkout' && m === 'POST') {
|
||||
fx.aufrufe.push(['checkout', req.postDataJSON()])
|
||||
return json(fx.checkout.body, fx.checkout.status)
|
||||
}
|
||||
if (p === '/api/termine/einstellungen' && m === 'GET') {
|
||||
const projekt = q.get('projectId')
|
||||
if (fx.verzoegerung?.[projekt]) await new Promise((fertig) => { setTimeout(fertig, fx.verzoegerung[projekt]) })
|
||||
const daten = fx.daten()
|
||||
const daten = fx.daten(fx.projekte.find((p) => p.projectId === projekt)?.features?.termine?.state)
|
||||
if (fx.ohneEinstellungFuer?.includes(projekt)) Object.assign(daten, { einstellung: null, version: 0 })
|
||||
return json(daten)
|
||||
}
|
||||
@@ -252,7 +298,7 @@ const formular = (page, name) => page.getByRole('form', { name })
|
||||
const fehlerAm = (loc, pfad) => loc.locator(`[data-fehler="${pfad}"]`)
|
||||
const warteAnsicht = async (page) => {
|
||||
await page.locator('#view-termine.active').waitFor()
|
||||
await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info').first().waitFor()
|
||||
await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info, #termine-body .tm-fragebogen, #termine-body .tm-statuskarte').first().waitFor()
|
||||
}
|
||||
|
||||
// ---- Gaeste ueber die oeffentliche Schnittstelle buchen (echte Pruefungen): Link-Token aus der Mail
|
||||
@@ -324,7 +370,7 @@ async function pruefungen() {
|
||||
await knopf(kachel, 'Fragebogen fortsetzen').waitFor()
|
||||
// Aus ohne Abo: kein Knopf, Hinweis; die Hinweise der uebrigen Gruende
|
||||
const faelle = [
|
||||
['kein_abo', 'Im Wartungs-Abo enthalten.'],
|
||||
['kein_abo', 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.'],
|
||||
['gekuendigt', 'Dein Wartungs-Abo ist gekündigt.'],
|
||||
['nicht_aktiv', 'Dein Wartungs-Abo ist gerade nicht aktiv'],
|
||||
]
|
||||
@@ -432,11 +478,12 @@ async function pruefungen() {
|
||||
await warteAnsicht(page)
|
||||
await page.locator('.tm-reiterleiste').waitFor()
|
||||
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl: die Website der Kachel')
|
||||
// andere Website ohne Terminplaner: Hinweis statt Reiter
|
||||
// andere Website, Terminplaner nicht bestellt (Aus, das Abo läuft): der Fragebogen statt der Reiter (C10b ersetzt den Hinweis „noch nicht freigeschaltet“)
|
||||
await page.locator('#termine-project').selectOption('p1')
|
||||
await page.getByText('noch nicht freigeschaltet').waitFor()
|
||||
await page.locator('#termine-body .tm-fragebogen').waitFor()
|
||||
eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
|
||||
await knopf(page.locator('#termine-body'), 'Chat öffnen').waitFor()
|
||||
assert(!(await textVon(page.locator('#termine-body'))).includes('noch nicht freigeschaltet'), 'der alte Text ist weg')
|
||||
eq(await page.locator('#termine-body [data-view="chat"]').count(), 0, 'kein Chat-Knopf der alten Ansicht')
|
||||
await shot(page, 'projektwahl')
|
||||
await page.locator('#termine-project').selectOption('p2')
|
||||
await page.locator('.tm-reiterleiste').waitFor()
|
||||
@@ -457,6 +504,171 @@ async function pruefungen() {
|
||||
})(),
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Ansicht nach Zustand (TP2 C10b)
|
||||
/** Fixture mit einer Website im Zustand state (Eintrag der Liste wie GET /api/funktionen ihn liefert). */
|
||||
const imZustand = (state, extra = {}) => {
|
||||
const fx = neueFixture()
|
||||
fx.projekte[0].features.termine = { state, ...extra }
|
||||
return fx
|
||||
}
|
||||
const GEORDERT = { orderedAt: '2026-10-04T08:00:00.000Z', ticketWoid: null, invoiceUrl: null }
|
||||
|
||||
await check('fx_ansicht_aus_fragebogen_und_hinweis', async (page) => {
|
||||
const fx = page.fx
|
||||
// das Abo läuft: der Fragebogen mit Fortschrittsleiste, keine Reiter
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
await page.locator('#termine-body nav.tm-fortschritt').waitFor()
|
||||
eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
|
||||
eq(await page.locator('[data-hinweis="abo"]').count(), 0, 'kein Hinweis')
|
||||
eq(await page.locator('li.tm-schritt[aria-current="step"]').getAttribute('data-schritt'), 'branche', 'Start beim ersten offenen Schritt')
|
||||
await shot(page, 'ansicht-fragebogen')
|
||||
// ohne Abo: nur der Hinweis des Servers, bei kein_abo mit dem Link zu den Verträgen; kein Fragebogen
|
||||
fx.bestellbar = false
|
||||
for (const [grund, mitLink] of [['kein_abo', true], ['gekuendigt', false], ['nicht_aktiv', false], ['unbekannt', false]]) {
|
||||
fx.aboGrund = grund === 'unbekannt' ? null : grund
|
||||
fx.bestellbar = grund === 'unbekannt' ? null : false
|
||||
await page.reload()
|
||||
await page.locator('[data-hinweis="abo"]').waitFor()
|
||||
const hinweis = page.locator('[data-hinweis="abo"]')
|
||||
assert((await textVon(hinweis)).includes(FX_ABO_HINWEISE[grund]), `${grund}: Text des Servers in ${await textVon(hinweis)}`)
|
||||
eq(await hinweis.locator('[data-view="vertraege"]').count(), mitLink ? 1 : 0, `${grund}: Link zu Verträge`)
|
||||
eq(await page.locator('#termine-body nav.tm-fortschritt').count(), 0, `${grund}: kein Fragebogen`)
|
||||
eq(await page.locator('#termine-body input').count(), 0, `${grund}: keine Felder`)
|
||||
eq(await page.locator('.tm-reiterleiste').count(), 0, `${grund}: keine Reiter`)
|
||||
if (grund === 'kein_abo') await shot(page, 'ansicht-ohne-abo')
|
||||
}
|
||||
// der Link führt zu den Verträgen
|
||||
fx.bestellbar = false
|
||||
fx.aboGrund = 'kein_abo'
|
||||
await page.reload()
|
||||
await knopf(page.locator('[data-hinweis="abo"]'), 'Zu Verträge').click()
|
||||
await page.locator('#view-vertraege.active').waitFor()
|
||||
}, { fixture: imZustand('off') })
|
||||
|
||||
for (const [state, pille, extra] of [
|
||||
['ordered', 'Zahlung offen', GEORDERT],
|
||||
['setup', 'In Einrichtung', { ...GEORDERT, ticketWoid: 100342, invoiceUrl: 'https://invoice.stripe.test/i/in_1' }],
|
||||
]) {
|
||||
await check(`fx_ansicht_${state}`, async (page) => {
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
const karte = page.locator('.tm-statuskarte')
|
||||
await karte.waitFor()
|
||||
eq(await textVon(karte.locator('.tm-pille')), pille, 'Pille')
|
||||
assert((await textVon(karte)).includes('Bestellt am'), 'Bestelldatum')
|
||||
if (state === 'setup') assert((await textVon(karte)).includes('#100342'), 'Ticketnummer')
|
||||
else eq(await karte.locator('[data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen')
|
||||
eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'drei Reiter, keiner für Termine')
|
||||
eq(await page.locator('#tm-tab-termine').count(), 0, 'kein Reiter Termine')
|
||||
eq(await page.locator('.tm-hinweise .tm-box').count(), 0, 'weder Banner der Abschaltung noch „Noch nicht buchbar“')
|
||||
await shot(page, `ansicht-${state}`)
|
||||
// schreibgeschützt: Leistungen ohne „+ Leistung anlegen“, Einstellungen gesperrt, ohne Speichern, ohne „Aktiv“-Status und Export
|
||||
await page.locator('#tm-tab-leistungen').click()
|
||||
await page.locator('#tm-panel .tm-tab').waitFor()
|
||||
eq(await page.locator('[data-werkzeug="leistung-neu"]').count(), 0, 'Leistungen: nichts zum Anlegen')
|
||||
assert(!(await textVon(page.locator('#tm-panel'))).split('\n').some((zeile) => zeile.trim() === 'null'), 'Leistungen: kein Text „null“')
|
||||
await page.locator('#tm-tab-einstellungen').click()
|
||||
await page.locator('#tm-panel .tm-tab').waitFor()
|
||||
eq(await page.locator('#tm-panel fieldset[disabled]').count() > 0, true, 'Einstellungen gesperrt')
|
||||
eq(await page.locator('#tm-panel button[type="submit"]').count(), 0, 'Einstellungen: kein Speichern')
|
||||
eq(await page.locator('#tm-panel .tm-status').count(), 0, 'kein „Aktiv“-Status vor der Freischaltung')
|
||||
eq(await page.locator('#tm-panel a[download]').count(), 0, 'kein Export vor der Freischaltung')
|
||||
eq(page.fx.aufrufe.filter((a) => a[0] === 'liste').length, 0, 'der Reiter Termine wird nicht gelesen')
|
||||
}, { fixture: imZustand(state, extra) })
|
||||
}
|
||||
|
||||
await check('fx_ansicht_bestellt_390', async (page) => {
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
await page.locator('.tm-statuskarte').waitFor()
|
||||
eq(await passtInBreite(page), true, 'kein Streifen rechts')
|
||||
const kleine = await page.evaluate(() => [...document.querySelectorAll('#view-termine button')]
|
||||
.filter((e) => e.offsetParent !== null && e.getBoundingClientRect().height < 43.5).map((e) => `${e.textContent.trim().slice(0, 24)} ${Math.round(e.getBoundingClientRect().height)}`))
|
||||
eq(kleine, [], 'Knöpfe mindestens 44 px hoch')
|
||||
await shot(page, '390-ansicht-bestellt')
|
||||
}, { fixture: imZustand('ordered', GEORDERT), context: { viewport: { width: 390, height: 844 } } })
|
||||
|
||||
await check('fx_rueckkehr_zahlung', async (page) => {
|
||||
await oeffne(page, '#termine?bestellt=1&projekt=p2')
|
||||
await warteAnsicht(page)
|
||||
await page.locator('.tm-statuskarte [data-danke]').waitFor()
|
||||
eq(await textVon(page.locator('[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.', 'Danke')
|
||||
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Zahlung')
|
||||
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
|
||||
await shot(page, 'rueckkehr-zahlung')
|
||||
// neu geladen: kein Danke mehr, die Auswahl bleibt nicht (Seite neu), aber die Adresse ist sauber
|
||||
await page.reload()
|
||||
await warteAnsicht(page)
|
||||
eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Neuladen')
|
||||
}, {
|
||||
fixture: (() => {
|
||||
const fx = neueFixture()
|
||||
fx.projekte = [
|
||||
{ projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
|
||||
{ projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } },
|
||||
]
|
||||
return fx
|
||||
})(),
|
||||
})
|
||||
|
||||
await check('fx_rueckkehr_abbruch', async (page) => {
|
||||
await oeffne(page, '#termine?projekt=p2')
|
||||
await warteAnsicht(page)
|
||||
await page.locator('.tm-statuskarte').waitFor()
|
||||
eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Abbruch')
|
||||
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Bestellung')
|
||||
eq(await page.locator('.tm-statuskarte [data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen')
|
||||
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
|
||||
}, {
|
||||
fixture: (() => {
|
||||
const fx = neueFixture()
|
||||
fx.projekte = [
|
||||
{ projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
|
||||
{ projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } },
|
||||
]
|
||||
return fx
|
||||
})(),
|
||||
})
|
||||
|
||||
await check('fx_zahlung_fortsetzen_zustimmung_fehlt', async (page) => {
|
||||
const fx = page.fx
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
await page.locator('.tm-statuskarte').waitFor()
|
||||
await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click()
|
||||
// die Karte weicht dem Bestellformular (R110), die Reiter bleiben
|
||||
await page.locator('.tm-bestellen').waitFor()
|
||||
eq(await page.locator('.tm-statuskarte').count(), 0, 'keine Karte mehr')
|
||||
eq(fx.aufrufe.filter((a) => a[0] === 'checkout').map((a) => a[1]), [{ projectId: 'p1', fortsetzen: true }], 'Zahlung fortsetzen ohne Zustimmung')
|
||||
assert((await textVon(meldung(page))).includes('Bitte stimme der Erklärung zu'), 'Meldung der Hülle')
|
||||
const bestellen = page.locator('[data-aktion="bestellen"]')
|
||||
eq(await bestellen.isDisabled(), true, 'gesperrt bis zum Haken')
|
||||
await page.locator('.tm-bestellen input[type="checkbox"]').check()
|
||||
eq(await bestellen.isDisabled(), false, 'frei nach dem Haken')
|
||||
eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'Reiter bleiben')
|
||||
await shot(page, 'zahlung-fortsetzen-bestellformular')
|
||||
}, {
|
||||
fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }),
|
||||
})
|
||||
|
||||
await check('fx_zahlung_fortsetzen_inzwischen_bezahlt', async (page) => {
|
||||
const fx = page.fx
|
||||
await oeffne(page, '#termine')
|
||||
await warteAnsicht(page)
|
||||
await page.locator('.tm-statuskarte').waitFor()
|
||||
eq(await textVon(page.locator('.tm-statuskarte .tm-pille')), 'Zahlung offen', 'zuerst offen')
|
||||
// inzwischen kam die Zahlung an (Webhook): der Server kennt keine offene Bestellung mehr (400)
|
||||
fx.projekte[0].features.termine = { state: 'setup', ...GEORDERT, ticketWoid: 100343 }
|
||||
await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click()
|
||||
await page.locator('.tm-statuskarte .tm-pille', { hasText: 'In Einrichtung' }).waitFor()
|
||||
assert((await textVon(page.locator('.tm-statuskarte'))).includes('#100343'), 'Ticketnummer der frischen Liste')
|
||||
eq(await page.locator('[data-aktion="fortsetzen"]').count(), 0, 'kein Knopf mehr')
|
||||
eq(await page.locator('.tm-bestellen').count(), 0, 'kein Bestellformular')
|
||||
}, {
|
||||
fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }),
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------ Reiter-Geruest
|
||||
await check('fx_reiter_geruest', async (page) => {
|
||||
await oeffne(page, '#termine')
|
||||
@@ -1862,9 +2074,11 @@ async function pruefungen() {
|
||||
// Hinweise der Spezifikation
|
||||
const text = await textVon(page.locator('#tm-panel'))
|
||||
// Ruling R52: Wortlaut der rechtlichen Hinweise
|
||||
eq(await textVon(page.locator('#tm-panel .tm-box-info', { hasText: 'Privatpersonen können eine Buchung' })), 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.', 'Hinweis: Widerruf schlägt Stornoregel (R55: auch Anfragen)')
|
||||
eq(await textVon(page.locator('#tm-panel label.tm-haken small', { hasText: 'Nur ankreuzen, wenn' })), 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend oder ein Escape Room. Friseur, Kosmetik, Massage, Tattoo, Beratung, Fotoshootings und Weiterbildungen sind in der Regel keine Freizeitleistung. Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.', 'Hinweis: Freizeit')
|
||||
eq(await textVon(page.locator('#tm-panel .tm-box-info', { hasText: 'Privatpersonen können eine Buchung' })), 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. Hat ein Gast ausdrücklich verlangt, dass du vor Ablauf der Frist beginnst, und widerruft er danach, kann er für die bis dahin erbrachte Leistung zu einem angemessenen Betrag verpflichtet sein (Wertersatz, § 357a Abs. 2 BGB). Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.', 'Hinweis: Widerruf schlägt Stornoregel (R55: auch Anfragen)')
|
||||
eq(await textVon(page.locator('#tm-panel label.tm-haken small', { hasText: 'Nur ankreuzen, wenn' })), 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend, ein Kurs oder ein Escape Room. Nach unserer Einschätzung gilt bei Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung in der Regel ein Widerrufsrecht; bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht. Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. Das ist keine Rechtsberatung: Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.', 'Hinweis: Freizeit')
|
||||
assert(text.includes('kein fertiger Rechtstext') && await page.locator('.tm-muster .tm-pille', { hasText: 'Muster' }).count() === 1, 'Stornotext-Muster als Muster gekennzeichnet')
|
||||
eq(await textVon(page.locator('#tm-panel [data-hinweis="storno-muster"]')), 'Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.', 'Hinweis „Muster“ beim Stornofeld')
|
||||
assert(text.includes('Die Stornobedingungen gelten nur für Leistungen mit Preis; bei kostenlosen Leistungen fragt der Terminplaner Gäste nicht danach.'), 'Storno nur bei Preis')
|
||||
assert(text.includes('Bitte keine Gesundheitsangaben.'), 'Hinweis am Nachrichtenfeld')
|
||||
assert(text.includes('Aktiv seit 01.10.2026'), 'aktiv seit')
|
||||
const online = form.getByRole('radio', { name: /Online bezahlen/ })
|
||||
|
||||
@@ -182,7 +182,7 @@ async function pruefungen() {
|
||||
// Haken: Storno und vorzeitiger Beginn Pflicht (12.10. liegt in der Widerrufsfrist), Erinnerung freiwillig und nicht vorangekreuzt
|
||||
eq(await wd.locator('label[for$="h-storno"]').innerText(), 'Ich habe die Stornobedingungen gelesen.', 'Haken Storno')
|
||||
assert((await wd.locator('label[for$="h-vorzeitig"]').innerText()).startsWith('Ich verlange ausdrücklich, dass Salon Müller mit der Leistung vor Ablauf der Widerrufsfrist beginnt.'), 'Haken vorzeitig')
|
||||
eq(await wd.locator('label[for$="h-erinnerung"]').innerText(), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.', 'Haken Erinnerung')
|
||||
eq(await wd.locator('label[for$="h-erinnerung"]').innerText(), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.', 'Haken Erinnerung')
|
||||
assert(!(await wd.locator('input[name=erinnerung]').isChecked()), 'Erinnerung darf nicht vorangekreuzt sein')
|
||||
assert(await wd.locator('input[name=storno]').getAttribute('required') !== null && await wd.locator('input[name=vorzeitig]').getAttribute('required') !== null, 'Pflicht-Haken')
|
||||
assert(await wd.locator('input[name=erinnerung]').getAttribute('required') === null, 'Erinnerung ist freiwillig')
|
||||
@@ -286,7 +286,8 @@ async function pruefungen() {
|
||||
await dlg.locator('.wkt-zusammen').waitFor()
|
||||
eq(await dlg.locator('input[name=vorzeitig]').count(), 0, 'kein Haken fuer vorzeitigen Beginn ohne Preis')
|
||||
eq(await dlg.locator('.wkt-form button[type=submit]').innerText(), 'Termin buchen', 'Knopftext ohne Preis')
|
||||
await dlg.locator('input[name=storno]').check()
|
||||
eq(await dlg.locator('input[name=storno]').count(), 0, 'kein Storno-Haken ohne Preis (F11)')
|
||||
eq(await dlg.locator('.wkt-box h3', { hasText: 'Stornobedingungen' }).count(), 0, 'keine Stornobox ohne Preis (F11)')
|
||||
await dlg.getByRole('button', { name: 'Termin buchen' }).click()
|
||||
await dlg.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
|
||||
await srv.warteAufVersand()
|
||||
@@ -871,6 +872,7 @@ async function pruefungen() {
|
||||
await bisZusammenfassung(page, { leistung: 'Brautfrisur', tag: '2026-10-26' })
|
||||
await wd.locator('details.wkt-belehrung').waitFor()
|
||||
assert((await wd.locator('details.wkt-belehrung').innerText()).includes('Widerrufsbelehrung'), 'Belehrung vor dem Absenden')
|
||||
eq(await wd.locator('input[name=storno]').count(), 0, 'Anfrage ohne Preis: kein Storno-Haken (F11)')
|
||||
await hakenUndBuchen(wd, 'Termin anfragen')
|
||||
await srv.warteAufVersand()
|
||||
const buchungen = srv.buchungen()
|
||||
|
||||
@@ -21,6 +21,14 @@
|
||||
// startet ohne Einstellung (Vorlagenauswahl); der Server stellt auch dashboard.html und /api/funktionen bereit, die uebrigen
|
||||
// Portal-Schnittstellen (/api/auth/me usw.) mockt das Skript per page.route.
|
||||
//
|
||||
// Entwurf und Bestellung (TP2, Fragebogen bis Bestellklick): srv.zuruecksetzen({ entwurf: true }) startet wie ein Kunde vor der ersten Branchenwahl:
|
||||
// kein Funktionsdokument, keine Einstellung, aber ein laufendes Wartungs-Abo (wartungDoc) fuer c1 und ein Konto mit Firma und Anschrift
|
||||
// (Vorschlag fuer die Betriebsangaben). srv.abo(zustand) tauscht das Abo ('wartung' | 'hosting' | 'keins' | 'gekuendigt' | 'nicht_aktiv'),
|
||||
// srv.entwurf({ eingabe, fragebogen }) legt Funktionsdokument (off), Einstellung und Fragebogen an (Abkuerzung statt aller Klicks),
|
||||
// srv.bestellt({ mitZustimmung }) macht daraus eine Bestellung im Zustand ordered mit offener Stripe-Sitzung (und dem Nachweis dieser Sitzung),
|
||||
// srv.kundenansichtCookie() ist die Sitzung eines Mitarbeiters in der Kundenansicht (der Waechter haengt hier nicht: geprueft wird die
|
||||
// Absicherung der Router selbst). srv.fragebogen() / srv.version() lesen den gespeicherten Stand.
|
||||
//
|
||||
// Seitenherkunft: srv.url ist http://127.0.0.1:PORT, srv.pageOrigin ist http://localhost:PORT (derselbe Server, anderer Host).
|
||||
// Mit { crossOrigin: true } gilt pageOrigin als Herkunft des Portals (config.publicBaseUrl): Seiten von pageOrigin mit
|
||||
// data-api=srv.url sind dann echte Cross-Origin-Anfragen (Preflight fuer POST), die der Server erlaubt.
|
||||
@@ -118,6 +126,8 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
|
||||
termineClaims: schemaKeys('termineClaims'),
|
||||
termineOutbox: schemaKeys('termineOutbox'),
|
||||
customerViewLog: schemaKeys('customerViewLog'),
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
featureConsents: schemaKeys('featureConsents'),
|
||||
},
|
||||
datetimes: TERMINE_DATETIMES,
|
||||
defaultLimit: 25,
|
||||
@@ -128,14 +138,19 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
|
||||
const { config } = await import('../../server/config.js')
|
||||
const { startTestApp } = await import('../support/testApp.js')
|
||||
const { setMailTransportForTests } = await import('../../server/services/mailer.js')
|
||||
const { deleteDocument } = await import('../../server/services/appwriteAdmin.js')
|
||||
const { deleteDocument, updateDocument } = await import('../../server/services/appwriteAdmin.js')
|
||||
const { default: publicTermineRoutes, setzeUhrFuerTests: setzeUhrPublic, resetPublicTermineForTests } = await import('../../server/routes/publicTermine.js')
|
||||
const { default: termineRoutes, setzeUhrFuerTests: setzeUhrKunde, resetTermineRoutesForTests } = await import('../../server/routes/termine.js')
|
||||
const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js')
|
||||
const { normalisiereEinstellung } = await import('../../server/services/termine/einstellungen.js')
|
||||
const { speichereEinstellung, resetKontextForTests } = await import('../../server/services/termine/kontext.js')
|
||||
const { speichereEinstellung, speichereFragebogen, resetKontextForTests } = await import('../../server/services/termine/kontext.js')
|
||||
const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js')
|
||||
const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js')
|
||||
const { SCHRITTE, leerFragebogen } = await import('../../server/services/termine/fragebogen.js')
|
||||
const { featureConfig, featureHaken } = await import('../../server/services/featureRegistry.js')
|
||||
const { hakenTexte, markOrdered } = await import('../../server/services/siteFeatures.js')
|
||||
const { nachweisTexte, speichereZustimmung, zustimmungsfassungen } = await import('../../server/services/featureConsents.js')
|
||||
const { hostingDoc, wartungDoc } = await import('../support/fixtures.js')
|
||||
|
||||
// Testseiten (HTML aus dem Skript) kommen vom Server selbst: Chromium lehnt Anfragen von Seiten, die Playwright per
|
||||
// route.fulfill liefert, an 127.0.0.1 ab (Local Network Access); Seiten desselben Servers duerfen.
|
||||
@@ -233,15 +248,123 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
|
||||
return { name: roh.slice(0, i), value: roh.slice(i + 1), url: app.url }
|
||||
},
|
||||
|
||||
/**
|
||||
* Wartungs-Abo des Kunden c1 tauschen: 'wartung' (laeuft), 'hosting' (nur Hosting-Abo, Grund kein_abo), 'keins' (gar kein Abo, kein_abo),
|
||||
* 'gekuendigt' (Kuendigung vorgemerkt, Grund gekuendigt) oder 'nicht_aktiv' (Zahlung offen, Grund nicht_aktiv).
|
||||
*/
|
||||
async abo(zustand = 'wartung') {
|
||||
for (const id of [...aw.docs('subscriptions').keys()]) await deleteDocument('subscriptions', id)
|
||||
const docs = {
|
||||
wartung: [wartungDoc],
|
||||
hosting: [hostingDoc],
|
||||
keins: [],
|
||||
gekuendigt: [{ ...wartungDoc, cancelAtPeriodEnd: true }],
|
||||
nicht_aktiv: [{ ...wartungDoc, status: 'past_due' }],
|
||||
}[zustand]
|
||||
if (!docs) throw new Error(`Unbekanntes Abo: ${zustand}`)
|
||||
for (const doc of docs) aw.seed('subscriptions', doc)
|
||||
},
|
||||
|
||||
/** Cookie einer Kundenansicht (Mitarbeiter Andrej sieht das Portal von c1) fuer page.context().addCookies([...]): { name, value, url }. */
|
||||
async kundenansichtCookie({ customerId = KUNDE } = {}) {
|
||||
const { kundenansichtCookie } = await import('../support/fixtures.js')
|
||||
const roh = kundenansichtCookie({ customerId })
|
||||
const i = roh.indexOf('=')
|
||||
return { name: roh.slice(0, i), value: roh.slice(i + 1), url: app.url }
|
||||
},
|
||||
|
||||
/**
|
||||
* Entwurf wie nach der Branchenwahl, ohne alle Klicks: Funktionsdokument im Zustand off (mit siteKey), Einstellung (Vorgabe: salonEingabe,
|
||||
* gespeichert als Entwurf, also auch ohne Pflichtangaben fuer Gaeste) und Fragebogen (Vorgabe: Branche salon, nichts bestaetigt; fragebogen
|
||||
* ueberschreibt einzelne Felder, z. B. { bestaetigt: srv.SCHRITTE, bundeslandGewaehlt: true }).
|
||||
*/
|
||||
async entwurf({ eingabe = salonEingabe(), fragebogen = {} } = {}) {
|
||||
const erg = normalisiereEinstellung(eingabe, { now: uhr(), entwurf: true })
|
||||
if (erg.fehler) throw new Error(`Einstellung ungueltig: ${JSON.stringify(erg.fehler)}`)
|
||||
resetKontextForTests()
|
||||
aw.seed('portalFeatures', srv.featureDoc('off'))
|
||||
const doc = aw.docs('termineConfig').get(FEATURE)
|
||||
await speichereEinstellung(srv.featureDoc('off'), erg.einstellung, { version: doc ? doc.version : 0, now: uhr() })
|
||||
await speichereFragebogen(srv.featureDoc('off'), () => ({ ...leerFragebogen(), branche: 'salon', ...fragebogen }), { by: `kunde:${KUNDE}`, now: uhr() })
|
||||
},
|
||||
|
||||
/** Gespeicherter Fragebogen (Objekt) oder null; Version der gespeicherten Einstellung (0 ohne Dokument). */
|
||||
fragebogen() {
|
||||
const doc = aw.docs('termineConfig').get(FEATURE)
|
||||
return doc?.fragebogen ? JSON.parse(doc.fragebogen) : null
|
||||
},
|
||||
version() { return aw.docs('termineConfig').get(FEATURE)?.version ?? 0 },
|
||||
SCHRITTE,
|
||||
|
||||
/**
|
||||
* Bestellung im Zustand ordered, wie sie der Checkout hinterlaesst: eine offene Stripe-Sitzung im Nachbau, das Funktionsdokument ordered
|
||||
* (markOrdered) und, mit mitZustimmung, der Zustimmungsnachweis dieser Sitzung (Texte des Registers samt Belehrung, Fassungen, Zeit).
|
||||
* Setzt einen Entwurf voraus (srv.entwurf). Liefert die ID der Sitzung.
|
||||
*/
|
||||
async bestellt({ sitzungId = 'cs_test_ui_bestellt', mitZustimmung = true } = {}) {
|
||||
const register = featureConfig('termine')
|
||||
st.checkoutSessions.set(sitzungId, {
|
||||
id: sitzungId,
|
||||
object: 'checkout.session',
|
||||
url: `https://checkout.stripe.test/c/pay/${sitzungId}`,
|
||||
mode: 'payment',
|
||||
customer: null,
|
||||
client_reference_id: KUNDE,
|
||||
subscription: null,
|
||||
metadata: { customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine' },
|
||||
status: 'open',
|
||||
payment_status: 'unpaid',
|
||||
invoice: null,
|
||||
})
|
||||
const klickAt = uhr().toISOString()
|
||||
await markOrdered({ customerId: KUNDE, projectId: PROJEKT, key: 'termine', sessionId: sitzungId, consent: { text: hakenTexte(register), at: klickAt, ip: '127.0.0.1', userAgent: 'ui-test' } })
|
||||
if (mitZustimmung) {
|
||||
await speichereZustimmung({
|
||||
customerId: KUNDE,
|
||||
projectId: PROJEKT,
|
||||
featureKey: 'termine',
|
||||
stripeSessionId: sitzungId,
|
||||
fassungen: zustimmungsfassungen(register),
|
||||
texte: nachweisTexte(register, { belehrung: (await featureHaken('termine').belehrung?.()) ?? null }),
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'ui-test',
|
||||
konto: 'Silke Müller <silke@salon-mueller.example>',
|
||||
at: klickAt,
|
||||
})
|
||||
}
|
||||
resetKontextForTests()
|
||||
return sitzungId
|
||||
},
|
||||
|
||||
/**
|
||||
* Bestellung bezahlt, Einrichtungs-Ticket angelegt: das Funktionsdokument im Zustand setup mit Ticketnummer (Ticket im Nachbau, noch nicht
|
||||
* geschlossen, sonst schaltete GET /api/funktionen frei) und Rechnungslink. Setzt ein Funktionsdokument voraus (srv.entwurf, srv.bestellt).
|
||||
*/
|
||||
async einrichtung({ woid = 100342, invoiceUrl = 'https://invoice.stripe.test/i/in_ui1' } = {}) {
|
||||
aw.seed('workorders', { $id: 't_ui1', woid, status: 'In Progress' })
|
||||
await updateDocument('portalFeatures', FEATURE, { state: 'setup', enabled: false, purchaseStatus: 'paid', ticketId: 't_ui1', invoiceUrl })
|
||||
resetKontextForTests()
|
||||
},
|
||||
|
||||
/** Zustimmungsnachweise loeschen (ein ordered ohne Nachweis: der Zustand nach einer abgelaufenen Sitzung oder einem Fehler). */
|
||||
async loescheZustimmungen() {
|
||||
for (const id of [...aw.docs('featureConsents').keys()]) await deleteDocument('featureConsents', id)
|
||||
},
|
||||
|
||||
/**
|
||||
* Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke, Einstellung und Funktion neu anlegen.
|
||||
* ohneEinstellung: die Funktion ist aktiv, aber es gibt noch keine Einstellung (erstes Oeffnen im Portal: Vorlage waehlen).
|
||||
* entwurf: ein Kunde vor der ersten Branchenwahl (TP2): kein Funktionsdokument, keine Einstellung, ein laufendes Wartungs-Abo fuer c1 und ein
|
||||
* Konto mit Firma und Anschrift; die Stripe-Sitzungen und Aufrufe des Nachbaus sind leer.
|
||||
*/
|
||||
async zuruecksetzen({ eingabe, uhrNeu = true, ohneEinstellung = false } = {}) {
|
||||
async zuruecksetzen({ eingabe, uhrNeu = true, ohneEinstellung = false, entwurf = false } = {}) {
|
||||
await warteAufVersand()
|
||||
for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) {
|
||||
const sammlungen = ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects', 'subscriptions', 'featureConsents', 'workorders']
|
||||
for (const collection of sammlungen) {
|
||||
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
|
||||
}
|
||||
st.checkoutSessions.clear()
|
||||
st.calls.length = 0
|
||||
resetKontextForTests()
|
||||
resetOutboxForTests()
|
||||
resetTermineLimitsForTests()
|
||||
@@ -259,7 +382,16 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
|
||||
srv.sink = mailSink()
|
||||
setMailTransportForTests(srv.sink)
|
||||
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Salon Müller', subdomain: 'salon', liveDomain: 'salon-mueller.example' })
|
||||
aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@salon-mueller.example' })
|
||||
aw.seed('customers', {
|
||||
$id: KUNDE,
|
||||
name: 'Silke Müller',
|
||||
email: 'silke@salon-mueller.example',
|
||||
...(entwurf ? { companyName: 'Salon Müller', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler' } : {}),
|
||||
})
|
||||
if (entwurf) {
|
||||
await srv.abo('wartung')
|
||||
return
|
||||
}
|
||||
aw.seed('portalFeatures', srv.featureDoc())
|
||||
if (!ohneEinstellung) await srv.speichere(anpassen(eingabe ?? salonEingabe()))
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user