Files
tickte-system/tests/unit/funktionenView.test.mjs
ClaudeandClaude Sonnet 5.5 f11fd0bb25 Funktionen: News im Abo-Modell in Einrichtung ohne "Aktivieren (ohne Zahlung)" (Abschlussprüfung W2)
Das Portal antwortet darauf immer mit 409 bereits_eingeschaltet. Erkannt wird das Abo-Modell am Marker unlockedBy (abo, projekt) der Funktion; bei fehlendem Ticket steht "Ticket nachlegen" an der Stelle.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:17:35 +00:00

1021 lines
60 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// Kunden-Reiter "Funktionen": Register der Funktionen (News, Terminplaner), Aktionen je Zustand, Antwort einarbeiten
const {
FUNKTIONEN,
ZUSTAND,
AKTION_LABEL,
funktionOf,
aktionen,
aboModellFunktion,
mergeFunktion,
siteKeyOf,
abgeschaltetText,
hindernisText,
anleitungKopien,
anleitungPfad,
panelModell,
anleitungOffen,
textformNoetig,
termineTextformLabel,
termineChatTemplate,
shopTextformLabel,
unterkunftTextformLabel,
unterkunftChatTemplate,
shopChatTemplate,
TEXTFORM_FUNKTIONEN,
textformAnsicht,
textformBereit,
featureBody,
ABRECHNUNG_OPTIONEN,
ABRECHNUNG_HINWEIS,
fragtAbrechnung,
abrechnungBereit,
aboFehltText,
featureFehlerText,
speicherStatus,
K16_WARNUNG,
K16_WARNUNG_OHNE_UNTERLAGEN,
ABO_FEHLT_WARNUNG,
warenartLabel,
artikelzahlLabel,
anfrageStatus,
anfrageZeilen,
anfrageText,
stripeText,
stripeTon,
shopHatEintrag,
begruendungOk,
entscheidungBody,
entscheidungStatus,
} = await loadModule('src/lib/funktionenView.js')
test('FUNKTIONEN: News, Terminplaner, Webshop und Öffnungszeiten live (in dieser Reihenfolge) mit den Bestätigungstexten', () => {
assert.deepEqual(FUNKTIONEN, [
{
key: 'news',
label: 'News',
bestaetigen: {
activate: 'News ohne Zahlung aktivieren?',
disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
{
key: 'termine',
label: 'Terminplaner',
bestaetigen: {
activate: 'Terminplaner ohne Zahlung aktivieren?',
disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich. Damit beginnt die Löschung: Nach 30 Tagen werden alle Termine und Einstellungen gelöscht. Wieder einschalten ist nach diesen 30 Tagen nur mit einer neuen Einrichtung möglich.',
abnahme: 'Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
{
key: 'shop',
label: 'Webshop',
bestaetigen: {
activate: 'Webshop ohne Zahlung aktivieren?',
disable: 'Webshop abschalten? Auf der Website sind dann keine Bestellungen mehr möglich.',
abnahme: 'Webshop freischalten? Der Kunde bekommt die Mail „bereit“ und der Shop kann geöffnet werden.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
{
key: 'zeiten',
label: 'Öffnungszeiten live',
bestaetigen: {
activate: 'Öffnungszeiten live ohne Zahlung aktivieren? Es startet der Einbau: Das Einbau-Ticket wird angelegt und die Seite, wenn möglich, neu gebaut. Freigeschaltet wird, sobald das Ticket geschlossen ist und der Kunde seine Zeiten eingetragen hat.',
disable: 'Öffnungszeiten live abschalten? Leisten und Live-Anzeige verschwinden von der Website, die feste Ersatz-Tabelle bleibt stehen. Die Angaben des Kunden bleiben gespeichert. Weicht der Wochenplan von der Ersatz-Tabelle ab, wird ein Ticket „Ersatz-Tabelle aktualisieren“ angelegt.',
abnahme: 'Öffnungszeiten live freischalten? Der Kunde bekommt die Mail „bereit“ und der Einbau gilt als abgenommen.',
ticket: 'Fehlendes Einbau-Ticket neu anlegen?',
},
},
{
key: 'unterkunft',
label: 'Buchungskalender',
bestaetigen: {
activate: 'Buchungskalender ohne Zahlung aktivieren?',
disable: 'Buchungskalender abschalten? Auf der Website sind dann keine Buchungsanfragen mehr möglich.',
abnahme: 'Buchungskalender freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können anfragen.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
])
})
test('FUNKTIONEN: Schlüssel sind eindeutig und jede Funktion hat Aktivieren und Abschalten bestätigt', () => {
assert.deepEqual(FUNKTIONEN.map((f) => f.key), ['news', 'termine', 'shop', 'zeiten', 'unterkunft'])
for (const f of FUNKTIONEN) {
assert.ok(f.bestaetigen.activate && f.bestaetigen.disable, f.key)
}
const zeiten = FUNKTIONEN.find((f) => f.key === 'zeiten').bestaetigen
for (const text of Object.values(zeiten)) assert.doesNotMatch(text, /24 Uhr/, 'nie "24 Uhr"')
})
test('W1: der Abschalten-Dialog des Terminplaners nennt Buchungsstopp, Löschung nach 30 Tagen und die neue Einrichtung, aber keinen Preis', () => {
const text = FUNKTIONEN.find((f) => f.key === 'termine').bestaetigen.disable
assert.match(text, /^Terminplaner abschalten\?/)
assert.match(text, /keine Buchungen mehr möglich/, 'Buchungen stehen')
assert.match(text, /Nach 30 Tagen werden alle Termine und Einstellungen gelöscht/, 'Löschung nach 30 Tagen')
assert.match(text, /nur mit einer neuen Einrichtung möglich/, 'danach nur mit neuer Einrichtung')
assert.doesNotMatch(text, /€|Euro|Bestellung/, 'kein Preis')
// News bleibt unverändert: dort wird nichts gelöscht
assert.equal(FUNKTIONEN.find((f) => f.key === 'news').bestaetigen.disable, 'News abschalten? Die Website zeigt dann keine Beiträge mehr.')
})
test('ZUSTAND: Ton und Beschriftung je Zustand', () => {
assert.deepEqual(ZUSTAND, {
off: ['muted', 'Aus'],
ordered: ['warn', 'Bestellt'],
setup: ['info', 'Einrichtung'],
active: ['ok', 'Aktiv'],
disabled: ['muted', 'Abgeschaltet'],
})
})
test('funktionOf: Ansicht der Funktion, ohne Eintrag "off"', () => {
const project = { projectId: 'p1', features: { news: { state: 'active', ticketWoid: '100' }, termine: { state: 'setup' } } }
assert.deepEqual(funktionOf(project, 'news'), { state: 'active', ticketWoid: '100' })
assert.deepEqual(funktionOf(project, 'termine'), { state: 'setup' })
assert.deepEqual(funktionOf({ projectId: 'p2', features: { news: { state: 'active' } } }, 'termine'), { state: 'off' })
assert.deepEqual(funktionOf({ projectId: 'p3' }, 'news'), { state: 'off' })
assert.deepEqual(funktionOf(null, 'news'), { state: 'off' })
})
test('aktionen: News unverändert – Aktivieren außer bei "active", Abschalten bei Bestellt, Einrichtung und Aktiv', () => {
assert.deepEqual(aktionen('off', 'news'), ['activate'])
assert.deepEqual(aktionen('ordered', 'news'), ['activate', 'disable'])
assert.deepEqual(aktionen('setup', 'news'), ['activate', 'disable'])
assert.deepEqual(aktionen('active', 'news'), ['disable'])
assert.deepEqual(aktionen('disabled', 'news'), ['activate'])
assert.deepEqual(aktionen(undefined, 'news'), ['activate'])
})
test('aktionen: ohne Schlüssel wie News (auch mit undefiniertem Zustand)', () => {
assert.deepEqual(aktionen('off'), ['activate'])
assert.deepEqual(aktionen('ordered'), ['activate', 'disable'])
assert.deepEqual(aktionen('setup'), ['activate', 'disable'])
assert.deepEqual(aktionen('active'), ['disable'])
assert.deepEqual(aktionen('disabled'), ['activate'])
assert.deepEqual(aktionen(undefined), ['activate'])
assert.deepEqual(aktionen('setup', undefined, { ticketMissing: true }), ['activate', 'disable'], 'ticketMissing betrifft nur den Terminplaner')
})
test('aktionen: News ignoriert ticketMissing in jedem Zustand', () => {
for (const state of ['off', 'ordered', 'setup', 'active', 'disabled', undefined]) {
assert.deepEqual(aktionen(state, 'news', { ticketMissing: true }), aktionen(state, 'news'), String(state))
}
})
test('aboModellFunktion: nur Funktionen, die das Abo-Modell eingeschaltet hat (unlockedBy "abo" oder "projekt")', () => {
assert.equal(aboModellFunktion({ state: 'setup', unlockedBy: 'abo' }), true)
assert.equal(aboModellFunktion({ state: 'setup', unlockedBy: 'projekt' }), true)
for (const unlockedBy of ['kauf', 'team', null, undefined, '', 'Abo', 'ABO', ' abo', 'projekt ', 5, true, ['abo']]) {
assert.equal(aboModellFunktion({ state: 'setup', unlockedBy }), false, String(unlockedBy))
}
for (const feature of [undefined, null, {}, 'abo', 5]) assert.equal(aboModellFunktion(feature), false, String(feature))
})
test('aktionen W2: News im Abo-Modell in "Einrichtung" bietet kein Aktivieren (der Server antwortet 409 bereits_eingeschaltet), nur Abschalten', () => {
assert.deepEqual(aktionen('setup', 'news', { aboModell: true }), ['disable'])
assert.deepEqual(aktionen('setup', undefined, { aboModell: true }), ['disable'], 'ohne Schlüssel wie News')
assert.deepEqual(aktionen('setup', 'news', { aboModell: true, hindernis: 'kein_abo' }), ['disable'])
})
test('aktionen W2: fehlt das Einrichtungs-Ticket, steht an der Stelle von Aktivieren "Ticket nachlegen" (dafür gibt es die Route, Aktivieren nicht)', () => {
assert.deepEqual(aktionen('setup', 'news', { aboModell: true, ticketMissing: true }), ['ticket', 'disable'])
assert.deepEqual(aktionen('setup', 'news', { aboModell: true, ticketMissing: false }), ['disable'])
for (const action of aktionen('setup', 'news', { aboModell: true, ticketMissing: true })) {
assert.ok(FUNKTIONEN.find((f) => f.key === 'news').bestaetigen[action], `Text für ${action}`)
}
assert.equal(FUNKTIONEN.find((f) => f.key === 'news').bestaetigen.ticket, 'Fehlendes Einrichtungs-Ticket neu anlegen?')
})
test('aktionen W2: außerhalb von "Einrichtung" und ohne Abo-Modell bleibt alles wie bisher', () => {
// andere Zustände: das Abo-Modell ändert nichts
for (const state of ['off', 'ordered', 'active', 'disabled', undefined]) {
for (const ticketMissing of [false, true]) {
assert.deepEqual(aktionen(state, 'news', { aboModell: true, ticketMissing }), aktionen(state, 'news'), String(state))
}
}
// ohne Abo-Modell (Kauf, Team, altes Portal ohne unlockedBy): Aktivieren in "Einrichtung" bleibt, ticketMissing wird ignoriert
assert.deepEqual(aktionen('setup', 'news', { aboModell: false }), ['activate', 'disable'])
assert.deepEqual(aktionen('setup', 'news', { aboModell: false, ticketMissing: true }), ['activate', 'disable'])
assert.deepEqual(aktionen('setup', 'news', {}), ['activate', 'disable'])
assert.deepEqual(aktionen('setup', 'news', { aboModell: 'abo' }), ['activate', 'disable'], 'nur ein echtes true zählt')
// ticketbasierte Funktionen sind vom Abo-Modell unberührt (dort gibt es in "Einrichtung" nie Aktivieren)
for (const key of ['termine', 'shop', 'zeiten', 'unterkunft']) {
for (const state of ['off', 'ordered', 'setup', 'active', 'disabled', undefined]) {
for (const ticketMissing of [false, true]) {
assert.deepEqual(aktionen(state, key, { aboModell: true, ticketMissing }), aktionen(state, key, { ticketMissing }), `${key} ${String(state)}`)
}
}
}
})
test('aktionen: jede angebotene Aktion hat Beschriftung und Bestätigungstext (alle Funktionen, Zustände und Optionen, auch im Abo-Modell)', () => {
for (const f of FUNKTIONEN) {
for (const state of ['off', 'ordered', 'setup', 'active', 'disabled', undefined]) {
for (const aboModell of [false, true]) {
for (const ticketMissing of [false, true]) {
for (const hindernis of [null, 'zeiten_fehlen']) {
for (const action of aktionen(state, f.key, { aboModell, ticketMissing, hindernis })) {
assert.ok(AKTION_LABEL[action], `${f.key} ${String(state)}: Beschriftung ${action}`)
assert.ok(f.bestaetigen[action], `${f.key} ${String(state)}: Text ${action}`)
}
}
}
}
}
}
})
test('aktionen: Terminplaner – bestellt nur Abschalten (kein Aktivieren ohne Zahlung), Einrichtung mit Abnahme', () => {
assert.deepEqual(aktionen('off', 'termine'), ['activate'])
assert.deepEqual(aktionen('ordered', 'termine'), ['disable'], 'Aktivieren würde eine spätere Zahlung im Portal verschlucken')
assert.deepEqual(aktionen('setup', 'termine'), ['abnahme', 'disable'])
assert.deepEqual(aktionen('active', 'termine'), ['disable'])
assert.deepEqual(aktionen('disabled', 'termine'), ['activate'])
assert.deepEqual(aktionen(undefined, 'termine'), ['activate'])
})
test('aktionen: Terminplaner mit fehlendem Ticket bietet in der Einrichtung zuerst "ticket" an', () => {
assert.deepEqual(aktionen('setup', 'termine', { ticketMissing: true }), ['ticket', 'abnahme', 'disable'])
assert.deepEqual(aktionen('setup', 'termine', { ticketMissing: false }), ['abnahme', 'disable'])
assert.deepEqual(aktionen('setup', 'termine', {}), ['abnahme', 'disable'])
for (const state of ['off', 'ordered', 'active', 'disabled', undefined]) {
assert.deepEqual(aktionen(state, 'termine', { ticketMissing: true }), aktionen(state, 'termine'), `${String(state)}: ticketMissing nur in setup`)
}
})
test('aktionen: Öffnungszeiten live – aus nur Aktivieren, bestellt nur Abschalten, Einrichtung mit Abnahme, aktiv nur Abschalten, abgeschaltet nur Aktivieren', () => {
assert.deepEqual(aktionen('off', 'zeiten'), ['activate'])
assert.deepEqual(aktionen('ordered', 'zeiten'), ['disable'], 'wie beim Terminplaner: Aktivieren würde eine spätere Zahlung verschlucken')
assert.deepEqual(aktionen('setup', 'zeiten'), ['abnahme', 'disable'])
assert.deepEqual(aktionen('active', 'zeiten'), ['disable'])
assert.deepEqual(aktionen('disabled', 'zeiten'), ['activate'])
assert.deepEqual(aktionen(undefined, 'zeiten'), ['activate'])
})
test('aktionen: Öffnungszeiten live bietet die Abnahme nur ohne Hindernis an (wartet auf die Zeiten des Kunden: nur Abschalten)', () => {
assert.deepEqual(aktionen('setup', 'zeiten', { hindernis: 'zeiten_fehlen' }), ['disable'])
assert.deepEqual(aktionen('setup', 'zeiten', { hindernis: null }), ['abnahme', 'disable'])
assert.deepEqual(aktionen('setup', 'zeiten', { hindernis: undefined }), ['abnahme', 'disable'])
assert.deepEqual(aktionen('setup', 'zeiten', { ticketMissing: true, hindernis: 'zeiten_fehlen' }), ['ticket', 'disable'])
for (const state of ['off', 'ordered', 'active', 'disabled', undefined]) {
assert.deepEqual(aktionen(state, 'zeiten', { hindernis: 'zeiten_fehlen' }), aktionen(state, 'zeiten'), `${String(state)}: hindernis nur in setup`)
}
})
test('aktionen: Öffnungszeiten live mit fehlendem Ticket bietet in der Einrichtung zuerst "ticket" an (wie der Terminplaner)', () => {
assert.deepEqual(aktionen('setup', 'zeiten', { ticketMissing: true }), ['ticket', 'abnahme', 'disable'])
assert.deepEqual(aktionen('setup', 'zeiten', { ticketMissing: false }), ['abnahme', 'disable'])
for (const state of ['off', 'ordered', 'active', 'disabled', undefined]) {
assert.deepEqual(aktionen(state, 'zeiten', { ticketMissing: true }), aktionen(state, 'zeiten'), `${String(state)}: ticketMissing nur in setup`)
}
})
test('aktionen: Terminplaner und News ignorieren hindernis (der Terminplaner zeigt die Abnahme weiter, das Portal sperrt)', () => {
for (const hindernis of ['kein_abo', 'keine_einstellung', 'abo_unbekannt', 'zeiten_fehlen', null]) {
assert.deepEqual(aktionen('setup', 'termine', { hindernis }), ['abnahme', 'disable'], String(hindernis))
assert.deepEqual(aktionen('setup', 'news', { hindernis }), ['activate', 'disable'], String(hindernis))
}
})
test('AKTION_LABEL: Beschriftung je Aktion', () => {
assert.deepEqual(AKTION_LABEL, {
activate: 'Aktivieren (ohne Zahlung)',
disable: 'Abschalten',
abnahme: 'Freischalten (Abnahme)',
ticket: 'Ticket nachlegen',
})
})
test('hindernisText: Text je Code, sonst leer', () => {
assert.equal(hindernisText('kein_abo'), 'Kein laufendes Wartungs-Abo: Freischalten ist gesperrt.')
assert.equal(hindernisText('keine_einstellung'), 'Noch keine Einstellungen gespeichert.')
assert.equal(hindernisText('abo_unbekannt'), 'Abo gerade nicht prüfbar.')
for (const code of ['x', '', null, undefined, 5, 'constructor', 'toString']) assert.equal(hindernisText(code), '', String(code))
})
// Antwort von GET /funktionen/:customerId/termine/anleitung (Portal, D8) in Kurzform
const antwort = () => ({
stand: { version: 3, updatedAt: '2026-10-03T10:15:00.000Z' },
warnungen: ['Datenschutz-Entwurf', 'Ticket ist älter als der Stand'],
abschnitte: [
{ id: 'modell', titel: 'Modell', text: 'Modell-Text', kopierbar: false },
{ id: 'snippet', titel: 'Einbau-Code (Snippet)', text: '<script></script>', kopierbar: true },
{ id: 'css', titel: 'Aussehen (CSS)', text: ':root {}', kopierbar: true },
{ id: 'checkliste', titel: 'Abnahme-Checkliste', text: '- [ ] Punkt', kopierbar: false },
{ id: 'datenschutz_sie', titel: 'Datenschutz (Sie)', text: 'Absatz Sie', kopierbar: true },
{ id: 'datenschutz_du', titel: 'Datenschutz (Du)', text: 'Absatz Du', kopierbar: true },
],
text: 'GANZE ANLEITUNG',
state: 'setup',
ticketWoid: '100123',
ticketVersion: 3,
hindernis: null,
})
test('anleitungKopien: zuerst "alles", dann die kopierbaren Abschnitte in Antwortreihenfolge mit festen Beschriftungen', () => {
assert.deepEqual(anleitungKopien(antwort()), [
{ id: 'alles', label: 'Alles kopieren', text: 'GANZE ANLEITUNG' },
{ id: 'snippet', label: 'Einbau-Code kopieren', text: '<script></script>' },
{ id: 'css', label: 'CSS-Variablen kopieren', text: ':root {}' },
{ id: 'datenschutz_sie', label: 'Datenschutz-Absatz (Sie) kopieren', text: 'Absatz Sie' },
{ id: 'datenschutz_du', label: 'Datenschutz-Absatz (Du) kopieren', text: 'Absatz Du' },
])
})
test('anleitungKopien: nicht kopierbare Abschnitte fehlen, unbekannte kopierbare heißen "<Titel> kopieren"', () => {
const a = antwort()
a.abschnitte = [
{ id: 'modell', titel: 'Modell', text: 'x', kopierbar: false },
{ id: 'neu', titel: 'Neuer Teil', text: 'Inhalt', kopierbar: true },
{ id: 'constructor', titel: 'Eigener Titel', text: 'y', kopierbar: true },
]
assert.deepEqual(anleitungKopien(a), [
{ id: 'alles', label: 'Alles kopieren', text: 'GANZE ANLEITUNG' },
{ id: 'neu', label: 'Neuer Teil kopieren', text: 'Inhalt' },
{ id: 'constructor', label: 'Eigener Titel kopieren', text: 'y' },
])
})
test('anleitungKopien: kopierbarer Abschnitt ohne Text entfällt, null und Antwort ohne Text ergeben []', () => {
const a = antwort()
a.abschnitte = [{ id: 'snippet', titel: 'Einbau-Code (Snippet)', text: '', kopierbar: true }]
assert.deepEqual(anleitungKopien(a), [{ id: 'alles', label: 'Alles kopieren', text: 'GANZE ANLEITUNG' }])
assert.deepEqual(anleitungKopien(null), [])
assert.deepEqual(anleitungKopien(undefined), [])
assert.deepEqual(anleitungKopien({ ...antwort(), text: '' }), [])
assert.deepEqual(anleitungKopien({ ...antwort(), text: undefined }), [])
assert.deepEqual(anleitungKopien({ abschnitte: antwort().abschnitte }), [])
})
test('anleitungPfad: Kunde und Projekt kodiert', () => {
assert.equal(anleitungPfad('c1', 'p1'), '/funktionen/c1/termine/anleitung?projectId=p1')
assert.equal(anleitungPfad('c/1', 'p&x=1 ü'), '/funktionen/c%2F1/termine/anleitung?projectId=p%26x%3D1%20%C3%BC')
})
test('panelModell: Beispielantwort liefert alle sechs Felder', () => {
const modell = panelModell(antwort(), { state: 'setup', hindernis: 'kein_abo' })
assert.deepEqual(Object.keys(modell).sort(), ['abschnitte', 'hinweis', 'kopf', 'kopien', 'ticketPfad', 'warnungen'])
assert.equal(modell.kopf, 'Stand Version 3, 03.10.2026')
assert.equal(modell.ticketPfad, '/tickets?woid=100123')
assert.deepEqual(modell.warnungen, ['Datenschutz-Entwurf', 'Ticket ist älter als der Stand'])
assert.equal(modell.hinweis, 'Kein laufendes Wartungs-Abo: Freischalten ist gesperrt.')
assert.deepEqual(modell.abschnitte, [
{ id: 'modell', titel: 'Modell', text: 'Modell-Text' },
{ id: 'snippet', titel: 'Einbau-Code (Snippet)', text: '<script></script>' },
{ id: 'css', titel: 'Aussehen (CSS)', text: ':root {}' },
{ id: 'checkliste', titel: 'Abnahme-Checkliste', text: '- [ ] Punkt' },
{ id: 'datenschutz_sie', titel: 'Datenschutz (Sie)', text: 'Absatz Sie' },
{ id: 'datenschutz_du', titel: 'Datenschutz (Du)', text: 'Absatz Du' },
])
assert.deepEqual(modell.kopien, anleitungKopien(antwort()))
})
test('panelModell: Kopf nach Berliner Kalendertag, ohne Datum nur die Version', () => {
const kopf = (stand) => panelModell({ ...antwort(), stand }, {}).kopf
assert.equal(kopf({ version: 12, updatedAt: '2026-10-03T23:30:00.000Z' }), 'Stand Version 12, 04.10.2026', 'nach Mitternacht in Berlin schon der nächste Tag')
assert.equal(kopf({ version: 2, updatedAt: null }), 'Stand Version 2')
assert.equal(kopf({ version: 2 }), 'Stand Version 2')
assert.equal(kopf({ version: 2, updatedAt: 'kein Datum' }), 'Stand Version 2')
assert.equal(panelModell({ ...antwort(), stand: undefined }, {}).kopf, '')
assert.equal(panelModell({ ...antwort(), stand: {} }, {}).kopf, '', 'ohne Version kein Kopf')
})
test('panelModell: null-Antwort ergibt ein leeres Modell', () => {
const leer = { kopf: '', ticketPfad: null, warnungen: [], hinweis: '', kopien: [], abschnitte: [] }
assert.deepEqual(panelModell(null), leer)
assert.deepEqual(panelModell(undefined, undefined), leer)
assert.deepEqual(panelModell(null, { hindernis: 'kein_abo' }), leer)
})
test('panelModell: ohne ticketWoid kein Ticketpfad, ohne Hindernis kein Hinweis, Woid wird kodiert', () => {
const ohne = panelModell({ ...antwort(), ticketWoid: null }, { state: 'setup' })
assert.equal(ohne.ticketPfad, null)
assert.equal(ohne.hinweis, '')
assert.equal(panelModell({ ...antwort(), ticketWoid: undefined }, null).ticketPfad, null)
assert.equal(panelModell({ ...antwort(), ticketWoid: '' }, null).ticketPfad, null)
assert.equal(panelModell({ ...antwort(), ticketWoid: 'a b/1' }, null).ticketPfad, '/tickets?woid=a%20b%2F1')
assert.equal(panelModell(antwort(), { hindernis: 'unbekannt' }).hinweis, '')
})
test('panelModell: fehlende Listen werden zu leeren Listen', () => {
const modell = panelModell({ stand: { version: 1, updatedAt: null }, state: 'active' }, {})
assert.deepEqual(modell.warnungen, [])
assert.deepEqual(modell.abschnitte, [])
assert.deepEqual(modell.kopien, [])
})
test('anleitungOffen: nur die Adresse mit ?anleitung=<projectId> öffnet das Panel', () => {
assert.equal(anleitungOffen('?tab=funktionen&anleitung=p%2F1', 'p/1'), true, 'kodiert')
assert.equal(anleitungOffen('?tab=funktionen&anleitung=p/1', 'p/1'), true, 'unkodiert')
assert.equal(anleitungOffen('?anleitung=p1', 'p1'), true)
assert.equal(anleitungOffen('anleitung=p1', 'p1'), true, 'ohne Fragezeichen')
assert.equal(anleitungOffen('?tab=funktionen&anleitung=p2', 'p1'), false, 'anderes Projekt')
assert.equal(anleitungOffen('?anleitung=p10', 'p1'), false, 'kein Präfix-Treffer')
assert.equal(anleitungOffen('?tab=funktionen', 'p1'), false)
assert.equal(anleitungOffen('?anleitung=', 'p1'), false)
assert.equal(anleitungOffen('', 'p1'), false)
assert.equal(anleitungOffen(undefined, 'p1'), false)
assert.equal(anleitungOffen(null, 'p1'), false)
assert.equal(anleitungOffen('?anleitung=p1', ''), false)
assert.equal(anleitungOffen('?anleitung=', ''), false, 'leeres Projekt öffnet nichts')
assert.equal(anleitungOffen('?anleitung=p1', undefined), false)
})
const projects = () => [
{ projectId: 'p1', projectName: 'Eins', features: { news: { state: 'off' }, termine: { state: 'active', siteKey: 'abc' } } },
{ projectId: 'p2', projectName: 'Zwei', features: { news: { state: 'active' }, termine: { state: 'off' } } },
]
test('mergeFunktion: übernimmt nur die gewählte Funktion, die andere bleibt', () => {
const antwort = {
projectId: 'p1',
projectName: 'Eins (neu)',
features: { news: { state: 'setup', ticketWoid: '7' }, termine: { state: 'off' } },
}
const merged = mergeFunktion(projects(), 'p1', 'news', antwort)
assert.deepEqual(merged[0].features.news, { state: 'setup', ticketWoid: '7' })
assert.deepEqual(merged[0].features.termine, { state: 'active', siteKey: 'abc' })
assert.equal(merged[0].projectName, 'Eins')
assert.deepEqual(merged[1], projects()[1])
})
test('mergeFunktion: Terminplaner überschreibt News nicht', () => {
const antwort = { projectId: 'p2', features: { news: { state: 'off' }, termine: { state: 'ordered' } } }
const merged = mergeFunktion(projects(), 'p2', 'termine', antwort)
assert.deepEqual(merged[1].features, { news: { state: 'active' }, termine: { state: 'ordered' } })
})
test('mergeFunktion: verändert die Eingabe nicht, ohne Antwort oder ohne Funktion bleibt alles', () => {
const list = projects()
const merged = mergeFunktion(list, 'p1', 'news', { projectId: 'p1', features: { news: { state: 'active' } } })
assert.notEqual(merged, list)
assert.deepEqual(list, projects())
assert.deepEqual(mergeFunktion(list, 'p1', 'news', undefined), projects())
assert.deepEqual(mergeFunktion(list, 'p1', 'news', { projectId: 'p1', features: {} }), projects())
assert.deepEqual(mergeFunktion(list, 'unbekannt', 'news', { features: { news: { state: 'active' } } }), projects())
})
test('mergeFunktion: Projekt ohne bisherige features bekommt die Funktion', () => {
const merged = mergeFunktion([{ projectId: 'p9' }], 'p9', 'termine', { features: { termine: { state: 'setup' } } })
assert.deepEqual(merged, [{ projectId: 'p9', features: { termine: { state: 'setup' } } }])
})
test('siteKeyOf: nur der Terminplaner zeigt seinen Site-Key, nur wenn das Portal einen nennt', () => {
assert.equal(siteKeyOf({ state: 'active', siteKey: 'abcdefghijklmnop' }, 'termine'), 'abcdefghijklmnop')
assert.equal(siteKeyOf({ state: 'active', siteKey: ' abcdefghijklmnop ' }, 'termine'), 'abcdefghijklmnop', 'getrimmt')
assert.equal(siteKeyOf({ state: 'active', siteKey: 'abcdefghijklmnop' }, 'news'), '', 'News: kein Site-Key in der Tabelle')
for (const feature of [{ state: 'off' }, { state: 'active', siteKey: null }, { state: 'active', siteKey: '' }, { state: 'active', siteKey: 5 }, null, undefined]) {
assert.equal(siteKeyOf(feature, 'termine'), '', JSON.stringify(feature))
}
})
test('siteKeyOf liefert für zeiten den Schlüssel, für news weiter ""', () => {
assert.equal(siteKeyOf({ state: 'setup', siteKey: 'abcdefghijklmnop' }, 'zeiten'), 'abcdefghijklmnop')
assert.equal(siteKeyOf({ state: 'setup', siteKey: ' abcdefghijklmnop ' }, 'zeiten'), 'abcdefghijklmnop', 'getrimmt')
assert.equal(siteKeyOf({ state: 'active', siteKey: 'abcdefghijklmnop' }, 'news'), '')
assert.equal(siteKeyOf({ state: 'active', siteKey: 'abcdefghijklmnop' }, undefined), '')
assert.equal(siteKeyOf({ state: 'active', siteKey: 'abcdefghijklmnop' }, 'unbekannt'), '')
for (const feature of [{ state: 'off' }, { state: 'setup', siteKey: null }, { state: 'setup', siteKey: '' }, { state: 'setup', siteKey: 5 }, null, undefined]) {
assert.equal(siteKeyOf(feature, 'zeiten'), '', JSON.stringify(feature))
}
})
test('abgeschaltetText: Grund und Löschdatum im Zustand "disabled"', () => {
const text = (disabledReason, loeschungAm) => abgeschaltetText({ state: 'disabled', disabledReason, loeschungAm })
assert.equal(text('abo_ende', '2026-11-04'), 'Abgeschaltet (Abo beendet), Löschung am 04.11.2026')
assert.equal(text('abo_ende', undefined), 'Abgeschaltet (Abo beendet)')
assert.equal(text('abo_ende_vor_einrichtung', '2026-11-04'), 'Abgeschaltet (Abo endete vor Einrichtung, Erstattung prüfen), Löschung am 04.11.2026')
assert.equal(text('abo_ende_vor_einrichtung', null), 'Abgeschaltet (Abo endete vor Einrichtung, Erstattung prüfen)')
assert.equal(text('team', '2026-12-01'), 'Abgeschaltet (Team), Löschung am 01.12.2026')
assert.equal(text('team', null), 'Abgeschaltet (Team)')
assert.equal(text('kunde_geloescht', '2026-12-31'), 'Abgeschaltet (Kunde gelöscht), Löschung am 31.12.2026')
assert.equal(text('kunde_geloescht', ''), 'Abgeschaltet (Kunde gelöscht)')
})
test('abgeschaltetText: ohne Grund nur "Abgeschaltet", mit Datum "Abgeschaltet, Löschung am …"', () => {
assert.equal(abgeschaltetText({ state: 'disabled' }), 'Abgeschaltet')
assert.equal(abgeschaltetText({ state: 'disabled', disabledReason: null, loeschungAm: null }), 'Abgeschaltet')
assert.equal(abgeschaltetText({ state: 'disabled', disabledReason: '', loeschungAm: '' }), 'Abgeschaltet')
assert.equal(abgeschaltetText({ state: 'disabled', disabledReason: null, loeschungAm: '2026-11-04' }), 'Abgeschaltet, Löschung am 04.11.2026')
assert.equal(abgeschaltetText({ state: 'disabled', loeschungAm: '2026-01-09' }), 'Abgeschaltet, Löschung am 09.01.2026')
})
test('abgeschaltetText: unbekannter Grund wird wie "ohne Grund" behandelt', () => {
assert.equal(abgeschaltetText({ state: 'disabled', disabledReason: 'frist' }), 'Abgeschaltet')
assert.equal(abgeschaltetText({ state: 'disabled', disabledReason: 'frist', loeschungAm: '2026-11-04' }), 'Abgeschaltet, Löschung am 04.11.2026')
assert.equal(abgeschaltetText({ state: 'disabled', disabledReason: 'constructor' }), 'Abgeschaltet', 'kein Treffer über die Prototypenkette')
assert.equal(abgeschaltetText({ state: 'disabled', disabledReason: 5 }), 'Abgeschaltet')
})
test('abgeschaltetText: außerhalb von "disabled" leer, auch mit Grund und Datum', () => {
for (const state of ['active', 'setup', 'ordered', 'off', undefined, null]) {
assert.equal(abgeschaltetText({ state, disabledReason: 'abo_ende', loeschungAm: '2026-11-04' }), '', String(state))
}
for (const feature of [{}, null, undefined]) assert.equal(abgeschaltetText(feature), '', JSON.stringify(feature))
})
// --- Staff-Aktivierung des Terminplaners nur mit Zustimmung in Textform (F13b; Portal F13a: GET ... => termine) ---
// Feld "termine" der Portal-Antwort GET /funktionen/:customerId
const termine = (extra = {}) => ({
textformNoetig: true,
avvVersion: '2026-10-05',
links: {
lb: 'https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html',
avv: 'https://project.webklar.com/rechtliches/avv-terminplaner-2026-10-05.html',
},
...extra,
})
test('textformNoetig: nur "Aktivieren (ohne Zahlung)" des Terminplaners und nur, wenn das Portal es verlangt', () => {
assert.equal(textformNoetig('termine', 'activate', termine()), true)
assert.equal(textformNoetig('termine', 'activate', termine({ textformNoetig: false })), false)
assert.equal(textformNoetig('termine', 'activate', undefined), false, 'altes Portal ohne Feld: wie bisher')
assert.equal(textformNoetig('termine', 'activate', null), false)
assert.equal(textformNoetig('termine', 'activate', termine({ textformNoetig: 'true' })), false, 'nur echtes true')
for (const action of ['disable', 'abnahme', 'ticket']) assert.equal(textformNoetig('termine', action, termine()), false, action)
for (const action of ['activate', 'disable']) assert.equal(textformNoetig('news', action, termine()), false, `News ${action}`)
assert.equal(textformNoetig(undefined, 'activate', termine()), false)
})
// Feld "shop" der Portal-Antwort GET /funktionen/:customerId (Fassung = config.shop.fassung)
const shopFeld = (extra = {}) => ({
textformNoetig: true,
avvVersion: '2026-10-05',
links: {
lb: 'https://project.webklar.com/rechtliches/leistungsbeschreibung-webshop-2026-10-05.html',
avv: 'https://project.webklar.com/rechtliches/avv-webshop-2026-10-05.html',
},
...extra,
})
test('textformNoetig: auch das Aktivieren (ohne Zahlung) des Webshops, mit dem Feld shop; die Funktion kommt aus dem Schlüssel', () => {
assert.deepEqual(TEXTFORM_FUNKTIONEN, ['termine', 'shop', 'unterkunft'])
assert.equal(textformNoetig('shop', 'activate', shopFeld()), true)
assert.equal(textformNoetig('shop', 'activate', shopFeld({ textformNoetig: false })), false)
assert.equal(textformNoetig('shop', 'activate', undefined), false, 'altes Portal ohne Feld shop: wie bisher')
assert.equal(textformNoetig('shop', 'activate', null), false)
assert.equal(textformNoetig('shop', 'activate', shopFeld({ textformNoetig: 'true' })), false, 'nur echtes true')
for (const action of ['disable', 'abnahme', 'ticket']) assert.equal(textformNoetig('shop', action, shopFeld()), false, action)
assert.equal(textformNoetig('news', 'activate', shopFeld()), false)
})
test('shopTextformLabel und shopChatTemplate: wörtlich, mit Fassung, den Erklärungen des Nachweises und den versionierten Adressen', () => {
const l = shopTextformLabel('2026-10-05')
assert.equal(l.lb, 'Leistungsbeschreibung Webshop')
assert.equal(l.avv, 'Auftragsverarbeitungsvertrag Webshop')
assert.equal(
l.before + l.lb + l.between + l.avv + l.after,
'Der Kunde hat der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop (Fassung 2026-10-05) ' +
'in Textform zugestimmt und bestätigt, dass er als Unternehmer in eigenem Namen verkauft, für seine Produkte, Preise, Produktangaben und Rechtstexte selbst verantwortlich ist, ' +
'keine der in der Leistungsbeschreibung ausgeschlossenen Waren verkauft und die Zahlungen seiner Käufer über sein eigenes Stripe-Konto laufen.',
)
const { lb, avv } = shopFeld().links
const text = shopChatTemplate('2026-10-05', { lb, avv })
assert.ok(text.startsWith('Damit wir den Webshop für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. '))
assert.ok(text.includes(`in der Fassung 2026-10-05: ${lb} · ${avv}. `))
assert.match(text, /leistungsbeschreibung-webshop-2026-10-05\.html/)
assert.match(text, /avv-webshop-2026-10-05\.html/)
assert.match(text, /Ich stimme zu/)
assert.match(text, /eigenes Stripe-Konto/)
assert.ok(!text.includes('undefined') && !text.includes('{') && !text.includes('Terminplaner'))
})
test('textformAnsicht(feld, "shop"): Label, Vorlage und Links des Webshops; ohne Schlüssel bleibt es der Terminplaner', () => {
const a = textformAnsicht(shopFeld(), 'shop')
assert.equal(a.version, '2026-10-05')
assert.deepEqual(a.links, shopFeld().links)
assert.deepEqual(a.label, shopTextformLabel('2026-10-05'))
assert.equal(a.vorlage, shopChatTemplate('2026-10-05', shopFeld().links))
assert.deepEqual(textformAnsicht(termine()).label, termineTextformLabel('2026-10-05'))
// Adressen ohne https werden nie Link, ohne beide Adressen keine Vorlage
const schlecht = textformAnsicht(shopFeld({ links: { lb: 'javascript:alert(1)', avv: 'http://x.example/a.html' } }), 'shop')
assert.deepEqual(schlecht.links, { lb: '', avv: '' })
assert.equal(schlecht.vorlage, '')
assert.equal(textformAnsicht(undefined, 'shop').version, '')
})
test('textformBereit mit dem Feld shop: Datum zwischen Fassung und heute, ohne Fassung nie bereit', () => {
const ok = { confirmed: true, date: '2026-10-06', channel: 'email', today: '2026-10-07' }
assert.equal(textformBereit(ok, shopFeld()), true)
assert.equal(textformBereit({ ...ok, date: '2026-10-04' }, shopFeld()), false, 'vor der Fassung')
assert.equal(textformBereit(ok, shopFeld({ avvVersion: '' })), false)
assert.equal(textformBereit(ok, undefined), false)
assert.equal(textformBereit({ ...ok, channel: 'whatsapp' }, shopFeld()), true, 'WhatsApp bei Funktionen erlaubt')
assert.equal(textformBereit({ ...ok, channel: 'telefon' }, shopFeld()), false)
})
test('termineChatTemplate: wörtlich, mit Fassung und den beiden versionierten Adressen', () => {
const { lb, avv } = termine().links
assert.equal(
termineChatTemplate('2026-10-05', { lb, avv }),
'Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. ' +
'Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung 2026-10-05: ' +
`${lb} · ${avv}. ` +
'Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.',
)
const text = termineChatTemplate('2026-10-05', { lb, avv })
assert.match(text, /leistungsbeschreibung-terminplaner-2026-10-05\.html/)
assert.match(text, /avv-terminplaner-2026-10-05\.html/)
assert.ok(!text.includes('undefined') && !text.includes('{'))
})
test('termineTextformLabel: Dialogtext des Kästchens nennt beide Unterlagen, die Fassung und den Ausschluss des Gesundheitsbereichs', () => {
const l = termineTextformLabel('2026-10-05')
assert.equal(l.lb, 'Leistungsbeschreibung Terminplaner')
assert.equal(l.avv, 'Auftragsverarbeitungsvertrag Terminplaner')
assert.equal(
l.before + l.lb + l.between + l.avv + l.after,
'Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung 2026-10-05) ' +
'in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.',
)
})
test('textformAnsicht: Fassung, geprüfte https-Adressen, Label und Vorlage aus dem Portal-Feld', () => {
const a = textformAnsicht(termine())
assert.equal(a.version, '2026-10-05')
assert.deepEqual(a.links, termine().links)
assert.deepEqual(a.label, termineTextformLabel('2026-10-05'))
assert.equal(a.vorlage, termineChatTemplate('2026-10-05', termine().links))
})
test('textformAnsicht: Adressen ohne https werden nie Link, ohne beide Adressen keine Vorlage', () => {
const schlecht = textformAnsicht(termine({ links: { lb: 'javascript:alert(1)', avv: 'http://project.webklar.com/x.html' } }))
assert.deepEqual(schlecht.links, { lb: '', avv: '' })
assert.equal(schlecht.vorlage, '', 'eine Vorlage mit leeren Adressen geht nie an den Kunden')
const halb = textformAnsicht(termine({ links: { lb: termine().links.lb } }))
assert.equal(halb.links.avv, '')
assert.equal(halb.vorlage, '')
for (const leer of [undefined, null, {}, { links: null }]) {
const a = textformAnsicht(leer)
assert.equal(a.version, '')
assert.deepEqual(a.links, { lb: '', avv: '' })
assert.equal(a.vorlage, '')
}
})
test('textformBereit: canSubmitTextform mit minDate = Fassung (Grenzen eingeschlossen), today = heute in Berlin', () => {
const t = termine()
const ok = { confirmed: true, date: '2026-10-06', channel: 'chat', today: '2026-10-07' }
assert.equal(textformBereit(ok, t), true)
assert.equal(textformBereit({ ...ok, date: '2026-10-05' }, t), true, 'Tag der Fassung selbst')
assert.equal(textformBereit({ ...ok, date: '2026-10-07' }, t), true, 'heute')
assert.equal(textformBereit({ ...ok, channel: 'email' }, t), true)
assert.equal(textformBereit({ ...ok, date: '2026-10-04' }, t), false, 'vor der Fassung')
assert.equal(textformBereit({ ...ok, date: '2026-10-08' }, t), false, 'Zukunft')
assert.equal(textformBereit({ ...ok, confirmed: false }, t), false)
assert.equal(textformBereit({ ...ok, date: '' }, t), false)
assert.equal(textformBereit({ ...ok, date: 'morgen' }, t), false)
assert.equal(textformBereit({ ...ok, channel: '' }, t), false)
assert.equal(textformBereit({ ...ok, channel: 'telefon' }, t), false)
})
test('textformBereit: ohne Fassung im Portal-Feld nie bereit (fail closed)', () => {
const ok = { confirmed: true, date: '2026-10-06', channel: 'chat', today: '2026-10-07' }
assert.equal(textformBereit(ok, termine({ avvVersion: '' })), false)
assert.equal(textformBereit(ok, undefined), false)
assert.equal(textformBereit(ok, null), false)
})
test('featureBody: Aktivieren mit Textform trägt Datum und Weg, sonst bleibt der Rumpf wie bisher', () => {
assert.deepEqual(
featureBody({ projectId: 'p1', action: 'activate', note: 'Pilot', textformDate: '2026-10-06', textformChannel: 'chat' }),
{ projectId: 'p1', action: 'activate', note: 'Pilot', textformDate: '2026-10-06', textformChannel: 'chat' },
)
assert.deepEqual(featureBody({ projectId: 'p1', action: 'activate', note: '' }), { projectId: 'p1', action: 'activate', note: '' })
assert.deepEqual(featureBody({ projectId: 'p1', action: 'disable', note: 'x' }), { projectId: 'p1', action: 'disable', note: 'x' })
assert.deepEqual(
featureBody({ projectId: 'p1', action: 'disable', note: 'x', textformDate: '2026-10-06', textformChannel: 'chat' }),
{ projectId: 'p1', action: 'disable', note: 'x' },
'Abschalten sendet nie Textform-Angaben',
)
assert.deepEqual(
featureBody({ projectId: 'p1', action: 'activate', note: '', textformDate: '2026-10-06', textformChannel: '' }),
{ projectId: 'p1', action: 'activate', note: '' },
'halbe Angaben gehen nicht mit',
)
})
test('speicherStatus: warnung true (K16 nicht gesendet) wird zur Warnung, sonst "Gespeichert."', () => {
assert.deepEqual(speicherStatus({ projects: [], warnung: true }), { ok: false, text: K16_WARNUNG })
assert.match(K16_WARNUNG, /K16/)
assert.match(K16_WARNUNG, /von Hand/)
assert.deepEqual(speicherStatus({ projects: [] }), { ok: true, text: 'Gespeichert.' })
assert.deepEqual(speicherStatus(undefined), { ok: true, text: 'Gespeichert.' })
// Terminplaner und Webshop: der Wortlaut nennt Unterlagen und AVV (mit und ohne Schluessel gleich)
for (const key of [undefined, 'news', 'termine', 'shop']) {
assert.deepEqual(speicherStatus({ warnung: true }, key), { ok: false, text: K16_WARNUNG }, String(key))
}
assert.match(K16_WARNUNG, /AVV/)
})
test('speicherStatus: Öffnungszeiten live haben keinen AVV, die Warnung behauptet keinen', () => {
const antwort = speicherStatus({ projects: [], warnung: true }, 'zeiten')
assert.deepEqual(antwort, { ok: false, text: K16_WARNUNG_OHNE_UNTERLAGEN })
assert.match(antwort.text, /K16/)
assert.match(antwort.text, /von Hand/)
assert.doesNotMatch(antwort.text, /AVV|Auftragsverarbeitung|Unterlagen/)
assert.notEqual(antwort.text, K16_WARNUNG)
// ohne Warnung bleibt es bei "Gespeichert." (auch bei zeiten)
assert.deepEqual(speicherStatus({ projects: [] }, 'zeiten'), { ok: true, text: 'Gespeichert.' })
assert.deepEqual(speicherStatus(undefined, 'zeiten'), { ok: true, text: 'Gespeichert.' })
})
test('aktionen: Webshop verhält sich wie der Terminplaner (ticketbasiert)', () => {
assert.deepEqual(aktionen('off', 'shop'), ['activate'])
assert.deepEqual(aktionen('disabled', 'shop'), ['activate'])
assert.deepEqual(aktionen('ordered', 'shop'), ['disable'])
assert.deepEqual(aktionen('active', 'shop'), ['disable'])
assert.deepEqual(aktionen('setup', 'shop'), ['abnahme', 'disable'])
assert.deepEqual(aktionen('setup', 'shop', { ticketMissing: true }), ['ticket', 'abnahme', 'disable'])
for (const state of ['off', 'ordered', 'active', 'disabled']) assert.deepEqual(aktionen(state, 'shop', { ticketMissing: true }), aktionen(state, 'shop'))
for (const action of aktionen('setup', 'shop', { ticketMissing: true })) assert.ok(FUNKTIONEN.find((f) => f.key === 'shop').bestaetigen[action], `Text für ${action}`)
})
test('siteKeyOf: auch der Webshop zeigt seinen Site-Key', () => {
assert.equal(siteKeyOf({ state: 'active', siteKey: ' abcdefghijklmnop ' }, 'shop'), 'abcdefghijklmnop')
assert.equal(siteKeyOf({ state: 'active' }, 'shop'), '')
})
test('warenartLabel und artikelzahlLabel: Beschriftungen, Unbekanntes bleibt sichtbar', () => {
assert.equal(warenartLabel('waren'), 'Waren')
assert.equal(warenartLabel('lebensmittel'), 'Lebensmittel')
assert.equal(warenartLabel('kleidung'), 'Kleidung')
assert.equal(warenartLabel('kunst'), 'Kunst/Handgemachtes')
assert.equal(warenartLabel('sonstiges'), 'Sonstiges')
assert.equal(warenartLabel('xyz'), 'xyz')
assert.equal(warenartLabel(undefined), '–')
assert.equal(warenartLabel('toString'), 'toString', 'kein Treffer über die Prototypkette')
assert.equal(artikelzahlLabel('bis20'), 'bis 20')
assert.equal(artikelzahlLabel('bis100'), 'bis 100')
assert.equal(artikelzahlLabel('mehr'), 'mehr als 100')
assert.equal(artikelzahlLabel(''), '–')
})
test('anfrageStatus: Ton und Beschriftung', () => {
assert.deepEqual(anfrageStatus('offen'), ['warn', 'Offen'])
assert.deepEqual(anfrageStatus('angenommen'), ['ok', 'Angenommen'])
assert.deepEqual(anfrageStatus('abgelehnt'), ['danger', 'Abgelehnt'])
assert.deepEqual(anfrageStatus('zurueckgezogen'), ['muted', 'Zurückgezogen'])
assert.deepEqual(anfrageStatus(undefined), ['muted', '–'])
})
const ANFRAGE = { warenart: 'kunst', beschreibung: ' Handgemachte Kerzen ', artikelzahl: 'bis20', versand: true, abholung: false, unternehmer: true, keineAusgeschlossenen: true, nachricht: '' }
test('anfrageZeilen: alle Angaben der Anfrage als Zeilen', () => {
assert.deepEqual(anfrageZeilen(ANFRAGE), [
{ label: 'Warenart', text: 'Kunst/Handgemachtes' },
{ label: 'Beschreibung', text: 'Handgemachte Kerzen' },
{ label: 'Artikelzahl', text: 'bis 20' },
{ label: 'Übergabe', text: 'Versand' },
{ label: 'Unternehmer', text: 'Ja' },
{ label: 'Keine ausgeschlossenen Waren', text: 'Ja' },
{ label: 'Nachricht', text: '–' },
])
assert.equal(anfrageZeilen({ ...ANFRAGE, abholung: true }).find((z) => z.label === 'Übergabe').text, 'Versand und Abholung')
assert.equal(anfrageZeilen({ ...ANFRAGE, versand: false }).find((z) => z.label === 'Übergabe').text, '–')
assert.deepEqual(anfrageZeilen(null), [])
})
test('anfrageText: eine Zeile aus Warenart, Menge und Übergabe', () => {
assert.equal(anfrageText(ANFRAGE), 'Kunst/Handgemachtes, bis 20 Artikel, Versand')
assert.equal(anfrageText({ ...ANFRAGE, abholung: true }), 'Kunst/Handgemachtes, bis 20 Artikel, Versand und Abholung')
assert.equal(anfrageText({ warenart: 'waren' }), 'Waren')
assert.equal(anfrageText(null), '')
})
test('stripeText: nicht verbunden, aktiv, Angaben fehlen', () => {
assert.equal(stripeText(null, false), 'nicht verbunden')
assert.equal(stripeText(undefined), 'nicht verbunden')
assert.equal(stripeText(null, true), 'Konto angelegt, Stand noch nicht geprüft')
assert.equal(stripeText({ charges_enabled: true, payouts_enabled: true }, true), 'Zahlungen aktiv')
assert.equal(stripeText({ charges_enabled: true, payouts_enabled: false }, true), 'Zahlungen aktiv, Auszahlungen noch nicht freigegeben')
assert.equal(stripeText({ charges_enabled: false, currently_due: ['external_account', 'individual.dob.day'], past_due: [] }, true), 'Angaben fehlen: external_account, individual.dob.day')
assert.equal(stripeText({ charges_enabled: false, past_due: ['a'], currently_due: ['a', 'b'] }, true), 'Angaben fehlen: a, b', 'doppelte Einträge nur einmal')
assert.equal(stripeText({ charges_enabled: false, currently_due: ['a', 'b', 'c', 'd', 'e', 'f'] }, true), 'Angaben fehlen: a, b, c, d und 2 weitere')
assert.equal(stripeText({ charges_enabled: false, currently_due: [], disabled_reason: 'requirements.pending_verification' }, true), 'Angaben fehlen: requirements.pending_verification')
assert.equal(stripeText({ charges_enabled: false, details_submitted: true }, true), 'Angaben fehlen: Prüfung durch Stripe läuft')
assert.equal(stripeText({ charges_enabled: false, details_submitted: false }, true), 'Angaben fehlen: Einrichtung nicht abgeschlossen')
})
test('stripeTon: ok, warn, muted', () => {
assert.equal(stripeTon({ charges_enabled: true }, true), 'ok')
assert.equal(stripeTon({ charges_enabled: false }, true), 'warn')
assert.equal(stripeTon(null, true), 'warn')
assert.equal(stripeTon(null, false), 'muted')
})
test('shopHatEintrag: Anfrage oder Zustand außer "off"', () => {
assert.equal(shopHatEintrag({ state: 'off', anfrage: null }), false)
assert.equal(shopHatEintrag({ state: 'off', anfrage: { status: 'offen' } }), true)
assert.equal(shopHatEintrag({ state: 'active', anfrage: null }), true)
assert.equal(shopHatEintrag({ state: 'disabled' }), true)
assert.equal(shopHatEintrag(null), false)
})
test('begruendungOk: Pflicht, höchstens 1000 Zeichen', () => {
assert.equal(begruendungOk(''), false)
assert.equal(begruendungOk(' '), false)
assert.equal(begruendungOk(undefined), false)
assert.equal(begruendungOk('Passt nicht'), true)
assert.equal(begruendungOk('x'.repeat(1000)), true)
assert.equal(begruendungOk('x'.repeat(1001)), false)
assert.equal(begruendungOk(` ${'x'.repeat(1000)} `), true, 'ohne Rand gezählt')
})
test('entscheidungBody: Begründung nur beim Ablehnen, getrimmt', () => {
assert.deepEqual(entscheidungBody({ projectId: 'p1', entscheidung: 'annehmen', begruendung: 'egal' }), { projectId: 'p1', entscheidung: 'annehmen' })
assert.deepEqual(entscheidungBody({ projectId: 'p1', entscheidung: 'ablehnen', begruendung: ' Zu groß ' }), { projectId: 'p1', entscheidung: 'ablehnen', begruendung: 'Zu groß' })
})
test('entscheidungStatus: Warnung, wenn die Mail an den Kunden fehlschlug', () => {
assert.deepEqual(entscheidungStatus({ ok: true }), { ok: true, text: 'Gespeichert.' })
assert.deepEqual(entscheidungStatus({ ok: true, mailFehler: true }), { ok: false, text: 'Entscheidung gespeichert, Mail an den Kunden fehlgeschlagen.' })
assert.deepEqual(entscheidungStatus(null), { ok: true, text: 'Gespeichert.' })
})
test('unterkunft: Aktionen wie beim Webshop, Site-Key wird gezeigt', () => {
for (const state of ['off', 'ordered', 'setup', 'active', 'disabled', undefined]) {
assert.deepEqual(aktionen(state, 'unterkunft'), aktionen(state, 'shop'), String(state))
assert.deepEqual(aktionen(state, 'unterkunft', { ticketMissing: true }), aktionen(state, 'shop', { ticketMissing: true }), `${state} mit Ticket fehlt`)
}
assert.equal(siteKeyOf({ state: 'active', siteKey: ' abcdefghijklmnop ' }, 'unterkunft'), 'abcdefghijklmnop')
assert.equal(siteKeyOf({ state: 'active', siteKey: null }, 'unterkunft'), '')
})
test('anfrageZeilen (unterkunft): Art, Einheiten, Portale, Nachricht', () => {
const a = { art: 'ferienwohnung', einheiten: 3, portale: true, nachricht: ' Hallo ' }
assert.deepEqual(anfrageZeilen(a, 'unterkunft'), [
{ label: 'Art', text: 'Ferienwohnung' },
{ label: 'Einheiten', text: '3' },
{ label: 'Portale', text: 'nutzt Booking.com/Airbnb' },
{ label: 'Nachricht', text: 'Hallo' },
])
assert.equal(anfrageZeilen({ ...a, art: 'monteurzimmer' }, 'unterkunft')[0].text, 'Monteurzimmer')
assert.equal(anfrageZeilen({ ...a, art: 'pension' }, 'unterkunft')[0].text, 'Pension')
assert.equal(anfrageZeilen({ ...a, art: 'sonstiges' }, 'unterkunft')[0].text, 'Sonstiges')
assert.equal(anfrageZeilen({ ...a, art: 'xyz' }, 'unterkunft')[0].text, 'xyz')
const leer = anfrageZeilen({ portale: false }, 'unterkunft')
assert.deepEqual(leer.map((z) => z.text), ['–', '–', 'nutzt keine Portale', '–'])
assert.equal(anfrageZeilen({ ...a, einheiten: 'x' }, 'unterkunft')[1].text, '–')
assert.deepEqual(anfrageZeilen(null, 'unterkunft'), [])
// ohne art: unveraendert Webshop
assert.deepEqual(anfrageZeilen(a), anfrageZeilen(a, 'shop'))
})
test('anfrageText (unterkunft): Kurzfassung', () => {
assert.equal(anfrageText({ art: 'pension', einheiten: 4, portale: true }, 'unterkunft'), 'Pension, 4 Einheiten, nutzt Booking.com/Airbnb')
assert.equal(anfrageText({ art: 'pension', einheiten: 1 }, 'unterkunft'), 'Pension, 1 Einheit')
assert.equal(anfrageText(null, 'unterkunft'), '')
})
test('entscheidungBody und begruendungOk gelten unverändert für den Buchungskalender', () => {
assert.deepEqual(entscheidungBody({ projectId: 'p', entscheidung: 'ablehnen', begruendung: ' x ' }), { projectId: 'p', entscheidung: 'ablehnen', begruendung: 'x' })
})
// Buchungskalender (key unterkunft): Zustimmung in Textform wie beim Webshop
const ukFeld = (extra = {}) => ({
textformNoetig: true,
avvVersion: '2026-10-07',
links: {
lb: 'https://project.webklar.com/rechtliches/leistungsbeschreibung-unterkunft-2026-10-07.html',
avv: 'https://project.webklar.com/rechtliches/avv-unterkunft-2026-10-07.html',
},
...extra,
})
test('Buchungskalender: textformNoetig, Label, Vorlage und Links aus dem Feld unterkunft', () => {
assert.equal(textformNoetig('unterkunft', 'activate', ukFeld()), true)
assert.equal(textformNoetig('unterkunft', 'activate', ukFeld({ textformNoetig: false })), false)
assert.equal(textformNoetig('unterkunft', 'activate', undefined), false)
for (const action of ['disable', 'abnahme', 'ticket']) assert.equal(textformNoetig('unterkunft', action, ukFeld()), false, action)
const a = textformAnsicht(ukFeld(), 'unterkunft')
assert.deepEqual(a.label, unterkunftTextformLabel('2026-10-07'))
assert.equal(a.label.lb, 'Leistungsbeschreibung Buchungskalender')
assert.equal(a.label.avv, 'Auftragsverarbeitungsvertrag Buchungskalender')
assert.match(a.label.after, /Fassung 2026-10-07/)
assert.equal(a.vorlage, unterkunftChatTemplate('2026-10-07', a.links))
assert.ok(a.vorlage.startsWith('Damit wir den Buchungskalender für Dich einschalten können'))
assert.ok(a.vorlage.includes(a.links.lb) && a.vorlage.includes(a.links.avv))
const schlecht = textformAnsicht(ukFeld({ links: { lb: 'javascript:alert(1)', avv: 'http://x.example/a.html' } }), 'unterkunft')
assert.deepEqual(schlecht.links, { lb: '', avv: '' })
assert.equal(schlecht.vorlage, '')
assert.equal(textformBereit({ confirmed: true, date: '2026-10-07', channel: 'email', today: '2026-10-07' }, ukFeld()), true)
})
// Kein Preis, Abrechnung beim Aktivieren (aus dem Abo / im Projekt enthalten) und Fehlertext bei fehlendem Abo
test('Kein Text der Funktionen nennt einen Preis', () => {
for (const f of FUNKTIONEN) for (const t of Object.values(f.bestaetigen)) assert.doesNotMatch(t, /€|\bEuro\b/, f.key)
})
test('ABRECHNUNG_OPTIONEN und fragtAbrechnung: nur Aktivieren, nicht nach Team-Sperre', () => {
assert.deepEqual(ABRECHNUNG_OPTIONEN, [{ value: 'abo', label: 'Aus dem Abo' }, { value: 'projekt', label: 'Im Projekt enthalten' }])
for (const key of ['news', 'termine', 'shop', 'zeiten', 'unterkunft']) {
assert.equal(fragtAbrechnung(key, 'activate', { state: 'off' }), true, key)
assert.equal(fragtAbrechnung(key, 'activate', undefined), true, key)
assert.equal(fragtAbrechnung(key, 'activate', { state: 'disabled', disabledReason: 'abo_ende' }), true, key)
assert.equal(fragtAbrechnung(key, 'activate', { state: 'disabled', disabledReason: 'team' }), false, key)
for (const action of ['disable', 'abnahme', 'ticket']) assert.equal(fragtAbrechnung(key, action, { state: 'setup' }), false, `${key} ${action}`)
}
assert.equal(fragtAbrechnung('analyse', 'activate', { state: 'off' }), false)
})
test('ABRECHNUNG_HINWEIS: erklärt beide Wege ohne Preis', () => {
assert.equal(
ABRECHNUNG_HINWEIS,
'Aus dem Abo: Die Einrichtungszeit geht aufs Stundenkonto des Kunden. Im Projekt enthalten: Sie zählt nicht aufs Stundenkonto und läuft über die Projektkosten.'
)
assert.doesNotMatch(ABRECHNUNG_HINWEIS, /€|\bEuro\b/)
})
test('abrechnungBereit: ohne Frage immer bereit, mit Frage nur mit abo oder projekt', () => {
const b = (o) => abrechnungBereit({ key: 'news', action: 'activate', feature: { state: 'off' }, abrechnung: '', ...o })
assert.equal(b({}), false); assert.equal(b({ abrechnung: 'gratis' }), false); assert.equal(b({ abrechnung: undefined }), false)
assert.equal(b({ abrechnung: 'abo' }), true); assert.equal(b({ abrechnung: 'projekt' }), true)
assert.equal(b({ action: 'disable' }), true)
assert.equal(b({ feature: { state: 'disabled', disabledReason: 'team' } }), true)
})
test('featureBody trägt abrechnung nur beim Aktivieren und nur mit gültigem Wert', () => {
assert.deepEqual(featureBody({ projectId: 'p1', action: 'activate', note: '', abrechnung: 'projekt' }), { projectId: 'p1', action: 'activate', note: '', abrechnung: 'projekt' })
assert.deepEqual(featureBody({ projectId: 'p1', action: 'activate', note: '', abrechnung: 'gratis' }), { projectId: 'p1', action: 'activate', note: '' })
assert.deepEqual(featureBody({ projectId: 'p1', action: 'disable', note: 'x', abrechnung: 'abo' }), { projectId: 'p1', action: 'disable', note: 'x' })
assert.deepEqual(featureBody({ projectId: 'p1', action: 'activate', note: '', textformDate: '2026-10-06', textformChannel: 'chat', abrechnung: 'abo' }), { projectId: 'p1', action: 'activate', note: '', textformDate: '2026-10-06', textformChannel: 'chat', abrechnung: 'abo' })
})
test('aboFehltText und featureFehlerText: jeder Grund, unbekannter Grund, andere Fehler', () => {
const fehler = (grund, code = 'abo_fehlt') => Object.assign(new Error('Text des Portals'), { status: 409, code, grund })
assert.equal(featureFehlerText(fehler('kein_abo'), 'news'), 'News: Der Kunde hat kein laufendes Abo. Die Funktion läuft nur mit passendem Abo.')
assert.equal(featureFehlerText(fehler('falsches_abo'), 'termine'), 'Terminplaner: Das Hosting-Abo des Kunden enthält diese Funktion nicht. Sie ist im Wartungs-Abo enthalten.')
assert.equal(featureFehlerText(fehler('gekuendigt'), 'shop'), 'Webshop: Das Abo des Kunden ist gekündigt. Eingeschaltet wird nicht, solange die Kündigung vorgemerkt ist.')
assert.equal(featureFehlerText(fehler('nicht_aktiv'), 'zeiten'), 'Öffnungszeiten live: Das Abo des Kunden läuft nicht (Status nicht aktiv). Bitte zuerst das Abo klären.')
assert.equal(featureFehlerText(fehler('unbekannt'), 'unterkunft'), 'Buchungskalender: Das passende Abo fehlt.')
assert.equal(featureFehlerText(fehler(undefined), 'news'), 'News: Das passende Abo fehlt.')
assert.equal(featureFehlerText(fehler('kein_abo', 'andere'), 'news'), 'Text des Portals')
assert.equal(featureFehlerText(null, 'news'), 'Speichern fehlgeschlagen.')
})
test('aboFehltText: beginnt_spaeter nennt den Berliner Tag des Beginns, ohne brauchbares Datum bleibt der allgemeine Satz', () => {
assert.equal(aboFehltText('beginnt_spaeter', 'termine', '2026-11-03T08:00:00.000Z'), 'Terminplaner: Das Abo beginnt am 03.11.2026. Ab dann kann die Funktion eingeschaltet werden.')
// Berliner Kalendertag, nicht UTC: 23:30 UTC am 3.11. ist in Berlin schon der 4.11.
assert.equal(aboFehltText('beginnt_spaeter', 'news', '2026-11-03T23:30:00.000Z'), 'News: Das Abo beginnt am 04.11.2026. Ab dann kann die Funktion eingeschaltet werden.')
for (const ab of [undefined, null, '', 'kein Datum', 5, {}]) assert.equal(aboFehltText('beginnt_spaeter', 'news', ab), 'News: Das passende Abo fehlt.', String(ab))
// das Datum gehört nur zu beginnt_spaeter
for (const grund of ['kein_abo', 'falsches_abo', 'gekuendigt', 'nicht_aktiv', 'unbekannt']) {
assert.equal(aboFehltText(grund, 'news', '2026-11-03T08:00:00.000Z'), aboFehltText(grund, 'news'), grund)
}
})
test('featureFehlerText: beginnt_spaeter trägt das Datum des Fehlers, abgeschaltet und bereits_eingeschaltet haben eigene Texte fürs Team', () => {
const fehler = (code, extra = {}) => Object.assign(new Error('Text des Portals (in der Du-Form des Kunden)'), { status: 409, code, ...extra })
assert.equal(
featureFehlerText(fehler('abo_fehlt', { grund: 'beginnt_spaeter', ab: '2026-11-03T08:00:00.000Z' }), 'shop'),
'Webshop: Das Abo beginnt am 03.11.2026. Ab dann kann die Funktion eingeschaltet werden.'
)
assert.equal(featureFehlerText(fehler('abo_fehlt', { grund: 'beginnt_spaeter' }), 'shop'), 'Webshop: Das passende Abo fehlt.')
assert.equal(
featureFehlerText(fehler('abgeschaltet'), 'news'),
'News: Die Funktion wurde für dieses Projekt abgeschaltet (der Grund steht in der Zeile). Sie lässt sich hier nicht wieder einschalten.'
)
assert.equal(
featureFehlerText(fehler('bereits_eingeschaltet'), 'zeiten'),
'Öffnungszeiten live: Die Funktion ist für dieses Projekt schon eingeschaltet. Bitte die Seite neu laden.'
)
assert.equal(featureFehlerText(fehler('abgeschaltet'), 'unbekannt'), 'unbekannt: Die Funktion wurde für dieses Projekt abgeschaltet (der Grund steht in der Zeile). Sie lässt sich hier nicht wieder einschalten.')
// nie der Kundentext des Portals ("du", "Schreib uns im Chat"), und kein Preis
for (const code of ['abgeschaltet', 'bereits_eingeschaltet']) {
const text = featureFehlerText(fehler(code), 'termine')
assert.doesNotMatch(text, /\bdu\b|\bdein|Schreib|Portal|€/i, code)
}
// ein anderer Code oder ein Fehler ohne Code zeigt wie bisher die Meldung des Portals
assert.equal(featureFehlerText(fehler('bestellung_laeuft'), 'news'), 'Text des Portals (in der Du-Form des Kunden)')
assert.equal(featureFehlerText(Object.assign(new Error('Netz weg'), { status: 0 }), 'news'), 'Netz weg')
})
test('speicherStatus: warnung "abo_fehlt" (Aufbauphase, noch kein Abo) ist eine eigene Warnung und wird nicht mit der K16-Warnung verwechselt', () => {
const aufbau = { projects: [], warnung: 'abo_fehlt', grund: 'kein_abo' }
for (const key of [undefined, 'news', 'termine', 'shop', 'zeiten', 'unterkunft']) {
assert.deepEqual(speicherStatus(aufbau, key), { ok: false, text: ABO_FEHLT_WARNUNG }, String(key))
}
assert.equal(ABO_FEHLT_WARNUNG, 'Eingeschaltet. Achtung: Diese Funktion läuft nur mit Wartungs-Abo. Bitte das Abo vor dem Livegang vereinbaren.')
assert.doesNotMatch(ABO_FEHLT_WARNUNG, /K16|AVV|Unterlagen|€/)
assert.notEqual(ABO_FEHLT_WARNUNG, K16_WARNUNG)
assert.notEqual(ABO_FEHLT_WARNUNG, K16_WARNUNG_OHNE_UNTERLAGEN)
// die alte boolesche Warnung (K16 ging nicht raus) behält ihren Text, mit und ohne Grund
assert.deepEqual(speicherStatus({ projects: [], warnung: true }, 'termine'), { ok: false, text: K16_WARNUNG })
assert.deepEqual(speicherStatus({ projects: [], warnung: true }, 'zeiten'), { ok: false, text: K16_WARNUNG_OHNE_UNTERLAGEN })
assert.deepEqual(speicherStatus({ projects: [], warnung: true, grund: 'kein_abo' }, 'shop'), { ok: false, text: K16_WARNUNG })
// ohne Warnung (oder mit einem Wert, den es nicht gibt) bleibt es bei "Gespeichert."
for (const antwort of [{ projects: [] }, { warnung: false }, { warnung: 'unbekannt' }, { warnung: 'abo_fehlt_nicht' }, undefined, null]) {
assert.deepEqual(speicherStatus(antwort, 'news'), { ok: true, text: 'Gespeichert.' }, JSON.stringify(antwort))
}
})