Öffnungszeiten: öffentliche Antwort mit Cache

oeffentlich.js baut die Antwort für das Website-Skript (Pläne mit Woche, 60 Tagen
und Statuswechseln, Hinweise, Kontakt-Leiste, JSON-LD, Firma, Team) und merkt sie
30 Sekunden je siteKey. Ungültige Schlüssel und ausgeschalteter Schalter lösen
weder eine Abfrage noch einen Cache-Eintrag aus; die Funktion antwortet schon in
setup. findActiveBySiteKey nimmt dafür { states } entgegen (Vorgabe wie bisher
nur active).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 01:12:16 +00:00
1 parent 7bf2ad2665
commit 1c9f773e97
4 files changed
+787 -4

No files matched your search

+9 -4
View File
@@ -586,13 +586,18 @@ export async function activateClosedTickets({ now = new Date(), customerId = nul
return activated
}
/** Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null. */
export async function findActiveBySiteKey(siteKey, featureKey = 'news') {
if (!/^[a-z2-7]{16}$/.test(String(siteKey || '')) || !FEATURE_KEYS.includes(featureKey)) return null
/**
* Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null.
* states ersetzt die Vorgabe ['active'] (die Oeffnungszeiten antworten schon in 'setup', damit das Team die Leisten
* vor dem Schliessen des Tickets pruefen kann); eine leere Liste passt auf nichts und fragt Appwrite gar nicht erst.
*/
export async function findActiveBySiteKey(siteKey, featureKey = 'news', { states = ['active'] } = {}) {
if (typeof siteKey !== 'string' || !/^[a-z2-7]{16}$/.test(siteKey) || !FEATURE_KEYS.includes(featureKey)) return null
if (!Array.isArray(states) || states.length === 0) return null
const [doc] = await listDocuments(config.collections.portalFeatures, [
Query.equal('siteKey', siteKey),
Query.equal('featureKey', featureKey),
Query.equal('state', 'active'),
Query.equal('state', states),
Query.limit(1),
])
return doc || null
+159
View File
@@ -0,0 +1,159 @@
import { heute } from '../termine/zeit.js'
import { customerOf, findActiveBySiteKey, projectName } from '../siteFeatures.js'
import { zeitenCache } from './cache.js'
import { zeitenAus } from './schalter.js'
import { ladeZeiten } from './speicher.js'
import { gueltigeHinweise, hauptplan, jsonLdFuer, modusUndFenster, tageListe, wechselListe } from './zeitlogik.js'
/**
* Oeffnungszeiten live: die oeffentliche Antwort fuer das Website-Skript (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md,
* Abschnitt 4.1). Die Route (Task 12) reicht den siteKey der URL durch, setzt CORS, ETag und Limits und macht aus einem Fehler
* dieses Moduls ein 503; hier steht nur, was in der Antwort steht und wann sie gebaut wird.
*
* Die Antwort enthaelt nie interne IDs (kein $id, customerId, projectId, featureId, keine Plan-ID, nur den Kurznamen) und keine
* Kundendaten ausser dem Firmennamen und den veroeffentlichten Kontaktangaben.
*/
const SITE_KEY_RE = /^[a-z2-7]{16}$/
const TAGE_VORSCHAU = 60 // Spec 4.1: tage je Plan (heute bis heute + 59)
const ROUTE_BASIS = 'https://www.google.com/maps/dir/?api=1&destination='
const WHATSAPP_BASIS = 'https://wa.me/'
const TELEFON_RE = /^[1-9][0-9]{5,14}$/ // Ziffern mit Laendervorwahl, 6 bis 15 (E.164): so speichert pruefen.js
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const nichtLeererText = (wert) => (typeof wert === 'string' && wert.trim() !== '' ? wert.trim() : null)
function pruefeJetzt(now) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('now ist kein gueltiges Datum')
return now.getTime()
}
/** Beliebiger Text fuer eine URL; ein einzelnes Surrogat aus von Hand veraenderten Daten wuerde encodeURIComponent sonst werfen. */
const kodiere = (wert) => encodeURIComponent(String(wert).toWellFormed())
/** Nur Ziffern (nie ein Text, der in tel: oder wa.me landen darf, ohne dass er so gespeichert wurde), sonst null. */
const ziffern = (wert) => (typeof wert === 'string' && TELEFON_RE.test(wert) ? wert : null)
/**
* Kontakt-Leiste: null, wenn sie aus ist oder es nichts anzuzeigen gibt; sonst { telefon, routeUrl, whatsappUrl } mit null fuer
* Fehlendes. WhatsApp nur mit bestaetigtAt (der Kunde hat den Datenschutz-Haken gesetzt); der fremde Dienst oeffnet erst beim Klick.
*/
function kontaktAntwort(kontakt) {
if (!istObjekt(kontakt) || kontakt.an !== true) return null
const telefon = ziffern(kontakt.telefon)
const adresse = nichtLeererText(kontakt.adresse)
const wa = istObjekt(kontakt.whatsapp) ? kontakt.whatsapp : null
const nummer = wa && nichtLeererText(wa.bestaetigtAt) ? ziffern(wa.nummer) : null
const routeUrl = adresse ? ROUTE_BASIS + kodiere(adresse) : null
const whatsappUrl = nummer ? `${WHATSAPP_BASIS}${nummer}?text=${kodiere(typeof wa.starttext === 'string' ? wa.starttext : '')}` : null
if (!telefon && !routeUrl && !whatsappUrl) return null
return { telefon, routeUrl, whatsappUrl }
}
/** Team-Einstellungen mit den Vorgaben (null, false, false); nur diese drei Schluessel verlassen den Server. */
function teamAntwort(team) {
const t = istObjekt(team) ? team : {}
return {
navSelektor: nichtLeererText(t.navSelektor),
kontaktleisteAus: t.kontaktleisteAus === true,
hinweisleisteAus: t.hinweisleisteAus === true,
}
}
/** Ein Plan der Antwort; interne Plan-ID bleibt draussen, der Kurzname ist der oeffentliche Schluessel. */
function planAntwort(daten, plan, now, heuteTag) {
const woche = []
for (let tag = 1; tag <= 7; tag += 1) woche.push({ tag, ...modusUndFenster(plan.tage?.[tag]) })
return {
kurz: plan.kurz,
name: typeof plan.name === 'string' ? plan.name : '',
haupt: plan.haupt === true,
woche,
tage: tageListe(daten, plan, heuteTag, TAGE_VORSCHAU),
wechsel: wechselListe(daten, plan, now).map(({ ab, offen, text, kurz }) => ({ ab, offen, text, kurz })),
}
}
/**
* Baut die Antwort (rein: kein Appwrite, keine Uhr ausser now) aus den gespeicherten Daten.
* @param {{ daten: object|null, team: object, firma: { name: string }, now: Date }} p
* daten null (oder unbrauchbar) = noch nichts gespeichert: plaene leer, kein JSON-LD (jsonld null, damit der Browser vorhandenes
* JSON-LD nicht mit leeren Zeiten ueberschreibt). firma.name kommt vom Aufrufer, firma.adresse ist kontakt.adresse.
* @returns {object} { aktiv: true, stand, plaene, hinweise, kontakt, jsonld, firma, team }
*/
export function baueOeffentlicheAntwort({ daten, team, firma, now } = {}) {
pruefeJetzt(now)
const d = istObjekt(daten) ? daten : null
const heuteTag = heute(now)
const haupt = d ? hauptplan(d) : null
const plaene = Array.isArray(d?.plaene) ? d.plaene.filter((p) => istObjekt(p) && nichtLeererText(p.kurz)) : []
return {
aktiv: true,
stand: now.toISOString(),
plaene: plaene.map((plan) => planAntwort(d, plan, now, heuteTag)),
hinweise: d ? gueltigeHinweise(d, now) : [],
kontakt: kontaktAntwort(d?.kontakt),
jsonld: haupt ? jsonLdFuer(d, haupt, now) : null,
firma: { name: nichtLeererText(firma?.name) ?? '', adresse: nichtLeererText(d?.kontakt?.adresse) },
team: teamAntwort(team),
}
}
const AUS = Object.freeze({ aktiv: false })
/** Friert die Antwort samt allem Darunterliegenden ein: was im Speicher liegt, darf kein Aufrufer mehr veraendern. */
function friere(wert) {
if (wert !== null && typeof wert === 'object' && !Object.isFrozen(wert)) {
Object.freeze(wert)
for (const teil of Object.values(wert)) friere(teil)
}
return wert
}
/** Firmenname des Kunden; fehlen Firmen- und Kundenname, der Projektname, sonst ''. */
async function firmenName(kunde, projectId) {
const name = nichtLeererText(kunde.companyName) ?? nichtLeererText(kunde.name)
if (name) return name
return typeof projectId === 'string' && projectId !== '' ? (nichtLeererText(await projectName(projectId)) ?? '') : ''
}
async function ladeAntwort(siteKey, now) {
// 'setup' antwortet schon: das Team prueft die Leisten, bevor das Ticket geschlossen und die Funktion 'active' wird (Spec 4.4)
const feature = await findActiveBySiteKey(siteKey, 'zeiten', { states: ['setup', 'active'] })
if (!feature) return AUS
const kunde = typeof feature.customerId === 'string' && feature.customerId !== '' ? await customerOf(feature.customerId) : null
if (!kunde) return AUS
const gespeichert = await ladeZeiten(feature.$id)
return baueOeffentlicheAntwort({
daten: gespeichert?.daten ?? null,
team: gespeichert?.team,
firma: { name: await firmenName(kunde, feature.projectId) },
now,
})
}
/**
* Die oeffentliche Antwort zu einem siteKey (nie null, nie ein Fehler fuer Unbekanntes):
* Schalter aus oder siteKey nicht im Format [a-z2-7]{16} => { aktiv: false } ohne Appwrite-Abfrage und ohne Eintrag im Speicher;
* sonst aus dem Speicher (30 s, auch fuer { aktiv: false } bei unbekanntem Schluessel, falschem Zustand oder geloeschtem Kunden);
* sonst Funktion (setup/active) und Kunde laden, Zeiten laden, bauen, merken.
* Appwrite-Fehler gehen unveraendert an den Aufrufer (die Route macht daraus 503) und werden nicht gemerkt.
* Die Antwort ist eingefroren (sie wird geteilt).
* @param {string} siteKey Schluessel aus der URL
* @param {Date} [now]
*/
export async function oeffentlicheZeitenAntwort(siteKey, now = new Date()) {
if (zeitenAus()) return AUS
if (typeof siteKey !== 'string' || !SITE_KEY_RE.test(siteKey)) return AUS
const jetztMs = pruefeJetzt(now)
const gemerkt = zeitenCache.hole(siteKey, jetztMs)
if (gemerkt !== undefined) return gemerkt
const antwort = friere(await ladeAntwort(siteKey, now))
zeitenCache.setze(siteKey, antwort, jetztMs)
return antwort
}
+539
View File
@@ -0,0 +1,539 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
// Oeffentliche Antwort der Oeffnungszeiten (server/services/zeiten/oeffentlich.js, Spezifikation 2026-10-04-oeffnungszeiten-live-design.md,
// Abschnitt 4.1): baueOeffentlicheAntwort ist rein, oeffentlicheZeitenAntwort prueft Schalter und Schluessel, holt Funktion, Kunde und
// Zeiten aus dem Appwrite-Nachbau und legt die Antwort 30 Sekunden im Speicher ab.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
},
datetimes: ZEITEN_DATETIMES,
defaultLimit: 25,
})
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { zeitenCache, resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js')
const { speichereZeiten, speichereTeam } = await import('../server/services/zeiten/speicher.js')
const { baueOeffentlicheAntwort, oeffentlicheZeitenAntwort } = await import('../server/services/zeiten/oeffentlich.js')
const JETZT = new Date('2026-10-05T10:00:00.000Z') // Berlin: Montag 2026-10-05 12:00
const SITE_KEY = 'abcdabcdabcdabcd'
const KUNDE = 'c_kunde01'
const PROJEKT = 'p_projekt01'
const FEATURE_ID = 'f_p_projekt01_zeiten'
const P_STANDARD = 'p_aaaaaaa1'
const P_KUECHE = 'p_aaaaaaa2'
const ADRESSE = 'Hauptstraße 1, 67722 Winnweiler'
const STARTTEXT = 'Hallo, ich habe eine Frage & möchte einen Termin!'
const MODUS_VORHER = config.zeiten.mode
const FEATURE = { $id: FEATURE_ID, customerId: KUNDE, projectId: PROJEKT, siteKey: SITE_KEY, featureKey: 'zeiten', state: 'active', enabled: true }
const tage = (woche = {}) => {
const t = {}
for (let d = 1; d <= 7; d += 1) t[d] = woche[d] ? { modus: 'zeiten', fenster: [woche[d]] } : { modus: 'zu', fenster: [] }
return t
}
const eingabe = (extra = {}) => ({
land: 'RP',
plaene: [
{ id: P_STANDARD, kurz: 'standard', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'], 2: ['09:00', '18:00'], 3: ['09:00', '18:00'], 4: ['09:00', '18:00'], 5: ['09:00', '18:00'], 6: ['09:00', '12:00'] }) },
{ id: P_KUECHE, kurz: 'kueche', name: 'Küche', haupt: false, tage: tage({ 1: ['11:00', '14:00'] }) },
],
feiertage: {},
ausnahmen: [{ id: 'x_urlaub01', art: 'urlaub', von: '2026-10-12', bis: '2026-10-16', text: 'Betriebsurlaub', plan: null }],
hinweise: [
{ id: 'h_dauer001', art: 'info', text: 'Wir sind für Euch da.', knopf: null }, // ohne von/bis: ohne Ende
{ id: 'h_aktion01', art: 'aktion', text: 'Herbstaktion', knopf: { art: 'link', ziel: 'https://bäckerei.example/aktion', text: 'Zur Aktion' }, von: '2026-10-10', bis: '2026-10-20' },
{ id: 'h_spaet001', art: 'info', text: 'Weihnachtsöffnung', knopf: null, von: '2026-12-01', bis: '2026-12-24' }, // beginnt erst in 57 Tagen
{ id: 'h_vorbei01', art: 'info', text: 'Alt', knopf: null, von: '2026-09-01', bis: '2026-09-02' },
],
kontakt: {
an: true,
telefon: '0171 1234567',
adresse: ADRESSE,
whatsapp: { nummer: '+49 171 1234567', starttext: STARTTEXT, bestaetigtAt: '2026-10-01T08:00:00.000Z' },
},
...extra,
})
/** Gespeicherte, normalisierte Daten wie in Appwrite (Weg ueber speichereZeiten, wie im Betrieb). */
async function gespeicherteDaten(extra = {}) {
return (await speichereZeiten({ feature: FEATURE, eingabe: eingabe(extra), version: 0, by: 'kunde', now: JETZT })).daten
}
const TEAM = { navSelektor: '#kopf', kontaktleisteAus: false, hinweisleisteAus: true }
const FIRMA = { name: 'Bäckerei Sonnenschein' } // der Aufrufer liefert den Namen, die Adresse kommt aus den Kontaktangaben der Daten
const baue = (daten, extra = {}) => baueOeffentlicheAntwort({ daten, team: TEAM, firma: FIRMA, now: JETZT, ...extra })
/** Funktion, Kunde und Projekt des Normalfalls in den Nachbau legen. */
function legeAn({ feature = {}, kunde = { name: 'Frau Sonne', companyName: 'Bäckerei Sonnenschein' }, projekt = { projectName: 'Sonnenschein-Website' } } = {}) {
aw.seed('portalFeatures', { ...FEATURE, ...feature })
if (kunde) aw.seed('customers', { $id: KUNDE, ...kunde })
if (projekt) aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, ...projekt })
}
const listen = () => aw.listCalls('portalFeatures').length
beforeEach(async () => {
for (const collection of ['portalFeatures', 'oeffnungszeiten', 'zeitenErinnerungen', 'customers', 'websiteProjects']) {
for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id)
}
config.zeiten.mode = 'on'
resetZeitenCacheForTests()
aw.resetListCalls()
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
})
afterEach(() => {
config.zeiten.mode = MODUS_VORHER
mock.restoreAll()
})
/** Alle Schluessel und Zeichenketten einer Antwort (rekursiv). */
function durchsuche(wert, schluessel = new Set(), werte = []) {
if (Array.isArray(wert)) wert.forEach((e) => durchsuche(e, schluessel, werte))
else if (wert && typeof wert === 'object') {
for (const [k, v] of Object.entries(wert)) {
schluessel.add(k)
durchsuche(v, schluessel, werte)
}
} else if (typeof wert === 'string') werte.push(wert)
return { schluessel, werte }
}
// ---------------------------------------------------------------- baueOeffentlicheAntwort (rein)
test('Form: aktiv, stand, plaene, hinweise, kontakt, jsonld, firma, team (nach Spec 4.1)', async () => {
const a = baue(await gespeicherteDaten())
assert.deepEqual(Object.keys(a), ['aktiv', 'stand', 'plaene', 'hinweise', 'kontakt', 'jsonld', 'firma', 'team'])
assert.equal(a.aktiv, true)
assert.equal(a.stand, JETZT.toISOString())
assert.deepEqual(a.firma, { name: 'Bäckerei Sonnenschein', adresse: ADRESSE })
assert.deepEqual(a.team, TEAM)
assert.equal(JSON.parse(JSON.stringify(a)).plaene.length, 2, 'ist JSON-tauglich')
})
test('Plaene: kurz, name, haupt, woche (7 Tage), tage (60 Tage ab heute), wechsel (nur ab, offen, text, kurz)', async () => {
const a = baue(await gespeicherteDaten())
const [standard, kueche] = a.plaene
assert.deepEqual(Object.keys(standard), ['kurz', 'name', 'haupt', 'woche', 'tage', 'wechsel'])
assert.deepEqual([standard.kurz, standard.name, standard.haupt], ['standard', 'Standard', true])
assert.deepEqual([kueche.kurz, kueche.name, kueche.haupt], ['kueche', 'Küche', false])
assert.deepEqual(standard.woche.map((t) => t.tag), [1, 2, 3, 4, 5, 6, 7])
assert.deepEqual(standard.woche[0], { tag: 1, modus: 'zeiten', fenster: [['09:00', '18:00']] })
assert.deepEqual(standard.woche[6], { tag: 7, modus: 'zu', fenster: [] })
assert.deepEqual(kueche.woche[0].fenster, [['11:00', '14:00']], 'jeder Plan hat seine eigene Woche')
assert.equal(standard.tage.length, 60)
assert.equal(standard.tage[0].datum, '2026-10-05', 'heute (Berliner Tag)')
assert.equal(standard.tage[59].datum, '2026-12-03')
assert.deepEqual(Object.keys(standard.tage[0]), ['datum', 'art', 'name', 'modus', 'fenster'])
const urlaub = standard.tage.find((t) => t.datum === '2026-10-12')
assert.deepEqual([urlaub.art, urlaub.name, urlaub.modus], ['urlaub', 'Betriebsurlaub', 'zu'])
assert.equal(standard.tage.find((t) => t.datum === '2026-10-03'), undefined, 'gestern ist nicht dabei')
assert.equal(standard.tage.find((t) => t.datum === '2026-11-01')?.art, 'feiertag', 'Allerheiligen (RP)')
assert.ok(standard.wechsel.length >= 8, 'mindestens je Tag ein Wechsel')
assert.deepEqual(Object.keys(standard.wechsel[0]), ['ab', 'offen', 'text', 'kurz'])
assert.equal(typeof standard.wechsel[0].ab, 'number')
assert.equal(standard.wechsel[0].offen, true, 'Montag 12:00 ist offen')
assert.match(standard.wechsel[0].text, /geöffnet/i)
assert.ok(standard.wechsel.every((w, i, l) => i === 0 || w.ab > l[i - 1].ab), 'aufsteigend')
assert.ok(standard.wechsel.at(-1).ab < JETZT.getTime() + 8 * 24 * 3600 * 1000 + 3600 * 1000, 'hoechstens 8 Tage')
assert.notDeepEqual(kueche.wechsel, standard.wechsel, 'Wechsel je Plan')
})
test('Hinweise: gueltige und solche mit Beginn in 8 Tagen, ohne IDs; offene Enden bleiben Zahlen', async () => {
const a = baue(await gespeicherteDaten())
assert.deepEqual(a.hinweise.map((h) => h.text), ['Wir sind für Euch da.', 'Herbstaktion'], 'weder der ferne noch der vergangene')
assert.deepEqual(Object.keys(a.hinweise[0]), ['art', 'text', 'knopf', 'ab', 'bis', 'rang'])
assert.deepEqual([a.hinweise[0].ab, a.hinweise[0].bis, a.hinweise[0].rang, a.hinweise[0].knopf], [0, 8.64e15, 1, null])
const aktion = a.hinweise[1]
assert.equal(aktion.art, 'aktion')
assert.equal(aktion.rang, 2)
assert.deepEqual(aktion.knopf, { art: 'link', ziel: 'https://xn--bckerei-5wa.example/aktion', text: 'Zur Aktion' })
assert.equal(aktion.ab, Date.parse('2026-10-09T22:00:00.000Z'), 'Beginn 10.10. 00:00 Berliner Zeit (Sommerzeit)')
assert.equal(aktion.bis, Date.parse('2026-10-20T22:00:00.000Z'), 'Ende 20.10. 24:00 = 21.10. 00:00')
assert.equal(JSON.parse(JSON.stringify(a.hinweise))[0].bis, 8.64e15)
})
test('JSON-LD: Wochenzeiten und Sonderzeiten des Hauptplans samt neuem Block', async () => {
const a = baue(await gespeicherteDaten())
const { openingHoursSpecification: woche, specialOpeningHoursSpecification: sonder, neuerBlock } = a.jsonld
assert.deepEqual(woche.find((e) => e.dayOfWeek === 'Monday'), { '@type': 'OpeningHoursSpecification', dayOfWeek: 'Monday', opens: '09:00', closes: '18:00' })
assert.equal(woche.find((e) => e.dayOfWeek === 'Sunday'), undefined)
assert.deepEqual(
sonder.find((e) => e.validFrom === '2026-10-12'),
{ '@type': 'OpeningHoursSpecification', validFrom: '2026-10-12', validThrough: '2026-10-16', opens: '00:00', closes: '00:00' },
)
assert.equal(neuerBlock['@context'], 'https://schema.org', 'das https-Literal liefert der Server, das Skript hat keins')
assert.equal(neuerBlock['@type'], 'LocalBusiness')
assert.deepEqual(neuerBlock.openingHoursSpecification, woche)
assert.ok(!('name' in neuerBlock) && !('address' in neuerBlock) && !('url' in neuerBlock))
})
test('JSON-LD folgt dem Hauptplan, auch wenn er nicht der erste ist', async () => {
const daten = await gespeicherteDaten()
daten.plaene.reverse()
const a = baue(daten)
assert.deepEqual(a.plaene.map((p) => p.kurz), ['kueche', 'standard'])
assert.equal(a.jsonld.openingHoursSpecification.find((e) => e.dayOfWeek === 'Monday').closes, '18:00')
})
test('vor dem ersten Speichern: aktiv true, plaene leer, kein jsonld', () => {
for (const daten of [null, undefined, {}, { plaene: [] }, 'kaputt']) {
const a = baue(daten)
assert.equal(a.aktiv, true)
assert.deepEqual(a.plaene, [])
assert.deepEqual(a.hinweise, [])
assert.equal(a.kontakt, null)
assert.equal(a.jsonld, null, 'null statt leerer Listen: ein leeres JSON-LD wuerde vorhandenes im Browser ueberschreiben')
assert.deepEqual(a.firma, { name: 'Bäckerei Sonnenschein', adresse: null })
assert.deepEqual(a.team, TEAM, 'Team-Einstellungen gelten auch ohne Zeiten')
}
})
test('ohne Hauptplan kein JSON-LD, die Plaene bleiben', async () => {
const daten = await gespeicherteDaten()
for (const p of daten.plaene) p.haupt = false
const a = baue(daten)
assert.equal(a.jsonld, null)
assert.deepEqual(a.plaene.map((p) => p.haupt), [false, false])
})
test('Kontakt-Leiste mit Teildaten', () => {
const kontakt = (extra) => ({ an: true, telefon: '', adresse: '', whatsapp: null, ...extra })
const nur = baue({ plaene: [], kontakt: kontakt({ telefon: '491711234567' }) }).kontakt
assert.deepEqual(nur, { telefon: '491711234567', routeUrl: null, whatsappUrl: null })
const adresse = baue({ plaene: [], kontakt: kontakt({ adresse: ADRESSE }) }).kontakt
assert.deepEqual(adresse, { telefon: null, routeUrl: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(ADRESSE)}`, whatsappUrl: null })
const leer = baue({ plaene: [], kontakt: kontakt({}) }).kontakt
assert.equal(leer, null, 'an: true ohne Angaben ergibt keine Leiste')
assert.equal(baue({ plaene: [], kontakt: kontakt({ telefon: '491711234567', an: false }) }).kontakt, null, 'ausgeschaltet')
assert.equal(baue({ plaene: [], kontakt: kontakt({ telefon: ' ', adresse: ' ' }) }).kontakt, null, 'nur Leerraum')
})
test('routeUrl und whatsappUrl', () => {
const daten = {
plaene: [],
kontakt: { an: true, telefon: '491711234567', adresse: ADRESSE, whatsapp: { nummer: '491711234567', starttext: STARTTEXT, bestaetigtAt: '2026-10-01T08:00:00.000Z' } },
}
const k = baue(daten).kontakt
assert.equal(k.telefon, '491711234567')
assert.equal(k.routeUrl, 'https://www.google.com/maps/dir/?api=1&destination=' + encodeURIComponent(ADRESSE))
assert.equal(k.whatsappUrl, 'https://wa.me/491711234567?text=' + encodeURIComponent(STARTTEXT))
assert.ok(!k.whatsappUrl.includes(' ') && !k.whatsappUrl.includes('&möchte'), 'Starttext ist kodiert')
assert.deepEqual(Object.keys(k), ['telefon', 'routeUrl', 'whatsappUrl'])
})
test('WhatsApp nur mit bestaetigtAt; ohne Starttext bleibt der Text leer', () => {
const kontakt = (whatsapp, extra = {}) => ({ plaene: [], kontakt: { an: true, telefon: '', adresse: '', whatsapp, ...extra } })
assert.equal(baue(kontakt({ nummer: '491711234567', starttext: 'Hallo', bestaetigtAt: null })).kontakt, null, 'unbestaetigt und sonst nichts: keine Leiste')
assert.equal(baue(kontakt({ nummer: '491711234567', starttext: 'Hallo', bestaetigtAt: '' })).kontakt, null)
const mit = baue(kontakt({ nummer: '491711234567', starttext: 'Hallo', bestaetigtAt: null }, { telefon: '491701111111' })).kontakt
assert.deepEqual(mit, { telefon: '491701111111', routeUrl: null, whatsappUrl: null })
const ohneText = baue(kontakt({ nummer: '491711234567', starttext: '', bestaetigtAt: '2026-10-01T08:00:00.000Z' })).kontakt
assert.equal(ohneText.whatsappUrl, 'https://wa.me/491711234567?text=')
})
test('Telefonnummern gehen nur als Ziffern hinaus (kein tel:-Einschleusen aus beschaedigten Daten)', () => {
// mit gueltiger Adresse, damit die Leiste bestehen bleibt und die Telefonfelder einzeln zu sehen sind
const k = (telefon, nummer = '491711234567') =>
baue({ plaene: [], kontakt: { an: true, telefon, adresse: ADRESSE, whatsapp: { nummer, starttext: '', bestaetigtAt: '2026-10-01T08:00:00.000Z' } } }).kontakt
assert.equal(k('491711234567; rm', '49171 123').telefon, null)
assert.equal(k('491711234567; rm', '49171 123').whatsappUrl, null)
assert.equal(k('+491711234567').telefon, null, 'gespeichert werden nur Ziffern')
assert.equal(k(491711234567).telefon, null, 'Zahl statt Text')
assert.equal(k('12345').telefon, null, 'zu kurz fuer eine Nummer mit Vorwahl')
assert.equal(k('0171123456').telefon, null, 'fuehrende 0: gespeichert wird die Vorwahl 49')
assert.equal(k('4917112345678901234').telefon, null, 'laenger als 15 Ziffern')
assert.equal(k('491711234567').telefon, '491711234567')
assert.equal(k('491711234567\n').telefon, null, 'auch kein Zeilenumbruch hinten')
})
test('ein einzelnes Surrogat in Starttext oder Adresse bringt die Antwort nicht zu Fall', () => {
const k = baue({
plaene: [],
kontakt: { an: true, telefon: '', adresse: 'Hauptstr. \ud800 1', whatsapp: { nummer: '491711234567', starttext: 'Hallo \udc00!', bestaetigtAt: '2026-10-01T08:00:00.000Z' } },
}).kontakt
assert.equal(k.whatsappUrl, 'https://wa.me/491711234567?text=Hallo%20%EF%BF%BD!')
assert.equal(k.routeUrl, 'https://www.google.com/maps/dir/?api=1&destination=Hauptstr.%20%EF%BF%BD%201')
})
test('firma: Name vom Aufrufer, Adresse = kontakt.adresse (getrimmt; leer, fehlend oder kein Text = null)', () => {
const firma = (kontakt, name = 'X') => baue({ plaene: [], kontakt }, { firma: { name } }).firma
assert.deepEqual(firma({ an: true, telefon: '', adresse: ` ${ADRESSE} `, whatsapp: null }), { name: 'X', adresse: ADRESSE })
assert.deepEqual(firma({ an: false, telefon: '', adresse: ADRESSE, whatsapp: null }), { name: 'X', adresse: ADRESSE }, 'unabhaengig von der Kontakt-Leiste (Spec 4.1)')
for (const kontakt of [{ adresse: ' ' }, { adresse: 5 }, {}, null, 'x']) assert.equal(firma(kontakt).adresse, null, JSON.stringify(kontakt))
assert.deepEqual(baue(null, { firma: undefined }).firma, { name: '', adresse: null })
assert.deepEqual(baue(null, { firma: { name: 5 } }).firma, { name: '', adresse: null }, 'kein Text = leer')
assert.deepEqual(baue(null, { firma: { name: ' Büro ' } }).firma, { name: 'Büro', adresse: null })
})
test('team: Vorgabe bei fehlenden oder kaputten Angaben, keine fremden Schluessel', () => {
const vorgabe = { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false }
for (const team of [undefined, null, {}, 'x', [], { navSelektor: '', kontaktleisteAus: 'ja', hinweisleisteAus: 1 }]) {
assert.deepEqual(baue(null, { team }).team, vorgabe, JSON.stringify(team))
}
assert.deepEqual(baue(null, { team: { navSelektor: 'header', kontaktleisteAus: true, hinweisleisteAus: false, intern: 'x' } }).team, { navSelektor: 'header', kontaktleisteAus: true, hinweisleisteAus: false })
})
test('kaputte gespeicherte Daten werfen nicht', () => {
for (const daten of [
{ plaene: 'x', hinweise: {}, kontakt: 'x' },
{ plaene: [null, 5, { kurz: 'a' }, { kurz: 'b', haupt: true, tage: 'x' }, { name: 'ohne kurz' }], hinweise: [null, { text: 'x' }], kontakt: [] },
{ plaene: [{ kurz: 'a', name: 'A', haupt: true, tage: { 1: { modus: 'hm', fenster: 'x' } } }], ausnahmen: 7, feiertage: [], land: 'XX' },
]) {
const a = baue(daten)
assert.equal(a.aktiv, true)
assert.ok(Array.isArray(a.plaene) && Array.isArray(a.hinweise))
JSON.stringify(a)
}
const a = baue({ plaene: [{ kurz: 'a', name: 'A', haupt: true, tage: { 1: { modus: 'hm' } } }, { name: 'ohne kurz' }, null] })
assert.deepEqual(a.plaene.map((p) => p.kurz), ['a'], 'Plaene ohne Kurznamen koennen nicht angesprochen werden und fallen weg')
assert.deepEqual(a.plaene[0].woche[0], { tag: 1, modus: 'zu', fenster: [] }, 'unbekannter Modus = zu')
})
test('ungueltiges now ist ein Programmierfehler', () => {
for (const now of [undefined, null, 'heute', new Date('x'), 1]) assert.throws(() => baue(null, { now }), TypeError)
})
// ---------------------------------------------------------------- oeffentlicheZeitenAntwort
test('Schalter aus: aktiv false, ohne Abfrage und ohne Cache-Eintrag', async () => {
legeAn()
config.zeiten.mode = 'off'
assert.deepEqual(await oeffentlicheZeitenAntwort(SITE_KEY, JETZT), { aktiv: false })
assert.equal(listen(), 0)
assert.equal(zeitenCache.groesse(), 0)
config.zeiten.mode = 'pilot'
assert.equal((await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)).aktiv, true, 'pilot und on beantworten')
})
test('ungültiger Schlüssel: keine Abfrage, kein Cache-Eintrag', async () => {
legeAn()
for (const key of ['', 'ABCDABCDABCDABCD', 'abcdabcdabcdabc', 'abcdabcdabcdabcde', 'abcdabcdabcdabc1', 'abcdabcdabcdabc0', 'abcdabcdabcdabc!', 'abcd abcdabcdabc', undefined, null, 42, {}, [SITE_KEY], { toString: () => SITE_KEY }]) {
assert.deepEqual(await oeffentlicheZeitenAntwort(key, JETZT), { aktiv: false }, String(key))
}
assert.equal(aw.listCalls().length, 0, 'keine einzige Listenabfrage an Appwrite')
assert.equal(zeitenCache.groesse(), 0)
})
test('unbekannter, gueltig geformter Schluessel: aktiv false, wird gemerkt', async () => {
legeAn()
assert.deepEqual(await oeffentlicheZeitenAntwort('zzzzzzzzzzzzzzzz', JETZT), { aktiv: false })
assert.equal(listen(), 1)
assert.equal(zeitenCache.groesse(), 1)
assert.deepEqual(await oeffentlicheZeitenAntwort('zzzzzzzzzzzzzzzz', new Date(JETZT.getTime() + 29000)), { aktiv: false })
assert.equal(listen(), 1, 'auch das Unbekannte kommt aus dem Speicher')
})
test('Zustand: setup und active liefern dasselbe, off, ordered und disabled nur aktiv false', async () => {
legeAn()
await gespeicherteDaten()
const active = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)
assert.equal(active.aktiv, true)
for (const state of ['setup']) {
legeAn({ feature: { state, enabled: false } })
resetZeitenCacheForTests()
assert.deepEqual(await oeffentlicheZeitenAntwort(SITE_KEY, JETZT), active, state)
}
for (const state of ['off', 'ordered', 'disabled']) {
legeAn({ feature: { state, enabled: false } })
resetZeitenCacheForTests()
assert.deepEqual(await oeffentlicheZeitenAntwort(SITE_KEY, JETZT), { aktiv: false }, state)
}
})
test('nur die Funktion zeiten: der Schluessel von News oder Termine gibt aktiv false', async () => {
legeAn()
for (const featureKey of ['news', 'termine']) {
aw.seed('portalFeatures', { ...FEATURE, $id: `f_p_projekt01_${featureKey}`, featureKey, siteKey: 'nnnnnnnnnnnnnnnn' })
resetZeitenCacheForTests()
assert.deepEqual(await oeffentlicheZeitenAntwort('nnnnnnnnnnnnnnnn', JETZT), { aktiv: false }, featureKey)
await deleteDocument('portalFeatures', `f_p_projekt01_${featureKey}`)
}
})
test('Kunde gelöscht ⇒ aktiv false', async () => {
legeAn({ kunde: null })
await gespeicherteDaten()
assert.deepEqual(await oeffentlicheZeitenAntwort(SITE_KEY, JETZT), { aktiv: false })
assert.equal(zeitenCache.groesse(), 1, 'auch das wird gemerkt')
})
test('Funktion ohne Kunden-ID ⇒ aktiv false', async () => {
legeAn({ feature: { customerId: '' } })
assert.deepEqual(await oeffentlicheZeitenAntwort(SITE_KEY, JETZT), { aktiv: false })
})
test('vor dem ersten Speichern (ohne Dokument): aktiv true, plaene leer, kein jsonld, Standard-Team', async () => {
legeAn()
const a = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)
assert.equal(a.aktiv, true)
assert.deepEqual(a.plaene, [])
assert.equal(a.jsonld, null)
assert.equal(a.kontakt, null)
assert.deepEqual(a.team, { navSelektor: null, kontaktleisteAus: false, hinweisleisteAus: false })
assert.deepEqual(a.firma, { name: 'Bäckerei Sonnenschein', adresse: null })
})
test('nur Team-Einstellungen gespeichert (Dokument ohne daten): Team gilt, plaene leer', async () => {
legeAn()
await speichereTeam({ feature: FEATURE, eingabe: { navSelektor: '#kopf', kontaktleisteAus: true, hinweisleisteAus: false }, teamVersion: 0, now: JETZT })
const a = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)
assert.deepEqual(a.plaene, [])
assert.deepEqual(a.team, { navSelektor: '#kopf', kontaktleisteAus: true, hinweisleisteAus: false })
})
test('unlesbare daten gelten wie vor dem ersten Speichern', async () => {
legeAn()
aw.seed('oeffnungszeiten', { $id: FEATURE_ID, customerId: KUNDE, projectId: PROJEKT, featureId: FEATURE_ID, daten: '{kaputt', version: 3, teamVersion: 0 })
const a = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)
assert.equal(a.aktiv, true)
assert.deepEqual(a.plaene, [])
})
test('volle Antwort aus gespeicherten Daten: Pläne, Hinweise, Kontakt, firma.adresse und Team', async () => {
legeAn()
await gespeicherteDaten()
await speichereTeam({ feature: FEATURE, eingabe: TEAM, teamVersion: 0, now: JETZT })
const a = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)
assert.deepEqual(Object.keys(a), ['aktiv', 'stand', 'plaene', 'hinweise', 'kontakt', 'jsonld', 'firma', 'team'])
assert.equal(a.stand, JETZT.toISOString())
assert.deepEqual(a.plaene.map((p) => p.kurz), ['standard', 'kueche'])
assert.equal(a.hinweise.length, 2)
assert.deepEqual(a.kontakt, {
telefon: '491711234567',
routeUrl: 'https://www.google.com/maps/dir/?api=1&destination=' + encodeURIComponent(ADRESSE),
whatsappUrl: 'https://wa.me/491711234567?text=' + encodeURIComponent(STARTTEXT),
})
assert.deepEqual(a.firma, { name: 'Bäckerei Sonnenschein', adresse: ADRESSE })
assert.deepEqual(a.team, TEAM)
assert.equal(a.jsonld.openingHoursSpecification.length, 6)
})
test('firma ohne Firmenname fällt auf den Kundennamen, dann auf den Projektnamen, sonst leer', async () => {
const name = async (kunde, projekt) => {
for (const c of ['portalFeatures', 'customers', 'websiteProjects']) for (const id of [...aw.docs(c).keys()]) await deleteDocument(c, id)
legeAn({ kunde, projekt })
resetZeitenCacheForTests()
const a = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)
assert.equal(a.aktiv, true)
return a.firma.name
}
assert.equal(await name({ name: 'Frau Sonne', companyName: 'Bäckerei Sonnenschein' }, { projectName: 'P' }), 'Bäckerei Sonnenschein')
assert.equal(await name({ name: 'Frau Sonne', companyName: '' }, { projectName: 'P' }), 'Frau Sonne')
assert.equal(await name({ name: 'Frau Sonne' }, { projectName: 'P' }), 'Frau Sonne')
assert.equal(await name({ name: ' ', companyName: ' ' }, { projectName: 'Sonnenschein-Website' }), 'Sonnenschein-Website')
assert.equal(await name({ email: 'x@y.de' }, { projectName: 'Sonnenschein-Website' }), 'Sonnenschein-Website')
assert.equal(await name({ email: 'x@y.de' }, null), '', 'weder Kundenname noch Projekt: leer, nie ein Fehler')
assert.equal(await name({ email: 'x@y.de' }, { projectName: '' }), '')
})
test('zweiter Abruf innerhalb von 30 s aus dem Cache', async () => {
legeAn()
await gespeicherteDaten()
const erste = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)
assert.equal(listen(), 1)
// Aenderungen in Appwrite bleiben bis zum Ablauf unsichtbar
aw.seed('portalFeatures', { ...FEATURE, state: 'disabled', enabled: false })
const zweite = await oeffentlicheZeitenAntwort(SITE_KEY, new Date(JETZT.getTime() + 29999))
assert.equal(listen(), 1, 'keine neue Abfrage')
assert.deepEqual(zweite, erste)
assert.equal(zweite.stand, JETZT.toISOString(), 'stand ist der Zeitpunkt des Bauens')
const dritte = await oeffentlicheZeitenAntwort(SITE_KEY, new Date(JETZT.getTime() + 30000))
assert.equal(listen(), 2, 'nach 30 s wird neu gebaut')
assert.deepEqual(dritte, { aktiv: false })
})
test('der Cache gilt je Schluessel', async () => {
legeAn()
aw.seed('portalFeatures', { ...FEATURE, $id: 'f_p_projekt02_zeiten', projectId: 'p_projekt02', siteKey: 'wxyzwxyzwxyzwxyz' })
assert.equal((await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)).aktiv, true)
assert.equal((await oeffentlicheZeitenAntwort('wxyzwxyzwxyzwxyz', JETZT)).aktiv, true)
assert.equal(listen(), 2)
assert.equal(zeitenCache.groesse(), 2)
})
test('Speichern verwirft die Antwort: die naechste Anfrage sieht die neuen Zeiten sofort', async () => {
legeAn()
const daten = await gespeicherteDaten()
const vorher = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)
assert.equal(vorher.plaene[0].name, 'Standard')
const neu = { ...daten, plaene: daten.plaene.map((p) => (p.haupt ? { ...p, name: 'Hauptzeiten' } : p)) }
await speichereZeiten({ feature: FEATURE, eingabe: neu, version: 1, by: 'kunde', now: JETZT })
const nachher = await oeffentlicheZeitenAntwort(SITE_KEY, new Date(JETZT.getTime() + 1000))
assert.equal(nachher.plaene[0].name, 'Hauptzeiten')
})
test('keine internen IDs in der Antwort', async () => {
legeAn()
await gespeicherteDaten()
await speichereTeam({ feature: FEATURE, eingabe: TEAM, teamVersion: 0, now: JETZT })
const a = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)
const { schluessel, werte } = durchsuche(a)
for (const verboten of ['$id', '$collectionId', '$databaseId', 'customerId', 'projectId', 'featureId', 'siteKey', 'id', 'version', 'teamVersion', 'updatedBy', 'ersatzHash', 'email']) {
assert.ok(!schluessel.has(verboten), `Schluessel ${verboten}`)
}
const text = JSON.stringify(a)
for (const intern of [FEATURE_ID, KUNDE, PROJEKT, SITE_KEY, P_STANDARD, P_KUECHE, 'x_urlaub01', 'h_aktion01', 'h_dauer001', 'Frau Sonne']) {
assert.ok(!text.includes(intern), `Wert ${intern}`)
}
assert.ok(werte.length > 20)
})
test('Appwrite-Fehler gehen an den Aufrufer, ohne dass etwas gemerkt wird', async () => {
legeAn()
await gespeicherteDaten()
aw.failNext({ collection: 'portalFeatures', status: 500, message: 'kaputt' })
await assert.rejects(() => oeffentlicheZeitenAntwort(SITE_KEY, JETZT), { status: 500 })
assert.equal(zeitenCache.groesse(), 0)
aw.failNext({ collection: 'customers', status: 500, message: 'kaputt' })
await assert.rejects(() => oeffentlicheZeitenAntwort(SITE_KEY, JETZT), { status: 500 })
aw.failNext({ collection: 'oeffnungszeiten', status: 500, message: 'kaputt' })
await assert.rejects(() => oeffentlicheZeitenAntwort(SITE_KEY, JETZT), { status: 500 })
assert.equal(zeitenCache.groesse(), 0)
assert.equal((await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)).aktiv, true, 'danach geht es wieder')
})
test('ohne now rechnet die Funktion mit der aktuellen Zeit', async () => {
legeAn()
await gespeicherteDaten()
const vorher = Date.now()
const a = await oeffentlicheZeitenAntwort(SITE_KEY)
assert.ok(Date.parse(a.stand) >= vorher && Date.parse(a.stand) <= Date.now())
})
test('die gemerkte Antwort ist gegen Veraenderung geschuetzt', async () => {
legeAn()
await gespeicherteDaten()
const a = await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)
assert.throws(() => {
a.plaene[0].name = 'gehackt'
}, TypeError)
assert.throws(() => {
a.firma.name = 'gehackt'
}, TypeError)
assert.equal((await oeffentlicheZeitenAntwort(SITE_KEY, JETZT)).plaene[0].name, 'Standard')
})
+80
View File
@@ -0,0 +1,80 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
// findActiveBySiteKey(siteKey, featureKey, { states }): Vorgabe nur 'active' (News und Terminplaner unveraendert); die
// Oeffnungszeiten fragen mit states ['setup', 'active'], damit das Team die Leisten schon vor dem Schliessen des Tickets pruefen kann.
const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') }, defaultLimit: 25 })
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { findActiveBySiteKey } = await import('../server/services/siteFeatures.js')
const STATES = ['off', 'ordered', 'setup', 'active', 'disabled']
const KEYS = { off: 'aaaaaaaaaaaaaaaa', ordered: 'bbbbbbbbbbbbbbbb', setup: 'cccccccccccccccc', active: 'dddddddddddddddd', disabled: 'eeeeeeeeeeeeeeee' }
beforeEach(() => {
for (const state of STATES) {
aw.seed('portalFeatures', {
$id: `f_${state}_zeiten`,
customerId: 'c_kunde01',
projectId: `p_${state}`,
featureKey: 'zeiten',
state,
enabled: state === 'active',
siteKey: KEYS[state],
})
}
aw.seed('portalFeatures', { $id: 'f_news', customerId: 'c_kunde01', projectId: 'p_news', featureKey: 'news', state: 'setup', enabled: false, siteKey: 'ffffffffffffffff' })
aw.resetListCalls()
})
test('findActiveBySiteKey: Vorgabe nur active, mit states auch setup', async () => {
assert.equal((await findActiveBySiteKey(KEYS.active, 'zeiten'))?.$id, 'f_active_zeiten')
assert.equal(await findActiveBySiteKey(KEYS.setup, 'zeiten'), null, 'ohne states zaehlt setup nicht')
const mitSetup = { states: ['setup', 'active'] }
assert.equal((await findActiveBySiteKey(KEYS.setup, 'zeiten', mitSetup))?.$id, 'f_setup_zeiten')
assert.equal((await findActiveBySiteKey(KEYS.active, 'zeiten', mitSetup))?.$id, 'f_active_zeiten')
for (const state of ['off', 'ordered', 'disabled']) {
assert.equal(await findActiveBySiteKey(KEYS[state], 'zeiten', mitSetup), null, state)
}
})
test('states ersetzt die Vorgabe vollstaendig und die Liste geht als eine equal-Abfrage an Appwrite', async () => {
assert.equal((await findActiveBySiteKey(KEYS.disabled, 'zeiten', { states: ['disabled'] }))?.$id, 'f_disabled_zeiten')
assert.equal(await findActiveBySiteKey(KEYS.active, 'zeiten', { states: ['disabled'] }), null)
aw.resetListCalls()
await findActiveBySiteKey(KEYS.setup, 'zeiten', { states: ['setup', 'active'] })
const [aufruf] = aw.listCalls('portalFeatures')
const zustand = aufruf.queries.filter((q) => q.method === 'equal' && q.attribute === 'state')
assert.equal(zustand.length, 1)
assert.deepEqual(zustand[0].values, ['setup', 'active'])
})
test('ohne Optionen und mit leerem Objekt bleibt es bei active (bestehende Aufrufer unveraendert)', async () => {
assert.equal(await findActiveBySiteKey(KEYS.setup, 'zeiten', {}), null)
assert.equal((await findActiveBySiteKey(KEYS.active, 'zeiten', {}))?.$id, 'f_active_zeiten')
aw.resetListCalls()
await findActiveBySiteKey(KEYS.active, 'zeiten')
const [aufruf] = aw.listCalls('portalFeatures')
assert.deepEqual(aufruf.queries.find((q) => q.attribute === 'state').values, ['active'])
})
test('die Funktion bleibt Teil der Abfrage: ein News-Schluessel ist kein Zeiten-Schluessel, auch mit setup', async () => {
assert.equal(await findActiveBySiteKey('ffffffffffffffff', 'zeiten', { states: ['setup', 'active'] }), null)
assert.equal((await findActiveBySiteKey('ffffffffffffffff', 'news', { states: ['setup'] }))?.$id, 'f_news')
})
test('ungueltiger Schluessel, unbekannte Funktion oder leere Zustandsliste: null ohne Abfrage', async () => {
const mitSetup = { states: ['setup', 'active'] }
for (const key of ['', 'KURZ', 'dddddddddddddddd1', 'ddddddddddddddd1', 'ddddddddddddddd0', 'DDDDDDDDDDDDDDDD', undefined, null, 5, {}, ['dddddddddddddddd']]) {
assert.equal(await findActiveBySiteKey(key, 'zeiten', mitSetup), null, String(key))
}
assert.equal(await findActiveBySiteKey(KEYS.active, 'kalender', mitSetup), null)
assert.equal(await findActiveBySiteKey(KEYS.active, 'zeiten', { states: [] }), null, 'eine leere Liste ist in Appwrite ungueltig und passt auf nichts')
assert.equal(aw.listCalls('portalFeatures').length, 0)
})