From c584d220c8af6901e9f69ca6df21866cb436389e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:48:06 +0000 Subject: [PATCH] Funktionen: Register config.features mit News- und Termine-Eintrag config.features (news, termine) als reine Daten in config.js, config.ustHinweis, config.funktionen als Alias mit Gettern auf features.news. Neu featureRegistry.js (featureConfig, featureKeys, featureHaken mit Ueberschreib-Regel, oeffentlicheAnsicht); FEATURE_KEYS, FEATURE_LABELS und DISABLED_MESSAGE in siteFeatures.js kommen aus dem Register, Werte unveraendert. Co-Authored-By: Claude Sonnet 5.5 --- server/config.js | 141 +++++++++++++- server/services/featureRegistry.js | 72 +++++++ server/services/siteFeatures.js | 18 +- test/featureRegistry.test.js | 294 +++++++++++++++++++++++++++++ 4 files changed, 512 insertions(+), 13 deletions(-) create mode 100644 server/services/featureRegistry.js create mode 100644 test/featureRegistry.test.js diff --git a/server/config.js b/server/config.js index 2e6096e..2a57b28 100644 --- a/server/config.js +++ b/server/config.js @@ -130,6 +130,131 @@ export function resolveEnableBankingApiBase(value) { return ENABLE_BANKING_API_BASE } +// Pflichthinweis zum Preis (Kleinunternehmer); die Funktionen-Register-Texte und die Preisangaben nutzen ihn +const ustHinweis = 'ohne Umsatzsteuer gemäß § 19 UStG' + +const ticketProjektName = (project) => project?.projectName || project?.subdomain || '' + +/** + * Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner): Preis, Zustimmungen, Rechtsunterlagen, + * Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig, + * featureHaken, oeffentlicheAnsicht) stehen in server/services/featureRegistry.js. Haken (verfuegbar, + * bestellpruefung, unterlagenBereit, belehrung, freischaltbar) gehoeren nicht hierher: wer sie in Tests auf dem + * Eintrag setzt, ueberschreibt den Standard aus featureRegistry.js. + * ticket.topic(project) liefert den Ticket-Betreff, ticket.details(ctx) den Text des Einrichtungs-Tickets + * (ctx = { feature, doc, project, customer }: Register-Eintrag, portalFeatures-, websiteProjects- und Kunden-Dokument). + */ +const features = { + news: { + key: 'news', + product: 'newsSetup', + label: { name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' }, + priceLabel: '30 €', + setupEstimate: 'meist innerhalb von 1–2 Wochen', + // Wortlaut der Zustimmung; wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 4 BGB) + consent: { + version: '2026-09-30', + knopf: 'Zahlungspflichtig bestellen · 30 €', + items: [ + { + id: 'beginn', + haken: true, + text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).', + }, + ], + }, + leistungsbeschreibung: { version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' }, + avv: null, + widerruf: null, + returnHash: 'news', + returnProjekt: false, + ticket: { + topic: (project) => `News-Pinnwand einbauen – ${ticketProjektName(project)}`.trim(), + details: async (ctx) => { + const { feature, doc, project } = ctx || {} + const siteKey = doc?.siteKey || '' + const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || '' + return [ + `Funktion: ${feature?.key || 'news'}`, + `Projekt: ${ticketProjektName(project)}`, + `Domain: ${domain}`, + `siteKey: ${siteKey}`, + `Einbau:
+ https://project.webklar.com/embed/news.js`, + 'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)', + ].join('\n') + }, + }, + mails: { + funktion: 'News-Pinnwand', + formen: { dein: 'Deine News-Pinnwand', nom: 'die News-Pinnwand', akk: 'die News-Pinnwand', gen: 'der News-Pinnwand', pron: 'sie' }, + knopf: 'Beitrag schreiben', + danach: 'Danach kannst Du Beiträge im Portal schreiben.', + bereit: 'Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.', + preisHinweis: 'brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo', + anleitung: false, + }, + requiresWartungsAbo: false, + ticketFreischaltung: true, + disabledMessage: + 'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.', + orderedConflictMessage: 'Die News-Pinnwand ist für dieses Projekt schon bestellt.', + }, + termine: { + key: 'termine', + product: 'termineSetup', + label: { name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' }, + priceLabel: '149 €', + setupEstimate: 'meist innerhalb von 1–2 Wochen', + // Platzhalter bis Block F (Rechtsrunde): Struktur und IDs stehen, Texte und Fassung ersetzt Block F + consent: { + version: 'ENTWURF-2026-10-03', + knopf: 'Zahlungspflichtig bestellen · 149 €', + items: [ + { id: 'unterlagen', haken: true, text: 'ENTWURF (Block F): Zustimmung zu Leistungsbeschreibung und Auftragsverarbeitungsvertrag (AVV) Terminplaner.' }, + { id: 'gesundheit', haken: true, text: 'ENTWURF (Block F): Bestätigung, dass der Terminplaner nicht im Gesundheitsbereich eingesetzt wird.' }, + { + id: 'beginn', + haken: true, + text: 'ENTWURF (Block F): Ausdrückliches Verlangen, dass vor Ablauf der Widerrufsfrist mit dem Einbau begonnen wird, mit Hinweis auf das Erlöschen des Widerrufsrechts und den Wertersatz.', + }, + { + id: 'information', + haken: true, + text: 'ENTWURF (Block F): Pflichtinformationen zu Leistung, Gesamtpreis, Ende mit dem Wartungs-Abo und Einrichtungsdauer.', + }, + ], + }, + leistungsbeschreibung: { version: 'ENTWURF', url: '' }, + avv: { version: 'ENTWURF', url: '' }, + widerruf: null, + returnHash: 'termine', + returnProjekt: true, + ticket: { + topic: (project) => `Terminplaner einbauen – ${ticketProjektName(project)}`, + // Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch + details: async (ctx) => { + const { termineTicketDetails } = await import('./services/termine/ticketDetails.js') + return termineTicketDetails(ctx) + }, + }, + mails: { + funktion: 'Terminplaner', + formen: { dein: 'Dein Terminplaner', nom: 'der Terminplaner', akk: 'den Terminplaner', gen: 'des Terminplaners', pron: 'ihn' }, + knopf: 'Termine öffnen', + // Platzhalter: den Wortlaut schreibt der Inhaber in seiner Mailrunde + danach: 'ENTWURF (Mailrunde): Danach kannst Du im Portal Termine und Einstellungen verwalten.', + bereit: 'ENTWURF (Mailrunde): Du kannst ab sofort Termine online buchen lassen.', + preisHinweis: `einmalig, ${ustHinweis}; der Terminplaner läuft, solange Dein Wartungs-Abo läuft`, + anleitung: true, + }, + requiresWartungsAbo: true, + ticketFreischaltung: true, + disabledMessage: 'Der Terminplaner ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.', + orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.', + unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.', + }, +} + export const config = { port: Number(process.env.PORT) || 3000, sessionSecret: process.env.SESSION_SECRET || '', @@ -188,12 +313,18 @@ export const config = { products, mailStorage, newsSetup, - // Funktionen-Tab (News-Pinnwand): Zeiteinschaetzung auf der Buchungsseite und Wortlaut der Zustimmung, - // der mit Zeitstempel am Feature-Dokument gespeichert wird (Widerruf, § 356 Abs. 4 BGB) + ustHinweis, + // Register der kaufbaren Funktionen (siehe oben) + features, + // Funktionen-Tab (News-Pinnwand): Alias auf den News-Eintrag des Registers, Zeiteinschaetzung auf der + // Buchungsseite und Wortlaut der Zustimmung (die Getter folgen config.features.news zur Laufzeit) funktionen: { - setupEstimate: 'meist innerhalb von 1–2 Wochen', - consentText: - 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).', + get setupEstimate() { + return config.features.news.setupEstimate + }, + get consentText() { + return config.features.news.consent.items[0].text + }, }, // Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an. diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js new file mode 100644 index 0000000..aae757a --- /dev/null +++ b/server/services/featureRegistry.js @@ -0,0 +1,72 @@ +import { config } from '../config.js' + +// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner; Daten in server/config.js). +// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch: Standard-Haken +// laden ihre Dienste per dynamischem import() innerhalb der Funktion (sonst entstuenden Importzyklen). + +/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen. */ +const HAKEN_NAMEN = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar'] + +/** + * Standard-Haken je Funktion (Funktionen, nicht Daten). Leer in Task B1; es fuellen sie B6 (verfuegbar, + * bestellpruefung, unterlagenBereit), B7 (belehrung) und B11 (freischaltbar). News hat keine Standard-Haken. + */ +const STANDARD_HAKEN = { + news: {}, + termine: {}, +} + +/** Registereintrag oder null; nur eigene Schluessel des Registers ('analyse', '__proto__', 'toString' => null). */ +export function featureConfig(key) { + if (typeof key !== 'string' || !Object.hasOwn(config.features, key)) return null + return config.features[key] +} + +/** Schluessel der Funktionen im Register, in Registerreihenfolge. */ +export function featureKeys() { + return Object.keys(config.features) +} + +/** + * Haken einer Funktion: { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar? }. + * Wer config.features.. als Funktion setzt (Tests), ueberschreibt den Standard. + */ +export function featureHaken(key) { + const eintrag = featureConfig(key) + if (!eintrag) return {} + const standard = Object.hasOwn(STANDARD_HAKEN, key) ? STANDARD_HAKEN[key] : {} + const haken = {} + for (const name of HAKEN_NAMEN) { + const fn = typeof eintrag[name] === 'function' ? eintrag[name] : standard[name] + if (typeof fn === 'function') haken[name] = fn + } + return haken +} + +const fassung = (f) => (f ? { version: f.version, url: f.url ?? '' } : null) + +/** + * Sicht fuer den Browser (Bestellseite): JSON-sicher, ohne Funktionen und ohne ticket/mails, eine Kopie + * (Aenderungen daran aendern das Register nicht). Unbekannter Schluessel => null. + */ +export function oeffentlicheAnsicht(key) { + const e = featureConfig(key) + if (!e) return null + return { + key: e.key, + name: e.label.name, + kurz: e.label.kurz, + priceLabel: e.priceLabel, + setupEstimate: e.setupEstimate, + consent: { + version: e.consent.version, + knopf: e.consent.knopf, + items: e.consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) })), + }, + leistungsbeschreibung: fassung(e.leistungsbeschreibung), + avv: fassung(e.avv), + widerruf: fassung(e.widerruf), + returnHash: e.returnHash, + requiresWartungsAbo: e.requiresWartungsAbo === true, + } +} diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index e315591..9f0d2ae 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -4,20 +4,22 @@ import { createDocument, getDocument, listDocuments, updateDocument, Query } fro import { withLocks } from './keyedLock.js' import { sendAdminNotice, sendCustomerMail } from './mailer.js' import { getStripe, retrieveCheckoutSession } from './stripeBilling.js' +import { featureConfig, featureKeys } from './featureRegistry.js' import { createFeatureTicket } from './ticketAdmin.js' -export const FEATURE_KEYS = ['news', 'termine'] -/** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion. */ -export const FEATURE_LABELS = { - news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' }, - termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' }, -} +export const FEATURE_KEYS = featureKeys() +/** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion; aus dem Register. */ +export const FEATURE_LABELS = Object.fromEntries( + FEATURE_KEYS.map((key) => { + const { name, keine } = featureConfig(key).label + return [key, { name, keine }] + }) +) export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled'] /** Warum eine Funktion abgeschaltet ist (portalFeatures.disabledReason); 'team' ist der Vorgabewert (Abschalten im WOMS, Aufbewahrungsfrist). */ export const DISABLED_REASONS = ['team', 'abo_ende', 'abo_ende_vor_einrichtung', 'kunde_geloescht'] -export const DISABLED_MESSAGE = - 'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.' +export const DISABLED_MESSAGE = featureConfig('news').disabledMessage const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567' diff --git a/test/featureRegistry.test.js b/test/featureRegistry.test.js new file mode 100644 index 0000000..b820e0d --- /dev/null +++ b/test/featureRegistry.test.js @@ -0,0 +1,294 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +// Register config.features (News + Terminplaner) und featureRegistry.js. Dienste (siteFeatures, ticketAdmin) +// brauchen die Test-Umgebung vor dem ersten Import von server/config.js. +const aw = await startAppwriteMock() +const st = await startFakeStripe() +after(() => Promise.all([aw.close(), st.close()])) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const reg = await import('../server/services/featureRegistry.js') +const sf = await import('../server/services/siteFeatures.js') +const { createFeatureTicket } = await import('../server/services/ticketAdmin.js') + +// Wortlaut-Pin des News-Satzes und der Fassung (der einzige Literal-Pin auf Texte, die Block F2 ändert) +const NEWS_SATZ = + 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).' +const NEWS_FASSUNG = '2026-09-30' + +const SITE_KEY = 'abcdefghijklmnop' + +// ---------------------------------------------------------------------------------- News (Festwerte) --- +test('News-Eintrag entspricht den bisherigen Festwerten', async () => { + const news = reg.featureConfig('news') + assert.equal(news.key, 'news') + assert.equal(news.product, 'newsSetup') + assert.deepEqual(news.label, { name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' }) + assert.equal(news.priceLabel, '30 €') + assert.equal(news.setupEstimate, 'meist innerhalb von 1–2 Wochen') + assert.equal(news.consent.version, NEWS_FASSUNG) + assert.equal(news.consent.knopf, 'Zahlungspflichtig bestellen · 30 €') + assert.deepEqual(news.consent.items, [{ id: 'beginn', haken: true, text: NEWS_SATZ }]) + assert.deepEqual(news.leistungsbeschreibung, { version: NEWS_FASSUNG, url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' }) + assert.equal(news.avv, null) + assert.equal(news.widerruf, null) + assert.equal(news.returnHash, 'news') + assert.equal(news.returnProjekt, false) + assert.equal(news.requiresWartungsAbo, false) + assert.equal(news.ticketFreischaltung, true) + assert.equal( + news.disabledMessage, + 'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.' + ) + assert.equal(news.disabledMessage, sf.DISABLED_MESSAGE) + assert.equal(news.orderedConflictMessage, 'Die News-Pinnwand ist für dieses Projekt schon bestellt.') + assert.equal(news.unavailableMessage, undefined) + + assert.equal(news.ticket.topic({ projectName: 'Bäckerei' }), 'News-Pinnwand einbauen – Bäckerei') + assert.equal(news.ticket.topic({ subdomain: 'cafe' }), 'News-Pinnwand einbauen – cafe') + assert.equal(news.ticket.topic(null), 'News-Pinnwand einbauen –') + + assert.deepEqual(news.mails, { + funktion: 'News-Pinnwand', + formen: { dein: 'Deine News-Pinnwand', nom: 'die News-Pinnwand', akk: 'die News-Pinnwand', gen: 'der News-Pinnwand', pron: 'sie' }, + knopf: 'Beitrag schreiben', + danach: 'Danach kannst Du Beiträge im Portal schreiben.', + bereit: 'Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.', + preisHinweis: news.mails.preisHinweis, + anleitung: false, + }) + assert.match(news.mails.preisHinweis, /einmalig/) +}) + +test('News-Ticketdetails sind die sechs Zeilen des bisherigen Standards', async () => { + const news = reg.featureConfig('news') + const project = { $id: 'p1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de', previewUrl: 'https://p.example', subdomain: 'baeckerei' } + const ctx = { feature: news, doc: { siteKey: SITE_KEY, featureKey: 'news' }, project, customer: { name: 'Kunde' } } + const expected = [ + 'Funktion: news', + 'Projekt: Bäckerei', + 'Domain: baeckerei.de', + `siteKey: ${SITE_KEY}`, + `Einbau:
+ https://project.webklar.com/embed/news.js`, + 'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)', + ].join('\n') + assert.equal(await news.ticket.details(ctx), expected) + + // Domain: liveDomain || previewUrl || subdomain; Projektname: projectName || subdomain + const ohneLive = await news.ticket.details({ ...ctx, project: { projectName: 'X', previewUrl: 'https://p.example', subdomain: 's' } }) + assert.match(ohneLive, /^Domain: https:\/\/p\.example$/m) + const nurSub = await news.ticket.details({ ...ctx, project: { subdomain: 's' } }) + assert.match(nurSub, /^Projekt: s$/m) + assert.match(nurSub, /^Domain: s$/m) + + // Parität zu createFeatureTicket (Standardtexte): dasselbe Dokument, Wort für Wort + aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin' }) + const { ticketId } = await createFeatureTicket({ customerId: 'c1', project, featureKey: 'news', siteKey: SITE_KEY }) + const ticket = aw.docs('workorders').get(ticketId) + assert.equal(ticket.details, expected) + assert.equal(ticket.topic, news.ticket.topic(project)) +}) + +// ---------------------------------------------------------------------------------- Alias funktionen --- +test('Alias bleibt: config.funktionen folgt config.features.news zur Laufzeit', () => { + const news = config.features.news + assert.equal(config.funktionen.setupEstimate, news.setupEstimate) + assert.equal(config.funktionen.consentText, news.consent.items[0].text) + assert.equal(config.funktionen.consentText, NEWS_SATZ) + + const vorherEstimate = news.setupEstimate + const vorherText = news.consent.items[0].text + try { + news.setupEstimate = 'in 3 Tagen' + news.consent.items[0].text = 'Neuer Satz' + assert.equal(config.funktionen.setupEstimate, 'in 3 Tagen') + assert.equal(config.funktionen.consentText, 'Neuer Satz') + } finally { + news.setupEstimate = vorherEstimate + news.consent.items[0].text = vorherText + } + assert.equal(config.funktionen.setupEstimate, vorherEstimate) + assert.equal(config.funktionen.consentText, vorherText) +}) + +// ---------------------------------------------------------------------------------- siteFeatures --- +test('FEATURE_LABELS und FEATURE_KEYS unverändert, aus dem Register abgeleitet', () => { + assert.deepEqual(sf.FEATURE_LABELS, { + news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' }, + termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' }, + }) + assert.deepEqual(sf.FEATURE_KEYS, ['news', 'termine']) + assert.deepEqual(sf.FEATURE_KEYS, reg.featureKeys()) + assert.deepEqual(reg.featureKeys(), ['news', 'termine']) +}) + +// ---------------------------------------------------------------------------------- Schlüsselschutz --- +test('Schlüsselschutz: nur eigene Schlüssel des Registers', () => { + for (const key of ['analyse', '__proto__', 'constructor', 'toString', 'hasOwnProperty', undefined, null, '', 'News']) { + assert.equal(reg.featureConfig(key), null, `featureConfig(${String(key)})`) + assert.equal(reg.oeffentlicheAnsicht(key), null, `oeffentlicheAnsicht(${String(key)})`) + assert.deepEqual(reg.featureHaken(key), {}, `featureHaken(${String(key)})`) + } + assert.equal(reg.featureConfig('news'), config.features.news) + assert.equal(reg.featureConfig('termine'), config.features.termine) +}) + +// ---------------------------------------------------------------------------------- Haken --- +test('Haken überschreibbar: config.features.. gewinnt gegen den Standard', () => { + const termine = config.features.termine + const vorherTermine = reg.featureHaken('termine') + const vorherNews = reg.featureHaken('news') + const fn = () => [] + try { + termine.bestellpruefung = fn + const haken = reg.featureHaken('termine') + assert.equal(haken.bestellpruefung, fn) + // die anderen Haken bleiben beim Standard + for (const name of ['verfuegbar', 'unterlagenBereit', 'belehrung', 'freischaltbar']) assert.equal(haken[name], vorherTermine[name]) + } finally { + delete termine.bestellpruefung + } + assert.deepEqual(reg.featureHaken('termine'), vorherTermine) + + // jeder der fünf Haken lässt sich setzen; Nicht-Funktionen und fremde Namen zählen nicht + const namen = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar'] + const news = config.features.news + try { + for (const name of namen) news[name] = () => name + news.unbekannt = () => 'x' + const haken = reg.featureHaken('news') + assert.deepEqual(Object.keys(haken).sort(), [...namen].sort()) + for (const name of namen) assert.equal(haken[name](), name) + news.verfuegbar = 'kein Haken' + assert.equal(reg.featureHaken('news').verfuegbar, vorherNews.verfuegbar) + } finally { + for (const name of [...namen, 'unbekannt']) delete news[name] + } + assert.deepEqual(reg.featureHaken('news'), vorherNews) +}) + +// ---------------------------------------------------------------------------------- öffentliche Ansicht --- +test('Ansicht ohne Funktionen: JSON-sicher, ohne ticket und mails', () => { + for (const key of reg.featureKeys()) { + const ansicht = reg.oeffentlicheAnsicht(key) + assert.deepEqual(JSON.parse(JSON.stringify(ansicht)), ansicht) + assert.deepEqual(Object.keys(ansicht).sort(), [ + 'avv', + 'consent', + 'key', + 'kurz', + 'leistungsbeschreibung', + 'name', + 'priceLabel', + 'requiresWartungsAbo', + 'returnHash', + 'setupEstimate', + 'widerruf', + ]) + assert.equal(ansicht.ticket, undefined) + assert.equal(ansicht.mails, undefined) + assert.deepEqual(Object.keys(ansicht.consent).sort(), ['items', 'knopf', 'version']) + for (const item of ansicht.consent.items) assert.deepEqual(Object.keys(item).sort(), ['haken', 'id', 'text']) + } + const news = reg.oeffentlicheAnsicht('news') + assert.equal(news.name, 'News-Pinnwand') + assert.equal(news.kurz, 'News') + assert.equal(news.priceLabel, '30 €') + assert.equal(news.consent.knopf, 'Zahlungspflichtig bestellen · 30 €') + assert.deepEqual(news.consent.items, [{ id: 'beginn', text: NEWS_SATZ, haken: true }]) + assert.equal(news.returnHash, 'news') + assert.equal(news.requiresWartungsAbo, false) + assert.equal(news.avv, null) + + // eine Kopie: Änderungen an der Ansicht ändern das Register nicht + news.consent.items[0].text = 'verändert' + news.leistungsbeschreibung.version = 'verändert' + assert.equal(config.features.news.consent.items[0].text, NEWS_SATZ) + assert.equal(config.features.news.leistungsbeschreibung.version, NEWS_FASSUNG) +}) + +// ---------------------------------------------------------------------------------- Termine --- +test('Termine: Preis und Struktur', async () => { + const t = reg.featureConfig('termine') + assert.equal(t.key, 'termine') + assert.equal(t.product, 'termineSetup') + assert.deepEqual(t.label, { name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' }) + assert.equal(t.priceLabel, '149 €') + assert.equal(t.setupEstimate, 'meist innerhalb von 1–2 Wochen') + assert.equal(t.consent.knopf, 'Zahlungspflichtig bestellen · 149 €') + assert.deepEqual( + t.consent.items.map((i) => i.id), + ['unterlagen', 'gesundheit', 'beginn', 'information'] + ) + assert.deepEqual( + t.consent.items.map((i) => i.haken), + [true, true, true, true] + ) + for (const item of t.consent.items) { + assert.equal(typeof item.text, 'string') + assert.ok(item.text.length > 0) + assert.equal(item.url, undefined, 'ohne url') + } + assert.equal(t.widerruf, null) + assert.equal(t.returnHash, 'termine') + assert.equal(t.returnProjekt, true) + assert.equal(t.requiresWartungsAbo, true) + assert.equal(t.ticketFreischaltung, true) + assert.equal(t.disabledMessage, 'Der Terminplaner ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.') + assert.equal(t.orderedConflictMessage, 'Der Terminplaner ist für dieses Projekt schon bestellt.') + assert.equal(t.unavailableMessage, 'Der Terminplaner ist noch nicht verfügbar.') + + assert.equal(config.ustHinweis, 'ohne Umsatzsteuer gemäß § 19 UStG') + assert.equal(t.mails.preisHinweis, `einmalig, ${config.ustHinweis}; der Terminplaner läuft, solange Dein Wartungs-Abo läuft`) + assert.ok(t.mails.preisHinweis.includes(config.ustHinweis)) + assert.equal(t.mails.funktion, 'Terminplaner') + assert.deepEqual(t.mails.formen, { + dein: 'Dein Terminplaner', + nom: 'der Terminplaner', + akk: 'den Terminplaner', + gen: 'des Terminplaners', + pron: 'ihn', + }) + assert.equal(t.mails.knopf, 'Termine öffnen') + assert.equal(t.mails.anleitung, true) + assert.equal(typeof t.mails.danach, 'string') + assert.equal(typeof t.mails.bereit, 'string') + + assert.equal(t.ticket.topic({ projectName: 'Salon' }), 'Terminplaner einbauen – Salon') + assert.equal(t.ticket.topic({ subdomain: 'salon' }), 'Terminplaner einbauen – salon') + assert.equal(typeof t.ticket.details, 'function') + + const ansicht = reg.oeffentlicheAnsicht('termine') + assert.equal(ansicht.name, 'Terminplaner') + assert.equal(ansicht.kurz, 'Termine') + assert.equal(ansicht.priceLabel, '149 €') + assert.equal(ansicht.requiresWartungsAbo, true) + assert.equal(ansicht.returnHash, 'termine') +}) + +test('Termine-Ticketdetails werden erst beim Aufruf geladen', async () => { + // das Ziel termine/ticketDetails.js entsteht in Task B8; config.js importiert keine Dienste statisch + const { readFileSync } = await import('node:fs') + const src = readFileSync(new URL('../server/config.js', import.meta.url), 'utf8') + assert.doesNotMatch(src, /^import .* from '\.\/services\//m) + assert.match(src, /import\('\.\/services\/termine\/ticketDetails\.js'\)/) +}) + +// ---------------------------------------------------------------------------------- Block F --- +test('Bestelltexte ohne ENTWURF', { todo: 'Block F ersetzt die Platzhaltertexte' }, async () => { + const t = config.features.termine + const frei = (wert, was) => assert.doesNotMatch(JSON.stringify(wert), /ENTWURF/, `${was} enthält ENTWURF`) + frei(t.consent.version, 'consent.version') + frei(t.consent.items, 'consent.items') + frei(t.leistungsbeschreibung, 'leistungsbeschreibung') + frei(t.avv, 'avv') + frei(t.widerruf, 'widerruf') + // mails.danach und mails.bereit schreibt der Inhaber in seiner Mailrunde + // ab B7 gibt es den Haken belehrung (Platzhaltertext bis F7) + const belehrung = reg.featureHaken('termine').belehrung + if (belehrung) frei(await belehrung(), 'belehrung') +})