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: '', 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: '' },
{ 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 " 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: '' },
{ 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= ö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))
}
})