Öffnungszeiten: Register und Texte im Ticketsystem
Funktionen-Reiter kennt "Öffnungszeiten live" (zeiten) hinter dem Terminplaner: Bestätigungstexte, Site-Key, Aktionen je Zustand (Abnahme nur ohne Hindernis "zeiten_fehlen"). Neues Modul zeitenView.js: Statustext (Einbau läuft / wartet auf die Zeiten des Kunden / Abo-Ende mit Frist), navSelektorFehler (Spiegel der Portal-Regel) und teamBody für PATCH .../zeiten/team. Protokoll-Label für zeiten_gespeichert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
72eedeb59c
commit
994347408d
6 files changed
+326
-13
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner), Aktionen je Zustand und
|
// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner, Öffnungszeiten live), Aktionen je Zustand und
|
||||||
// Einarbeiten der Portal-Antwort. Rein, unter Node testbar.
|
// Einarbeiten der Portal-Antwort. Rein, unter Node testbar.
|
||||||
|
|
||||||
import { fmtYmd, fmtBerlin } from './format'
|
import { fmtYmd, fmtBerlin } from './format'
|
||||||
@@ -24,6 +24,16 @@ export const FUNKTIONEN = [
|
|||||||
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
|
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?',
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Zustand einer Funktion -> [Ton des Abzeichens, Beschriftung]. */
|
/** Zustand einer Funktion -> [Ton des Abzeichens, Beschriftung]. */
|
||||||
@@ -58,12 +68,12 @@ export function abgeschaltetText(feature) {
|
|||||||
export const funktionOf = (project, key) => project?.features?.[key] || { state: 'off' }
|
export const funktionOf = (project, key) => project?.features?.[key] || { state: 'off' }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Site-Key (Schluessel fuer den Einbau auf der Kundenwebsite) einer Funktion: nur beim Terminplaner und nur, wenn das Portal
|
* Site-Key (Schluessel fuer den Einbau auf der Kundenwebsite) einer Funktion: nur beim Terminplaner und bei Oeffnungszeiten live
|
||||||
* einen nennt (feature.siteKey); sonst ''.
|
* und nur, wenn das Portal einen nennt (feature.siteKey); sonst ''.
|
||||||
*/
|
*/
|
||||||
export function siteKeyOf(feature, key) {
|
export function siteKeyOf(feature, key) {
|
||||||
const wert = feature?.siteKey
|
const wert = feature?.siteKey
|
||||||
return key === 'termine' && typeof wert === 'string' ? wert.trim() : ''
|
return (key === 'termine' || key === 'zeiten') && typeof wert === 'string' ? wert.trim() : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Beschriftung der Aktionsknoepfe (Aktion = action im POST /funktionen/:customerId/:key). */
|
/** Beschriftung der Aktionsknoepfe (Aktion = action im POST /funktionen/:customerId/:key). */
|
||||||
@@ -76,13 +86,20 @@ export const AKTION_LABEL = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Moegliche Aktionen je Zustand. News (und ohne key): Aktivieren (ohne Zahlung) ausser bei "active", Abschalten solange bestellt, in
|
* Moegliche Aktionen je Zustand. News (und ohne key): Aktivieren (ohne Zahlung) ausser bei "active", Abschalten solange bestellt, in
|
||||||
* Einrichtung oder aktiv. Terminplaner: "ordered" nur Abschalten (Aktivieren ohne Zahlung wuerde eine spaetere Zahlung im Portal
|
* Einrichtung oder aktiv. Terminplaner und Oeffnungszeiten live (beide mit Ticket-Freischaltung): "ordered" nur Abschalten (Aktivieren
|
||||||
* verschlucken), "setup" Freischalten nach der Abnahme (mit ticketMissing zuerst "Ticket nachlegen"), "active" nur Abschalten.
|
* ohne Zahlung wuerde eine spaetere Zahlung im Portal verschlucken), "setup" Freischalten nach der Abnahme (mit ticketMissing zuerst
|
||||||
|
* "Ticket nachlegen"), "active" nur Abschalten; sonst ("off", "disabled", ohne Zustand) Aktivieren. Fuer "disabled" gibt es kein
|
||||||
|
* Abschalten, auch wenn das Ersatz-Ticket beim Abschalten fehlschlug (das Portal merkt sich das nicht; die Antwort nennt es nur einmal).
|
||||||
|
* hindernis (Portal: feature.hindernis): beim Terminplaner bleibt die Abnahme sichtbar und das Portal sperrt sie mit Begruendung
|
||||||
|
* (hindernisText); bei Oeffnungszeiten live entfaellt sie, solange der Kunde noch keine Zeiten eingetragen hat ('zeiten_fehlen').
|
||||||
*/
|
*/
|
||||||
export function aktionen(state, key, { ticketMissing = false } = {}) {
|
export function aktionen(state, key, { ticketMissing = false, hindernis = null } = {}) {
|
||||||
if (key === 'termine') {
|
if (key === 'termine' || key === 'zeiten') {
|
||||||
if (state === 'ordered' || state === 'active') return ['disable']
|
if (state === 'ordered' || state === 'active') return ['disable']
|
||||||
if (state === 'setup') return ticketMissing ? ['ticket', 'abnahme', 'disable'] : ['abnahme', 'disable']
|
if (state === 'setup') {
|
||||||
|
const abnahme = key === 'zeiten' && hindernis ? [] : ['abnahme']
|
||||||
|
return [...(ticketMissing ? ['ticket'] : []), ...abnahme, 'disable']
|
||||||
|
}
|
||||||
return ['activate']
|
return ['activate']
|
||||||
}
|
}
|
||||||
const list = []
|
const list = []
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const LABELS = {
|
|||||||
termin_einstellungen: 'Terminplaner-Einstellungen geändert',
|
termin_einstellungen: 'Terminplaner-Einstellungen geändert',
|
||||||
termin_aktion: 'Termin bearbeitet',
|
termin_aktion: 'Termin bearbeitet',
|
||||||
termin_manuell: 'Termin eingetragen',
|
termin_manuell: 'Termin eingetragen',
|
||||||
|
zeiten_gespeichert: 'Öffnungszeiten gespeichert',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deutsche Beschriftung einer Aktion; unbekannte Aktionen erscheinen roh. */
|
/** Deutsche Beschriftung einer Aktion; unbekannte Aktionen erscheinen roh. */
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Oeffnungszeiten live im Kunden-Reiter "Funktionen": Statustext je Projekt, Pruefung des Navigations-Selektors (Team-Einstellung) und
|
||||||
|
// Rumpf fuer PATCH /funktionen/:customerId/zeiten/team. Rein, unter Node testbar (keine Appwrite-Importe).
|
||||||
|
|
||||||
|
import { berlinYmd, fmtYmd } from './format'
|
||||||
|
|
||||||
|
const TEXT_EINBAU = 'Leisten live – Einbau läuft'
|
||||||
|
const TEXT_WARTET = 'wartet auf die Zeiten des Kunden'
|
||||||
|
/** Tage zwischen dem erkannten Abo-Ende (aboEndeAt) und dem Abschalten (Portal: zeiten/aboAbgleich.js, FRIST_TAGE). */
|
||||||
|
const FRIST_TAGE = 30
|
||||||
|
|
||||||
|
/** Kalendertag 'JJJJ-MM-TT' plus n Tage ('JJJJ-MM-TT'); reine Kalenderrechnung ohne Uhrzeit, also ohne Zeitumstellung. */
|
||||||
|
function plusTage(ymd, tage) {
|
||||||
|
const [jahr, monat, tag] = ymd.split('-').map(Number)
|
||||||
|
return new Date(Date.UTC(jahr, monat - 1, tag + tage)).toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hinweis auf das Ende des Wartungs-Abos: die Funktion wird am Berliner Tag von aboEndeAt plus 30 Tage abgeschaltet, ausser der
|
||||||
|
* Kunde behaelt sie. Mit unlesbarem Datum ohne Tag ("nach 30 Tagen"), nie mit einem erfundenen.
|
||||||
|
*/
|
||||||
|
function aboEndeText(aboEndeAt) {
|
||||||
|
const lesbar = typeof aboEndeAt === 'string' && !Number.isNaN(Date.parse(aboEndeAt))
|
||||||
|
const wann = lesbar ? `am ${fmtYmd(plusTage(berlinYmd(aboEndeAt), FRIST_TAGE))}` : `nach ${FRIST_TAGE} Tagen`
|
||||||
|
return `Abo beendet – Abschaltung ${wann}, falls der Kunde die Funktion nicht behält`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statustext unter dem Abzeichen (Portal: project.features.zeiten): in der Einrichtung "Leisten live – Einbau läuft" bzw. bei
|
||||||
|
* hindernis 'zeiten_fehlen' "wartet auf die Zeiten des Kunden"; in Einrichtung und aktiv zusaetzlich der Abo-Ende-Hinweis mit Frist
|
||||||
|
* (feature.aboEndeAt), durch " · " getrennt. In allen anderen Zustaenden '': Grund und Loeschdatum einer Abschaltung nennt
|
||||||
|
* abgeschaltetText (funktionenView). Ohne Anrede, nie "24 Uhr".
|
||||||
|
*/
|
||||||
|
export function zeitenStatusText(feature) {
|
||||||
|
const state = feature?.state
|
||||||
|
if (state !== 'setup' && state !== 'active') return ''
|
||||||
|
const teile = []
|
||||||
|
if (state === 'setup') teile.push(feature.hindernis === 'zeiten_fehlen' ? TEXT_WARTET : TEXT_EINBAU)
|
||||||
|
if (feature.aboEndeAt) teile.push(aboEndeText(feature.aboEndeAt))
|
||||||
|
return teile.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Team-Einstellungen (nur ueber WOMS aenderbar) ---
|
||||||
|
|
||||||
|
/** Hoechstlaenge des Navigations-Selektors (Portal: ZEITEN_MAX.navSelektor). */
|
||||||
|
export const NAV_SELEKTOR_MAX = 120
|
||||||
|
/** Erlaubt sind A-Z a-z 0-9, Leerzeichen und # . - _ > : [ ] = " ' (also nie < { } ;); Spiegel von NAV_RE in zeiten/pruefen.js. */
|
||||||
|
const NAV_RE = /^[A-Za-z0-9 #.\-_>:[\]="']+$/
|
||||||
|
/** Meldung wie im Portal (normalisiereTeam), damit Formular und Server dieselbe Regel gleich benennen. */
|
||||||
|
export const NAV_SELEKTOR_FEHLER = `Der Selektor darf höchstens ${NAV_SELEKTOR_MAX} Zeichen lang sein und nur A–Z, 0–9, Leerzeichen und # . - _ > : [ ] = " ' enthalten.`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft den Navigations-Selektor (wohin die Hinweis-Leiste der Website sich haengt) wie normalisiereTeam im Portal: nach dem Trimmen
|
||||||
|
* hoechstens 120 Zeichen aus der erlaubten Menge; leer (auch nur Leerraum, null, undefined) heisst "kein Selektor" und ist gueltig; kein Text
|
||||||
|
* ist ungueltig. => Fehlermeldung oder null.
|
||||||
|
*/
|
||||||
|
export function navSelektorFehler(text) {
|
||||||
|
if (text === undefined || text === null) return null
|
||||||
|
const s = typeof text === 'string' ? text.trim() : null
|
||||||
|
if (s === null || s.length > NAV_SELEKTOR_MAX || (s !== '' && !NAV_RE.test(s))) return NAV_SELEKTOR_FEHLER
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rumpf fuer PATCH /funktionen/:customerId/zeiten/team: { projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus }.
|
||||||
|
* Selektor getrimmt, leer => null; die Schalter sind immer echte Wahrheitswerte (nur true gilt als an). Die Version (teamVersion aus
|
||||||
|
* der Ansicht) und ein ungueltiger Selektor gehen unveraendert mit: das Portal prueft beides (409 ZEITEN_VERALTET, 400 mit fehler).
|
||||||
|
*/
|
||||||
|
export function teamBody({ projectId, teamVersion, navSelektor, kontaktleisteAus, hinweisleisteAus }) {
|
||||||
|
const selektor = typeof navSelektor === 'string' ? navSelektor.trim() : navSelektor
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
teamVersion,
|
||||||
|
navSelektor: selektor === '' || selektor === undefined ? null : selektor,
|
||||||
|
kontaktleisteAus: kontaktleisteAus === true,
|
||||||
|
hinweisleisteAus: hinweisleisteAus === true,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ const {
|
|||||||
featureBody,
|
featureBody,
|
||||||
} = await loadModule('src/lib/funktionenView.js')
|
} = await loadModule('src/lib/funktionenView.js')
|
||||||
|
|
||||||
test('FUNKTIONEN: News und Terminplaner mit den Bestätigungstexten', () => {
|
test('FUNKTIONEN: News, Terminplaner und Öffnungszeiten live (in dieser Reihenfolge) mit den Bestätigungstexten', () => {
|
||||||
assert.deepEqual(FUNKTIONEN, [
|
assert.deepEqual(FUNKTIONEN, [
|
||||||
{
|
{
|
||||||
key: 'news',
|
key: 'news',
|
||||||
@@ -45,9 +45,28 @@ test('FUNKTIONEN: News und Terminplaner mit den Bestätigungstexten', () => {
|
|||||||
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
|
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?',
|
||||||
|
},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('FUNKTIONEN: Schlüssel sind eindeutig und jede Funktion hat Aktivieren und Abschalten bestätigt', () => {
|
||||||
|
assert.deepEqual(FUNKTIONEN.map((f) => f.key), ['news', 'termine', 'zeiten'])
|
||||||
|
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 Bestellung (149 €)', () => {
|
test('W1: der Abschalten-Dialog des Terminplaners nennt Buchungsstopp, Löschung nach 30 Tagen und die neue Bestellung (149 €)', () => {
|
||||||
const text = FUNKTIONEN.find((f) => f.key === 'termine').bestaetigen.disable
|
const text = FUNKTIONEN.find((f) => f.key === 'termine').bestaetigen.disable
|
||||||
assert.match(text, /^Terminplaner abschalten\?/)
|
assert.match(text, /^Terminplaner abschalten\?/)
|
||||||
@@ -120,6 +139,40 @@ test('aktionen: Terminplaner mit fehlendem Ticket bietet in der Einrichtung zuer
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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', () => {
|
test('AKTION_LABEL: Beschriftung je Aktion', () => {
|
||||||
assert.deepEqual(AKTION_LABEL, {
|
assert.deepEqual(AKTION_LABEL, {
|
||||||
activate: 'Aktivieren (ohne Zahlung)',
|
activate: 'Aktivieren (ohne Zahlung)',
|
||||||
@@ -312,6 +365,17 @@ test('siteKeyOf: nur der Terminplaner zeigt seinen Site-Key, nur wenn das Portal
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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"', () => {
|
test('abgeschaltetText: Grund und Löschdatum im Zustand "disabled"', () => {
|
||||||
const text = (disabledReason, loeschungAm) => abgeschaltetText({ state: 'disabled', disabledReason, loeschungAm })
|
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', '2026-11-04'), 'Abgeschaltet (Abo beendet), Löschung am 04.11.2026')
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import { loadModule } from './load.mjs'
|
|||||||
// Protokoll der Kundenansicht: Aktionen des Portals (customerViewLog.action) als deutsche Beschriftung
|
// Protokoll der Kundenansicht: Aktionen des Portals (customerViewLog.action) als deutsche Beschriftung
|
||||||
const { viewLogLabel, formatViewLogTime } = await loadModule('src/lib/viewLog.js')
|
const { viewLogLabel, formatViewLogTime } = await loadModule('src/lib/viewLog.js')
|
||||||
|
|
||||||
test('viewLogLabel: alle zehn Aktionen und Rohwert', () => {
|
test('viewLogLabel: alle elf Aktionen und Rohwert', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[
|
[
|
||||||
'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht',
|
'geoeffnet', 'beendet', 'abgelaufen', 'news_angelegt', 'news_bild', 'news_geaendert', 'news_geloescht',
|
||||||
'termin_einstellungen', 'termin_aktion', 'termin_manuell',
|
'termin_einstellungen', 'termin_aktion', 'termin_manuell', 'zeiten_gespeichert',
|
||||||
].map(viewLogLabel),
|
].map(viewLogLabel),
|
||||||
[
|
[
|
||||||
'Kundenansicht geöffnet', 'Kundenansicht beendet', 'Kundenansicht abgelaufen', 'News angelegt', 'News-Foto hochgeladen', 'News geändert', 'News gelöscht',
|
'Kundenansicht geöffnet', 'Kundenansicht beendet', 'Kundenansicht abgelaufen', 'News angelegt', 'News-Foto hochgeladen', 'News geändert', 'News gelöscht',
|
||||||
'Terminplaner-Einstellungen geändert', 'Termin bearbeitet', 'Termin eingetragen',
|
'Terminplaner-Einstellungen geändert', 'Termin bearbeitet', 'Termin eingetragen', 'Öffnungszeiten gespeichert',
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
assert.equal(viewLogLabel('neu_irgendwas'), 'neu_irgendwas')
|
assert.equal(viewLogLabel('neu_irgendwas'), 'neu_irgendwas')
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
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 EINBAU = 'Leisten live – Einbau läuft'
|
||||||
|
const WARTET = 'wartet auf die Zeiten des Kunden'
|
||||||
|
|
||||||
|
test('zeitenStatusText: Einrichtung ohne Hindernis – Leisten sind live, der Einbau läuft', () => {
|
||||||
|
assert.equal(zeitenStatusText({ state: 'setup', hindernis: null }), EINBAU)
|
||||||
|
assert.equal(zeitenStatusText({ state: 'setup' }), EINBAU, 'ohne Feld hindernis')
|
||||||
|
assert.equal(zeitenStatusText({ state: 'setup', hindernis: '' }), EINBAU)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeitenStatusText: Einrichtung mit hindernis "zeiten_fehlen" – wartet auf die Zeiten des Kunden', () => {
|
||||||
|
assert.equal(zeitenStatusText({ state: 'setup', hindernis: 'zeiten_fehlen' }), WARTET)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeitenStatusText: unbekanntes Hindernis zeigt nichts Erfundenes, sondern den Einbau-Text', () => {
|
||||||
|
assert.equal(zeitenStatusText({ state: 'setup', hindernis: 'irgendwas_neues' }), EINBAU)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeitenStatusText: Abo-Ende nennt die Frist (Berliner Tag des Abo-Endes plus 30 Tage)', () => {
|
||||||
|
const abo = (state, aboEndeAt) => zeitenStatusText({ state, hindernis: null, aboEndeAt })
|
||||||
|
const hinweis = (datum) => `Abo beendet – Abschaltung am ${datum}, falls der Kunde die Funktion nicht behält`
|
||||||
|
assert.equal(abo('active', '2026-10-04T10:00:00.000Z'), hinweis('03.11.2026'))
|
||||||
|
// 22:30 UTC ist in Berlin schon der nächste Tag (Sommerzeit, +2 h): 05.10. -> 04.11.
|
||||||
|
assert.equal(abo('active', '2026-10-04T22:30:00.000Z'), hinweis('04.11.2026'))
|
||||||
|
// Monats- und Jahreswechsel, Zeitumstellung am 25.10. spielt bei Kalendertagen keine Rolle
|
||||||
|
assert.equal(abo('active', '2026-10-20T12:00:00.000Z'), hinweis('19.11.2026'))
|
||||||
|
assert.equal(abo('active', '2026-12-15T12:00:00.000Z'), hinweis('14.01.2027'))
|
||||||
|
// Appwrite liefert auch +00:00
|
||||||
|
assert.equal(abo('active', '2026-10-04T10:00:00.000+00:00'), hinweis('03.11.2026'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeitenStatusText: Abo-Ende in der Einrichtung steht zusätzlich zum Einbau- bzw. Warte-Text', () => {
|
||||||
|
const aboEndeAt = '2026-10-04T10:00:00.000Z'
|
||||||
|
const frist = 'Abo beendet – Abschaltung am 03.11.2026, falls der Kunde die Funktion nicht behält'
|
||||||
|
assert.equal(zeitenStatusText({ state: 'setup', hindernis: null, aboEndeAt }), `${EINBAU} · ${frist}`)
|
||||||
|
assert.equal(zeitenStatusText({ state: 'setup', hindernis: 'zeiten_fehlen', aboEndeAt }), `${WARTET} · ${frist}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeitenStatusText: Abo-Ende mit unlesbarem Datum nennt 30 Tage statt eines erfundenen Tages', () => {
|
||||||
|
const text = zeitenStatusText({ state: 'active', aboEndeAt: 'kein-datum' })
|
||||||
|
assert.equal(text, 'Abo beendet – Abschaltung nach 30 Tagen, falls der Kunde die Funktion nicht behält')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeitenStatusText: aktiv ohne Abo-Ende, aus, bestellt, abgeschaltet, ohne Ansicht – leer', () => {
|
||||||
|
assert.equal(zeitenStatusText({ state: 'active', hindernis: null, aboEndeAt: null }), '')
|
||||||
|
assert.equal(zeitenStatusText({ state: 'active' }), '')
|
||||||
|
assert.equal(zeitenStatusText({ state: 'off' }), '')
|
||||||
|
assert.equal(zeitenStatusText({ state: 'ordered' }), '')
|
||||||
|
// abgeschaltet: Grund und Löschdatum zeigt abgeschaltetText, nichts doppelt
|
||||||
|
assert.equal(zeitenStatusText({ state: 'disabled', disabledReason: 'abo_ende', loeschungAm: '2026-11-04', aboEndeAt: '2026-10-04T10:00:00.000Z' }), '')
|
||||||
|
assert.equal(zeitenStatusText({ state: 'off', aboEndeAt: '2026-10-04T10:00:00.000Z' }), '')
|
||||||
|
assert.equal(zeitenStatusText(null), '')
|
||||||
|
assert.equal(zeitenStatusText(undefined), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeitenStatusText: kein Text nennt "24 Uhr"', () => {
|
||||||
|
for (const feature of [
|
||||||
|
{ state: 'setup' },
|
||||||
|
{ state: 'setup', hindernis: 'zeiten_fehlen' },
|
||||||
|
{ state: 'active', aboEndeAt: '2026-10-04T10:00:00.000Z' },
|
||||||
|
{ state: 'setup', hindernis: 'zeiten_fehlen', aboEndeAt: '2026-10-04T22:30:00.000Z' },
|
||||||
|
]) {
|
||||||
|
assert.doesNotMatch(zeitenStatusText(feature), /24 Uhr/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navSelektorFehler: leer ist erlaubt (kein Selektor)', () => {
|
||||||
|
for (const wert of [undefined, null, '', ' ', '\n\t ']) {
|
||||||
|
assert.equal(navSelektorFehler(wert), null, JSON.stringify(wert))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navSelektorFehler: übliche Selektoren sind gültig', () => {
|
||||||
|
for (const wert of [
|
||||||
|
'header',
|
||||||
|
'#nav',
|
||||||
|
'.site-header',
|
||||||
|
'.site-header > nav',
|
||||||
|
'header#main-nav.fixed',
|
||||||
|
'nav[role="navigation"]',
|
||||||
|
"a[href='x']",
|
||||||
|
'ul.menu:first-child',
|
||||||
|
'div_wrap .a-b',
|
||||||
|
'ABC xyz 019',
|
||||||
|
' header ',
|
||||||
|
]) {
|
||||||
|
assert.equal(navSelektorFehler(wert), null, wert)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navSelektorFehler: erlaubt sind nur A–Z a–z 0–9, Leerzeichen und # . - _ > : [ ] = " \'', () => {
|
||||||
|
const zeichen = 'AZaz09 #.-_>:[]="\''
|
||||||
|
assert.equal(navSelektorFehler(zeichen), null)
|
||||||
|
assert.equal(navSelektorFehler('a'.repeat(NAV_SELEKTOR_MAX)), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navSelektorFehler: < { } ; und alle übrigen Zeichen werden abgelehnt', () => {
|
||||||
|
for (const zeichen of ['<', '{', '}', ';', '(', ')', ',', '*', '+', '~', '/', '\\', '@', '!', '|', '$', '%', '^', '&', '?', '`', 'ä', 'ß', '€', '\n', '\t']) {
|
||||||
|
assert.equal(navSelektorFehler(`a${zeichen}b`), NAV_SELEKTOR_FEHLER, JSON.stringify(zeichen))
|
||||||
|
}
|
||||||
|
assert.equal(navSelektorFehler('header { display: none }'), NAV_SELEKTOR_FEHLER)
|
||||||
|
assert.equal(navSelektorFehler('</style><script>'), NAV_SELEKTOR_FEHLER)
|
||||||
|
assert.equal(navSelektorFehler('a\nb'), NAV_SELEKTOR_FEHLER, 'Zeilenumbruch mitten im Text')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navSelektorFehler: höchstens 120 Zeichen, gezählt nach dem Trimmen', () => {
|
||||||
|
assert.equal(NAV_SELEKTOR_MAX, 120)
|
||||||
|
assert.equal(navSelektorFehler('a'.repeat(120)), null)
|
||||||
|
assert.equal(navSelektorFehler(` ${'a'.repeat(120)} `), null, 'Leerraum außen zählt nicht')
|
||||||
|
assert.equal(navSelektorFehler('a'.repeat(121)), NAV_SELEKTOR_FEHLER)
|
||||||
|
assert.equal(navSelektorFehler(`${'a'.repeat(60)} ${'b'.repeat(60)}`), NAV_SELEKTOR_FEHLER, '121 mit Leerzeichen innen')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('navSelektorFehler: kein Text wird abgelehnt wie auf dem Server (Zahl, Objekt, Wahrheitswert)', () => {
|
||||||
|
for (const wert of [5, true, {}, []]) {
|
||||||
|
assert.equal(navSelektorFehler(wert), NAV_SELEKTOR_FEHLER, JSON.stringify(wert))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('NAV_SELEKTOR_FEHLER: wörtlich wie die Meldung des Portals', () => {
|
||||||
|
assert.equal(NAV_SELEKTOR_FEHLER, 'Der Selektor darf höchstens 120 Zeichen lang sein und nur A–Z, 0–9, Leerzeichen und # . - _ > : [ ] = " \' enthalten.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('teamBody: genau die fünf Felder der Team-Route, Selektor getrimmt, leer wird null', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
teamBody({ projectId: 'p1', teamVersion: 3, navSelektor: ' header > nav ', kontaktleisteAus: true, hinweisleisteAus: false }),
|
||||||
|
{ projectId: 'p1', teamVersion: 3, navSelektor: 'header > nav', kontaktleisteAus: true, hinweisleisteAus: false }
|
||||||
|
)
|
||||||
|
for (const leer of ['', ' ', null, undefined]) {
|
||||||
|
assert.equal(teamBody({ projectId: 'p1', teamVersion: 0, navSelektor: leer, kontaktleisteAus: false, hinweisleisteAus: false }).navSelektor, null, JSON.stringify(leer))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('teamBody: Schalter sind immer echte Wahrheitswerte, nur true gilt als an', () => {
|
||||||
|
const body = (kontaktleisteAus, hinweisleisteAus) => teamBody({ projectId: 'p1', teamVersion: 1, navSelektor: '', kontaktleisteAus, hinweisleisteAus })
|
||||||
|
assert.deepEqual(body(true, true), { projectId: 'p1', teamVersion: 1, navSelektor: null, kontaktleisteAus: true, hinweisleisteAus: true })
|
||||||
|
assert.deepEqual(body(undefined, undefined), { projectId: 'p1', teamVersion: 1, navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false })
|
||||||
|
assert.deepEqual(body('true', 1), { projectId: 'p1', teamVersion: 1, navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('teamBody: die Version wird unverändert durchgereicht (das Portal prüft sie), auch 0', () => {
|
||||||
|
assert.equal(teamBody({ projectId: 'p1', teamVersion: 0, navSelektor: '', kontaktleisteAus: false, hinweisleisteAus: false }).teamVersion, 0)
|
||||||
|
assert.equal(teamBody({ projectId: 'p1', teamVersion: 41, navSelektor: '', kontaktleisteAus: false, hinweisleisteAus: false }).teamVersion, 41)
|
||||||
|
})
|
||||||
|
|
||||||
|
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}')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user