Öffnungszeiten: Team-Panel, Ersatz-HTML und Rechtliches-Karte

Panel unter der Zeile Öffnungszeiten live (Header-Selektor, Kontakt-/Hinweis-Leiste aus, Speichern mit teamVersion,
Ersatz-HTML kopieren je Plan), Meldung zum Ersatz-Ticket nach dem Abschalten, Hindernis an aktionen, Statustext der Zeiten,
Team-API (GET ersatz, PATCH team), Karte Leistungsbeschreibung Öffnungszeiten live in setup-legal (Schlüssel höchstens 36 Zeichen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 07:12:04 +00:00
1 parent 994347408d
commit 3d67aa88cf
10 files changed
+498 -6

No files matched your search

+21 -2
View File
@@ -62,17 +62,35 @@ test('terminplaner_karten_vorhanden', () => {
assert.ok(d.liveUrl.startsWith(base))
assert.ok(!/\d{4}-\d{2}-\d{2}/.test(d.liveUrl), 'Alias-Seite ohne Fassungsdatum')
}
assert.equal(DOCUMENTS.length, 9, 'keine Analyse-Karten und keine weiteren')
// Oeffnungszeiten live: Karte 10, hinter den Terminplaner-Karten; der Schluessel hat 28 Zeichen (WOMS-Grenze 36, Ruling R35)
assert.equal(DOCUMENTS.length, 10, 'keine Analyse-Karten und keine weiteren')
})
test('zeiten_karte_vorhanden_am_ende', () => {
const d = DOCUMENTS.find((x) => x.key === 'leistungsbeschreibung-zeiten')
assert.ok(d, 'Karte Leistungsbeschreibung Oeffnungszeiten live')
assert.deepEqual(d, {
key: 'leistungsbeschreibung-zeiten',
title: 'Leistungsbeschreibung Öffnungszeiten live',
liveUrl: 'https://project.webklar.com/vertragsunterlagen/zeiten-leistungsbeschreibung.html',
sort: 10,
})
assert.ok(d.key.length <= 36, 'Dokument-ID hoechstens 36 Zeichen')
assert.equal(DOCUMENTS.at(-1), d, 'am Ende von DOCUMENTS, nach den Terminplaner-Karten')
assert.equal(DOCUMENTS.findIndex((x) => x.key === 'widerrufsbelehrung-funktionen'), 8, 'Terminplaner-Karten unveraendert davor')
})
test('bestehende_karten_unveraendert', () => {
assert.deepEqual(DOCUMENTS.slice(0, 6), [
assert.deepEqual(DOCUMENTS.slice(0, 9), [
{ key: 'agb', title: 'AGB', liveUrl: 'https://webklar.com/agb', sort: 1 },
{ key: 'abo-bedingungen', title: 'Abo-Bedingungen', liveUrl: 'https://project.webklar.com/abo-bedingungen.html', sort: 2 },
{ key: 'avv', title: 'AVV (Auftragsverarbeitung)', liveUrl: 'https://project.webklar.com/abo-bedingungen.html', sort: 3 },
{ key: 'datenschutz', title: 'Datenschutzerklärung (Website und Portal)', liveUrl: 'https://project.webklar.com/datenschutz.html', sort: 4 },
{ key: 'impressum', title: 'Impressum', liveUrl: 'https://webklar.com/impressum', sort: 5 },
{ key: 'widerruf', title: 'Widerrufsbelehrung und -formular', liveUrl: 'https://project.webklar.com/widerrufen.html', sort: 6 },
{ key: 'leistungsbeschreibung-terminplaner', title: 'Leistungsbeschreibung Terminplaner', liveUrl: 'https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner.html', sort: 7 },
{ key: 'avv-terminplaner', title: 'AVV Terminplaner (Auftragsverarbeitung)', liveUrl: 'https://project.webklar.com/rechtliches/avv-terminplaner.html', sort: 8 },
{ key: 'widerrufsbelehrung-funktionen', title: 'Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)', liveUrl: 'https://project.webklar.com/rechtliches/widerrufsbelehrung-funktionen.html', sort: 9 },
])
})
@@ -88,5 +106,6 @@ test('dry_run_listet_karten', () => {
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext leistungsbeschreibung-terminplaner:')))
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext avv-terminplaner:')))
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext widerrufsbelehrung-funktionen:')))
assert.ok(zeilen.includes('Rechtstext leistungsbeschreibung-zeiten: Leistungsbeschreibung Öffnungszeiten live'))
assert.ok(!/angelegt|vorhanden|Fertig/.test(r.stdout), 'Trockenlauf legt nichts an')
})
+19
View File
@@ -125,3 +125,22 @@ test('401 des Portals: einmal mit frischem JWT wiederholen; scheitert dann creat
assert.deepEqual(await retry.api('/uebersicht'), { ok: true })
assert.deepEqual(retry.calls.map((c) => c.init.headers.Authorization), ['Bearer jwt-1', 'Bearer jwt-2'])
})
test('Fehlercode und Feldmeldungen des Portals bleiben am Fehler (409 ZEITEN_VERALTET, 400 mit fehler)', async () => {
const { api } = client({
responses: [
reply(409, { error: 'Inzwischen geändert – bitte neu laden.', code: 'ZEITEN_VERALTET' }),
reply(400, { error: 'Bitte prüfen.', fehler: { navSelektor: 'zu lang' } }),
reply(409, { error: 'nur Text' }),
],
})
const veraltet = await fails(api('/x', { method: 'PATCH', body: {} }))
assert.equal(veraltet.status, 409)
assert.equal(veraltet.code, 'ZEITEN_VERALTET')
const ungueltig = await fails(api('/x', { method: 'PATCH', body: {} }))
assert.equal(ungueltig.status, 400)
assert.deepEqual(ungueltig.fehler, { navSelektor: 'zu lang' })
const ohne = await fails(api('/x'))
assert.equal(ohne.code, undefined)
assert.equal(ohne.fehler, undefined)
})
+181 -1
View File
@@ -3,7 +3,11 @@ import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// Öffnungszeiten live im Funktionen-Reiter: Statustexte, Prüfung des Navigations-Selektors (Spiegel des Portals) und Rumpf der Team-Einstellungen
const { zeitenStatusText, navSelektorFehler, NAV_SELEKTOR_MAX, NAV_SELEKTOR_FEHLER, teamBody } = await loadModule('src/lib/zeitenView.js')
const {
zeitenStatusText, navSelektorFehler, NAV_SELEKTOR_MAX, NAV_SELEKTOR_FEHLER, teamBody,
ersatzPfad, zeitenTeamPfad, teamFormular, teamGeaendert, mergeTeam, teamSpeichernFehler,
ersatzKnoepfe, ersatzKopieren, ersatzMeldung, abschaltMeldung, KEINE_ZEITEN_TEXT, ZWISCHENABLAGE_FEHLT,
} = await loadModule('src/lib/zeitenView.js')
const EINBAU = 'Leisten live – Einbau läuft'
const WARTET = 'wartet auf die Zeiten des Kunden'
@@ -152,3 +156,179 @@ test('teamBody: die Version wird unverändert durchgereicht (das Portal prüft s
test('teamBody: ein ungültiger Selektor wird nicht "repariert", sondern unverändert gesendet (das Formular sperrt vorher, das Portal prüft noch einmal)', () => {
assert.equal(teamBody({ projectId: 'p1', teamVersion: 1, navSelektor: 'a{b}', kontaktleisteAus: false, hinweisleisteAus: false }).navSelektor, 'a{b}')
})
// --- Panel: Pfade, Formular, Speichern, Ersatz-HTML, Abschalt-Meldung ---
test('ersatzPfad: GET .../zeiten/ersatz mit kodierten Parametern projectId und plan', () => {
assert.equal(ersatzPfad('c1', 'p1', 'haupt'), '/funktionen/c1/zeiten/ersatz?projectId=p1&plan=haupt')
assert.equal(ersatzPfad('c/1', 'p 1', 'a&b=c'), '/funktionen/c%2F1/zeiten/ersatz?projectId=p%201&plan=a%26b%3Dc')
})
test('ersatzPfad: ohne plan fehlt der Parameter (Hauptplan), ein leerer plan bleibt stehen (das Portal antwortet dann 404)', () => {
assert.equal(ersatzPfad('c1', 'p1'), '/funktionen/c1/zeiten/ersatz?projectId=p1')
assert.equal(ersatzPfad('c1', 'p1', null), '/funktionen/c1/zeiten/ersatz?projectId=p1')
assert.equal(ersatzPfad('c1', 'p1', ''), '/funktionen/c1/zeiten/ersatz?projectId=p1&plan=')
})
test('zeitenTeamPfad: PATCH .../zeiten/team', () => {
assert.equal(zeitenTeamPfad('c1'), '/funktionen/c1/zeiten/team')
assert.equal(zeitenTeamPfad('c/1'), '/funktionen/c%2F1/zeiten/team')
})
test('teamFormular: Startwerte aus feature.team, null-Selektor wird ein leeres Feld', () => {
assert.deepEqual(
teamFormular({ team: { navSelektor: 'header > nav', kontaktleisteAus: true, hinweisleisteAus: false } }),
{ navSelektor: 'header > nav', kontaktleisteAus: true, hinweisleisteAus: false }
)
assert.deepEqual(
teamFormular({ team: { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: true } }),
{ navSelektor: '', kontaktleisteAus: false, hinweisleisteAus: true }
)
})
test('teamFormular: ohne Team-Feld (ältere Antwort, keine Ansicht) alles aus und leer', () => {
const leer = { navSelektor: '', kontaktleisteAus: false, hinweisleisteAus: false }
assert.deepEqual(teamFormular({}), leer)
assert.deepEqual(teamFormular({ team: null }), leer)
assert.deepEqual(teamFormular(null), leer)
assert.deepEqual(teamFormular(undefined), leer)
// Schalter nur bei true an, Selektor nur als Text
assert.deepEqual(teamFormular({ team: { navSelektor: 5, kontaktleisteAus: 'true', hinweisleisteAus: 1 } }), leer)
})
test('teamGeaendert: nur ein echter Unterschied zum gespeicherten Stand zählt, Leerraum um den Selektor nicht', () => {
const feature = { team: { navSelektor: 'header', kontaktleisteAus: false, hinweisleisteAus: true } }
const gleich = { navSelektor: 'header', kontaktleisteAus: false, hinweisleisteAus: true }
assert.equal(teamGeaendert(gleich, feature), false)
assert.equal(teamGeaendert({ ...gleich, navSelektor: ' header ' }, feature), false)
assert.equal(teamGeaendert({ ...gleich, navSelektor: 'header nav' }, feature), true)
assert.equal(teamGeaendert({ ...gleich, kontaktleisteAus: true }, feature), true)
assert.equal(teamGeaendert({ ...gleich, hinweisleisteAus: false }, feature), true)
// gespeichert ohne Selektor: leeres oder leeres-mit-Leerraum Feld ist keine Änderung
const ohne = { team: { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false } }
assert.equal(teamGeaendert({ navSelektor: ' ', kontaktleisteAus: false, hinweisleisteAus: false }, ohne), false)
assert.equal(teamGeaendert({ navSelektor: 'x', kontaktleisteAus: false, hinweisleisteAus: false }, ohne), true)
})
test('mergeTeam: übernimmt team und teamVersion in genau dieses Projekt, alles andere bleibt', () => {
const zeiten = { state: 'active', plaene: [{ kurz: 'a', name: 'A', haupt: true }], team: { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false }, teamVersion: 2 }
const projects = [
{ projectId: 'p1', projectName: 'Eins', features: { news: { state: 'off' }, zeiten } },
{ projectId: 'p2', projectName: 'Zwei', features: { zeiten: { ...zeiten, teamVersion: 7 } } },
]
const neu = mergeTeam(projects, 'p1', { team: { navSelektor: 'header', kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 3 })
assert.notEqual(neu, projects, 'neue Liste')
assert.deepEqual(neu[0].features.zeiten, { ...zeiten, team: { navSelektor: 'header', kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 3 })
assert.deepEqual(neu[0].features.news, { state: 'off' })
assert.equal(neu[0].projectName, 'Eins')
assert.equal(neu[1], projects[1], 'anderes Projekt unverändert')
assert.equal(projects[0].features.zeiten.teamVersion, 2, 'Eingabe nicht verändert')
})
test('mergeTeam: unbrauchbare Antwort oder Projekt ohne Zeiten ändert nichts', () => {
const projects = [{ projectId: 'p1', features: { zeiten: { state: 'active', teamVersion: 1, team: {} } } }, { projectId: 'p2', features: {} }]
assert.equal(mergeTeam(projects, 'p1', null), projects)
assert.equal(mergeTeam(projects, 'p1', {}), projects)
assert.equal(mergeTeam(projects, 'p1', { team: { navSelektor: null }, teamVersion: 'x' }), projects)
assert.equal(mergeTeam(projects, 'p1', { teamVersion: 2 }), projects)
assert.deepEqual(mergeTeam(projects, 'p2', { team: {}, teamVersion: 1 }), projects, 'p2 hat keine Zeiten: nichts erfunden')
assert.deepEqual(mergeTeam(projects, 'p9', { team: {}, teamVersion: 1 }), projects)
})
test('teamSpeichernFehler: 409 ZEITEN_VERALTET – „Inzwischen geändert – bitte neu laden“, danach neu laden', () => {
const err = Object.assign(new Error('Inzwischen geändert – bitte neu laden.'), { status: 409, code: 'ZEITEN_VERALTET' })
assert.deepEqual(teamSpeichernFehler(err), { text: 'Inzwischen geändert – bitte neu laden.', neuLaden: true })
// der feste Text gilt auch, wenn der Server-Text anders lautet
const anders = Object.assign(new Error('irgendwas'), { status: 409, code: 'ZEITEN_VERALTET' })
assert.equal(teamSpeichernFehler(anders).text, 'Inzwischen geändert – bitte neu laden.')
})
test('teamSpeichernFehler: anderer 409 (z. B. nicht eingeschaltet) zeigt den Text des Portals, die Ansicht ist trotzdem veraltet', () => {
const err = Object.assign(new Error('Öffnungszeiten live ist für dieses Projekt nicht eingeschaltet.'), { status: 409 })
assert.deepEqual(teamSpeichernFehler(err), { text: 'Öffnungszeiten live ist für dieses Projekt nicht eingeschaltet.', neuLaden: true })
})
test('teamSpeichernFehler: 400 und Netzfehler zeigen ihren Text unverändert, ohne Neuladen; ohne Text eine Ersatzmeldung', () => {
assert.deepEqual(teamSpeichernFehler(Object.assign(new Error(NAV_SELEKTOR_FEHLER), { status: 400 })), { text: NAV_SELEKTOR_FEHLER, neuLaden: false })
assert.deepEqual(teamSpeichernFehler(Object.assign(new Error('Das Kundenportal ist gerade nicht erreichbar.'), { status: 0 })), { text: 'Das Kundenportal ist gerade nicht erreichbar.', neuLaden: false })
assert.deepEqual(teamSpeichernFehler(new Error('')), { text: 'Speichern fehlgeschlagen.', neuLaden: false })
assert.deepEqual(teamSpeichernFehler(null), { text: 'Speichern fehlgeschlagen.', neuLaden: false })
})
test('ersatzKnoepfe: ein Plan – „Ersatz-HTML kopieren“, der Hauptplan zuerst bleibt in der Reihenfolge des Portals', () => {
assert.deepEqual(ersatzKnoepfe({ plaene: [{ kurz: 'haupt', name: 'Öffnungszeiten', haupt: true }] }), [
{ kurz: 'haupt', label: 'Ersatz-HTML kopieren', bezeichnung: 'Öffnungszeiten (haupt)' },
])
})
test('ersatzKnoepfe: mehrere Pläne – je Plan ein Knopf mit Name und Kurzname', () => {
const knoepfe = ersatzKnoepfe({
plaene: [
{ kurz: 'laden', name: 'Laden', haupt: true },
{ kurz: 'cafe', name: 'Café', haupt: false },
{ kurz: 'lieferung', name: '', haupt: false },
{ kurz: 'abholung', name: 'abholung', haupt: false },
],
})
assert.deepEqual(knoepfe.map((k) => k.kurz), ['laden', 'cafe', 'lieferung', 'abholung'])
assert.deepEqual(knoepfe.map((k) => k.label), [
'Ersatz-HTML kopieren: Laden (laden)',
'Ersatz-HTML kopieren: Café (cafe)',
'Ersatz-HTML kopieren: lieferung',
'Ersatz-HTML kopieren: abholung',
])
})
test('ersatzKnoepfe: Pläne ohne Kurzname und Unbrauchbares werden übergangen, ohne Pläne [] (Hinweis statt Knopf)', () => {
assert.deepEqual(ersatzKnoepfe({ plaene: [{ kurz: '', name: 'x' }, null, { name: 'y' }, { kurz: 5 }] }), [])
assert.deepEqual(ersatzKnoepfe({ plaene: [] }), [])
assert.deepEqual(ersatzKnoepfe({}), [])
assert.deepEqual(ersatzKnoepfe(null), [])
assert.equal(KEINE_ZEITEN_TEXT, 'Der Kunde hat noch keine Zeiten eingetragen.')
})
test('ersatzKopieren: schreibt das HTML in die Zwischenablage', async () => {
const geschrieben = []
const clipboard = { writeText: async (text) => { geschrieben.push(text) } }
assert.equal(await ersatzKopieren('<table>&auml;</table>', clipboard), 'kopiert')
assert.deepEqual(geschrieben, ['<table>&auml;</table>'])
})
test('ersatzKopieren: ohne Zwischenablage-API oder bei Ablehnung – manuell kopieren; leeres HTML wird nie kopiert', async () => {
assert.equal(await ersatzKopieren('<p>x</p>', undefined), 'manuell')
assert.equal(await ersatzKopieren('<p>x</p>', null), 'manuell')
assert.equal(await ersatzKopieren('<p>x</p>', {}), 'manuell')
assert.equal(await ersatzKopieren('<p>x</p>', { writeText: 'kein Funktion' }), 'manuell')
assert.equal(await ersatzKopieren('<p>x</p>', { writeText: async () => { throw new Error('NotAllowedError') } }), 'manuell')
assert.equal(await ersatzKopieren('<p>x</p>', { writeText: () => { throw new Error('sofort') } }), 'manuell')
let aufgerufen = 0
const clipboard = { writeText: async () => { aufgerufen++ } }
for (const leer of ['', undefined, null, 5]) assert.equal(await ersatzKopieren(leer, clipboard), 'leer', String(leer))
assert.equal(aufgerufen, 0)
})
test('ersatzMeldung: bestätigt das Kopieren sichtbar, nennt den Plan und warnt, wenn die Zwischenablage fehlt', () => {
assert.deepEqual(ersatzMeldung('kopiert'), { ok: true, text: 'Ersatz-HTML kopiert.' })
assert.deepEqual(ersatzMeldung('kopiert', 'Café (cafe)'), { ok: true, text: 'Ersatz-HTML kopiert: Café (cafe).' })
assert.deepEqual(ersatzMeldung('manuell'), { ok: false, text: ZWISCHENABLAGE_FEHLT })
assert.match(ZWISCHENABLAGE_FEHLT, /Zwischenablage/)
assert.equal(ersatzMeldung('leer').ok, false)
assert.equal(ersatzMeldung('irgendwas').ok, false)
})
test('abschaltMeldung: angelegt, aktuell, fehler und ohne Angabe', () => {
assert.deepEqual(abschaltMeldung('angelegt'), { ok: true, text: 'Gespeichert. Ticket „Ersatz-Tabelle aktualisieren“ angelegt.' })
assert.deepEqual(abschaltMeldung('aktuell'), { ok: true, text: 'Gespeichert. Die Ersatz-Tabelle ist aktuell, kein Ticket nötig.' })
assert.deepEqual(abschaltMeldung(undefined), { ok: true, text: 'Gespeichert.' })
assert.deepEqual(abschaltMeldung('etwas-neues'), { ok: true, text: 'Gespeichert.' })
})
test('abschaltMeldung: bei „fehler“ eine sichtbare Warnung – Ticket von Hand anlegen, ein zweites Abschalten gibt es nicht', () => {
const m = abschaltMeldung('fehler')
assert.equal(m.ok, false)
assert.match(m.text, /^Gespeichert, aber/)
assert.match(m.text, /„Ersatz-Tabelle aktualisieren“/)
assert.match(m.text, /konnte nicht angelegt werden/)
assert.match(m.text, /von Hand/)
assert.doesNotMatch(m.text, /24 Uhr/)
})