diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index ab8dc16..338ba49 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -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 diff --git a/server/services/zeiten/oeffentlich.js b/server/services/zeiten/oeffentlich.js new file mode 100644 index 0000000..3f1fc2d --- /dev/null +++ b/server/services/zeiten/oeffentlich.js @@ -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 +} diff --git a/test/zeitenOeffentlich.test.js b/test/zeitenOeffentlich.test.js new file mode 100644 index 0000000..8c250df --- /dev/null +++ b/test/zeitenOeffentlich.test.js @@ -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') +}) diff --git a/test/zeitenSiteKey.test.js b/test/zeitenSiteKey.test.js new file mode 100644 index 0000000..b6035da --- /dev/null +++ b/test/zeitenSiteKey.test.js @@ -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) +})