From 52456dac760b797225b290a481749eabffbe25ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:43:17 +0000 Subject: [PATCH] Terminplaner TP2 C10a: Nachbesserung 1 (Abo-Hinweis vom Server, R111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/funktionen liefert bei termine.verfuegbar ein fuenftes Feld hinweis (aboMeldung mit dem Register des Terminplaners, null bei bestellbar true). Die Kachel zeigt diesen Text maskiert an; die Browser-Kopie der Abo-Texte und der Gleichheitstest entfallen. Der Link "Zu Verträge" ist eine gemeinsame Konstante fuer Analyse und Terminplaner und erscheint bei kein_abo. Co-Authored-By: Claude Sonnet 5.5 --- public/funktionen.js | 32 +++++------- server/routes/funktionen.js | 11 ++-- test/funktionenTermine.test.js | 46 ++++++++++++++-- test/funktionenTiles.test.js | 95 ++++++++++++++++++++++------------ test/ui/termine-portal.ui.mjs | 20 ++++++- 5 files changed, 141 insertions(+), 63 deletions(-) diff --git a/public/funktionen.js b/public/funktionen.js index b75d396..91e0473 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -112,6 +112,9 @@ function newsTile(projects, price = PRICE) { ` } +// Link in den Hinweisen ohne Wartungs-Abo (kein_abo): Analyse-Kachel, Analyse-Seite und Terminplaner-Kachel teilen ihn +const ZU_VERTRAEGE_LINK = '' + /* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */ const analyseOf = (project) => project.analyse || { comingSoon: true } const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked') @@ -136,7 +139,7 @@ function analyseTile(projects) { else if (state === 'disabled') { text = 'Bitte melde dich bei uns'; action = '' } else if (state === 'active') action = `` else if (state === 'off') action = `` - else if (a.reason === 'kein_abo') action = '

Im Wartungs-Abo enthalten.

' + else if (a.reason === 'kein_abo') action = `

Im Wartungs-Abo enthalten. ${ZU_VERTRAEGE_LINK}

` else action = `

${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}

` const count = projects.length > 1 && active ? `

aktiv für ${active} von ${projects.length} Websites

` : '' return ` @@ -153,29 +156,15 @@ function analyseTile(projects) { /* Terminplaner: Kachel nur, wenn der Schalter es erlaubt (termine.verfuegbar), sonst "Kommt bald". Die Ansicht selbst (termine.js) wird von app.js geladen; hier steht nur die Kachel, damit funktionen.js keinen weiteren Import braucht. termine (Antwort von /api/funktionen) traegt bei verfuegbar: true bestellbar (true | false | null = Abo nicht lesbar), - grund (kein_abo | gekuendigt | nicht_aktiv | null), preis und einrichtung. Das Abo betrifft nur die Bestellung: ohne laufendes - Abo gibt es im Zustand Aus keinen Weg in die Ansicht; wer schon bestellt hat oder den Terminplaner nutzt, behaelt seinen. */ + grund (kein_abo | gekuendigt | nicht_aktiv | null), preis, einrichtung und hinweis (Abo-Text des Servers, aboMeldung in + server/services/featureOrder.js, bei bestellbar true null). Das Abo betrifft nur die Bestellung: ohne laufendes Abo gibt es im + Zustand Aus keinen Weg in die Ansicht; wer schon bestellt hat oder den Terminplaner nutzt, behaelt seinen. */ const TERMINE_TEXT = 'Deine Kundschaft bucht Termine direkt über deine Website.' const termineOf = (project) => project.features?.termine || { state: 'off' } // Doppelt zu anfragenText in termine-view.js: funktionen.js darf dort nichts importieren (die Kachel testet test/funktionenTiles.test.js // mit einem umgebogenen Import). Aenderungen an beiden Stellen machen. const anfragenText = (n) => (n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`) -// Abo-Hinweise der Kachel. Kopie von aboMeldung (server/services/featureOrder.js, einzige Quelle der Abo-Hinweistexte) fuer den -// Terminplaner; test/funktionenTiles.test.js haelt beide gleich. kein_abo steht wie bei der Analyse im Wortlaut der Kachel. -const TERMINE_ABO_TEXTE = { - gekuendigt: 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.', - nicht_aktiv: 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.', - unbekannt: 'Das Abo konnte gerade nicht geprüft werden.', -} -const KEIN_ABO_HINWEIS = 'Im Wartungs-Abo enthalten. ' - -function termineAboHinweis(angebot) { - if (angebot.bestellbar !== false) return TERMINE_ABO_TEXTE.unbekannt // null (Lesefehler) oder Feld fehlt: nicht geprueft - if (angebot.grund === 'kein_abo') return KEIN_ABO_HINWEIS - return Object.hasOwn(TERMINE_ABO_TEXTE, angebot.grund) && angebot.grund !== 'unbekannt' ? TERMINE_ABO_TEXTE[angebot.grund] : TERMINE_ABO_TEXTE.unbekannt -} - // Entwurf: Funktionsdokument im Zustand Aus mit siteKey (entsteht beim ersten Speichern des Fragebogens) const hatEntwurf = (project) => termineOf(project).state === 'off' && Boolean(termineOf(project).siteKey) @@ -216,7 +205,10 @@ function termineTile(projects, angebot = {}) { const preis = typeof angebot.preis === 'string' && angebot.preis.trim() ? ` · ${esc(angebot.preis)}` : '' action = knopf(hatEntwurf(target) ? 'Fragebogen fortsetzen' : `Terminplaner einrichten${preis}`) } else { - extra = `

${termineAboHinweis(angebot)}

` + // Hinweis des Servers (maskiert), bei kein_abo dazu der Weg in die Verträge; ohne Text bleibt nur der Link oder nichts + const hinweis = typeof angebot.hinweis === 'string' ? esc(angebot.hinweis) : '' + const zeile = [hinweis, angebot.bestellbar === false && angebot.grund === 'kein_abo' ? ZU_VERTRAEGE_LINK : ''].filter(Boolean).join(' ') + if (zeile) extra = `

${zeile}

` } const count = projects.length > 1 && active ? `

aktiv für ${active} von ${projects.length} Websites

` : '' return ` @@ -695,7 +687,7 @@ export async function initAnalyse(root, { projectId } = {}) { if (a.state === 'active') renderAnalyseStats(stage, project, reload) else if (a.state === 'disabled') stage.innerHTML = '

Analyse ist abgeschaltet

Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.

' else if (a.state === 'unknown') stage.innerHTML = '

Die Daten konnten gerade nicht geladen werden.

' - else if (!a.eligible) stage.innerHTML = `

Analyse nicht verfügbar

${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' ' : ''}

` + else if (!a.eligible) stage.innerHTML = `

Analyse nicht verfügbar

${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` ${ZU_VERTRAEGE_LINK}` : ''}

` else renderAnalyseActivation(stage, project, reload) } diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 9c862ff..9630f98 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -18,7 +18,7 @@ import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsf import { termineFuerKunde } from '../services/termine/schalter.js' import { aboMemo } from '../services/aboStatus.js' import { featureConfig, featureHaken, featureKeys, featureSperre, oeffentlicheAnsicht } from '../services/featureRegistry.js' -import { bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js' +import { aboMeldung, bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js' import { ELIGIBILITY_MESSAGES, @@ -94,8 +94,10 @@ const ABO_GRUENDE = ['kein_abo', 'gekuendigt', 'nicht_aktiv'] /** * Terminplaner-Teil der Antwort von GET /api/funktionen. Nicht verfuegbar (Schalter, Pilotliste): genau { verfuegbar: false }, * das Abo wird dann nicht gelesen. Verfuegbar: dazu bestellbar (true | false | null), grund (kein_abo | gekuendigt | nicht_aktiv | - * null), preis und einrichtung aus dem Register. Ein Lesefehler im Abo ergibt bestellbar: null und kostet die Liste nichts; im - * Log stehen nur Art und Status, nie der Text (Appwrite-Texte koennten Namen oder IDs enthalten). + * null), preis und einrichtung aus dem Register sowie hinweis, der Abo-Text fuer den Kunden (aboMeldung, einzige Quelle der + * Abo-Hinweise; der Browser zeigt ihn nur an). hinweis ist null, solange bestellbar true ist. Ein Lesefehler im Abo ergibt + * bestellbar: null und kostet die Liste nichts; im Log stehen nur Art und Status, nie der Text (Appwrite-Texte koennten Namen + * oder IDs enthalten). */ async function termineAngebot(email, customerId) { if (!termineFuerKunde(email)) return { verfuegbar: false } @@ -109,7 +111,8 @@ async function termineAngebot(email, customerId) { } catch (err) { console.error(`[funktionen] Abo für den Terminplaner nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) } - return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '' } + const hinweis = bestellbar === true ? null : aboMeldung(register, grund ?? 'unbekannt') + return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '', hinweis } } // Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index a4ee08b..0491cd8 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -20,6 +20,7 @@ const { default: teamRoutes } = await import('../server/routes/team/index.js') const sf = await import('../server/services/siteFeatures.js') const { loeschTag } = await import('../server/services/termine/frist.js') const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js') +const { aboMeldung } = await import('../server/services/featureOrder.js') const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/team': teamRoutes }) after(() => Promise.all([app.close(), aw.close(), st.close()])) @@ -165,7 +166,8 @@ test('GET /api/funktionen: termine.verfuegbar je Modus und Pilot-E-Mail; false b assert.equal(await verfuegbar(customerCookie('c2')), true) }) -// ---- Abo-Felder der Kachel (C10a): bestellbar, grund, preis, einrichtung nur bei verfuegbar: true +// ---- Abo-Felder der Kachel (C10a): bestellbar, grund, preis, einrichtung, hinweis nur bei verfuegbar: true +// hinweis (R111) = aboMeldung des Registers: der Browser zeigt den Text nur an und hat keine Kopie; bei bestellbar true null const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch })) const angebot = (bestellbar, grund) => ({ verfuegbar: true, @@ -173,6 +175,7 @@ const angebot = (bestellbar, grund) => ({ grund, preis: config.features.termine.priceLabel, einrichtung: config.features.termine.setupEstimate, + hinweis: bestellbar === true ? null : aboMeldung(config.features.termine, grund ?? 'unbekannt'), }) test('GET /api/funktionen: Kunde mit Wartungs-Abo => bestellbar true, grund null, Preis und Einrichtung aus dem Register', async (t) => { @@ -182,7 +185,38 @@ test('GET /api/funktionen: Kunde mit Wartungs-Abo => bestellbar true, grund null const termine = await termineAntwort() assert.deepEqual(termine, angebot(true, null)) assert.ok(termine.preis && termine.einrichtung, 'das Register liefert beide Werte') - assert.deepEqual(Object.keys(termine), ['verfuegbar', 'bestellbar', 'grund', 'preis', 'einrichtung']) + assert.deepEqual(Object.keys(termine), ['verfuegbar', 'bestellbar', 'grund', 'preis', 'einrichtung', 'hinweis']) + assert.equal(termine.hinweis, null, 'bestellbar: kein Hinweis (das Feld steht da, ist aber null)') +}) + +test('GET /api/funktionen: hinweis ist der Text von aboMeldung mit dem Register des Terminplaners, je Grund', async (t) => { + t.after(() => aw.seed('subscriptions', hostingDoc)) + termineMit('pilot', ['*']) + const faelle = [ + ['kein_abo', () => {}, 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.'], + ['gekuendigt', () => wartung({ cancelAtPeriodEnd: true }), 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.'], + [ + 'nicht_aktiv', + () => wartung({ status: 'past_due' }), + 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.', + ], + ] + for (const [grund, richteEin, text] of faelle) { + aw.seed('subscriptions', hostingDoc) + richteEin() + const termine = await termineAntwort() + assert.equal(termine.bestellbar, false, grund) + assert.equal(termine.grund, grund) + assert.equal(termine.hinweis, text, grund) + assert.equal(termine.hinweis, aboMeldung(config.features.termine, grund), grund) + } + // ein Lesefehler: bestellbar null, grund null => Text zu 'unbekannt' + aw.seed('subscriptions', hostingDoc) + wartung() + t.mock.method(console, 'error', () => {}) + aw.failNext({ collection: 'subscriptions', status: 500, message: 'x' }) + const unbekannt = await termineAntwort() + assert.deepEqual([unbekannt.bestellbar, unbekannt.grund, unbekannt.hinweis], [null, null, 'Das Abo konnte gerade nicht geprüft werden.']) }) test('GET /api/funktionen: ohne Wartungs-Abo (nur Hosting, gar kein Abo, beendet) => bestellbar false, grund kein_abo', async (t) => { @@ -212,11 +246,15 @@ test('GET /api/funktionen: Lesefehler im Abo => bestellbar null, grund null, die const fehler = t.mock.method(console, 'error', () => {}) termineMit('pilot', ['*']) wartung() + // eigenes Projekt mit aktivem Terminplaner: der Test haengt nicht von den Zustaenden aus frueheren Tests ab + aw.seed('websiteProjects', { $id: 'pt_lesefehler', customerId: 'c1', projectName: 'Lesefehler Salon' }) + aw.seed('portalFeatures', { $id: 'f_pt_lesefehler_termine', customerId: 'c1', projectId: 'pt_lesefehler', featureKey: 'termine', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true }) aw.failNext({ collection: 'subscriptions', status: 500, message: 'geheimer Appwrite-Text' }) const res = await get('/api/funktionen', { cookie }) assert.deepEqual(res.termine, angebot(null, null)) - for (const id of ['pt1', 'pt2']) assert.ok(res.projects.some((p) => p.projectId === id), `Projekt ${id} ist in der Liste`) - assert.equal(featuresOf({ body: res }, 'pt1').termine.state, 'active', 'die Zustaende der Projekte bleiben') + assert.equal(res.termine.hinweis, 'Das Abo konnte gerade nicht geprüft werden.') + assert.ok(res.projects.some((p) => p.projectId === 'pt_lesefehler'), 'das Projekt ist in der Liste') + assert.equal(featuresOf({ body: res }, 'pt_lesefehler').termine.state, 'active', 'die Zustaende der Projekte bleiben') assert.ok(!JSON.stringify(res).includes('geheimer')) assert.ok(fehler.mock.calls.every((aufruf) => !String(aufruf.arguments[0]).includes('geheimer')), 'auch nicht im Log') assert.ok(fehler.mock.calls.length >= 1, 'der Fehler steht im Log') diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js index ac7dc23..8f7a10a 100644 --- a/test/funktionenTiles.test.js +++ b/test/funktionenTiles.test.js @@ -4,8 +4,6 @@ import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { aboMeldung } from '../server/services/featureOrder.js' -import { featureConfig } from '../server/services/featureRegistry.js' // public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js werden auf die Dateien umgeleitet) const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-')) @@ -149,7 +147,7 @@ test('statisch: News postet weiter consent: true an /api/funktionen/news/checkou const withTermine = (id, state, extra = {}) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news, termine: { state, ...extra } } }) const noAnalyse = { comingSoon: true } // Standardantwort bei verfuegbar: true wie GET /api/funktionen (Kunde mit Wartungs-Abo); abo ueberschreibt einzelne Felder -const ABO_OK = { bestellbar: true, grund: null, preis: '149 €', einrichtung: 'meist innerhalb von 1–2 Wochen' } +const ABO_OK = { bestellbar: true, grund: null, preis: '149 €', einrichtung: 'meist innerhalb von 1–2 Wochen', hinweis: null } async function renderTermine(projects, { verfuegbar, anfragen = {}, abo = {} } = {}) { const calls = [] globalThis.fetch = async (url) => { @@ -178,10 +176,10 @@ test('Terminplaner-Kachel: fehlt termine.verfuegbar oder ist false ⇒ „Kommt } }) -// Die Abo-Hinweise der Kachel kommen aus aboMeldung (server/services/featureOrder.js, einzige Quelle der Abo-Hinweistexte); -// die Kachel steht im Browser und kennt den Text nur als Kopie: dieser Test haelt beide gleich. -const TERMINE_REGISTER = featureConfig('termine') -const KEIN_ABO_HINWEIS = /Im Wartungs-Abo enthalten\. ' const knopfDerKachel = (kachel) => kachel.match(/]*fn-tile-cta[^>]*>([^<]*)<\/button>/) test('Terminplaner-Kachel, Tabelle: jede Zeile mit Pille, Pillenklasse, Knopftext und Ziel', async () => { @@ -195,10 +193,10 @@ test('Terminplaner-Kachel, Tabelle: jede Zeile mit Pille, Pillenklasse, Knopftex ['Aktiv', [withTermine('a', 'active')], {}, 'on', 'Aktiv', 'Termine öffnen', 'termine'], ['Abgeschaltet', [withTermine('a', 'disabled')], {}, 'off', 'Abgeschaltet', 'Termine ansehen', 'termine'], // das Abo betrifft nur die Bestellung: wer schon bestellt hat oder nutzt, behaelt seinen Weg in die Ansicht - ['Zahlung offen, Abo gekündigt', [withTermine('a', 'ordered')], { bestellbar: false, grund: 'gekuendigt' }, 'setup', 'Zahlung offen', 'Status ansehen', 'termine'], - ['In Einrichtung, Abo nicht lesbar', [withTermine('a', 'setup')], { bestellbar: null, grund: null }, 'setup', 'In Einrichtung', 'Status ansehen', 'termine'], - ['Aktiv, Abo gekündigt', [withTermine('a', 'active')], { bestellbar: false, grund: 'gekuendigt' }, 'on', 'Aktiv', 'Termine öffnen', 'termine'], - ['Abgeschaltet, Abo nicht aktiv', [withTermine('a', 'disabled')], { bestellbar: false, grund: 'nicht_aktiv' }, 'off', 'Abgeschaltet', 'Termine ansehen', 'termine'], + ['Zahlung offen, Abo gekündigt', [withTermine('a', 'ordered')], { bestellbar: false, grund: 'gekuendigt', hinweis: 'Serverhinweis gekündigt.' }, 'setup', 'Zahlung offen', 'Status ansehen', 'termine'], + ['In Einrichtung, Abo nicht lesbar', [withTermine('a', 'setup')], { bestellbar: null, grund: null, hinweis: 'Serverhinweis nicht prüfbar.' }, 'setup', 'In Einrichtung', 'Status ansehen', 'termine'], + ['Aktiv, Abo gekündigt', [withTermine('a', 'active')], { bestellbar: false, grund: 'gekuendigt', hinweis: 'Serverhinweis gekündigt.' }, 'on', 'Aktiv', 'Termine öffnen', 'termine'], + ['Abgeschaltet, Abo nicht aktiv', [withTermine('a', 'disabled')], { bestellbar: false, grund: 'nicht_aktiv', hinweis: 'Serverhinweis nicht aktiv.' }, 'off', 'Abgeschaltet', 'Termine ansehen', 'termine'], ] for (const [name, projects, abo, klasse, pille, knopfText, ziel] of zeilen) { const { html } = await renderTermine(projects, { verfuegbar: true, abo }) @@ -210,44 +208,75 @@ test('Terminplaner-Kachel, Tabelle: jede Zeile mit Pille, Pillenklasse, Knopftex assert.ok(knopf[0].includes(`data-view="${ziel}" data-project="a"`), `${name}: Ziel ${knopf[0]}`) assert.doesNotMatch(kachel, /Wir schalten den Terminplaner für dich frei/, name) assert.doesNotMatch(kachel, /data-view="chat"/, `${name}: kein Chat-Knopf mehr`) + assert.doesNotMatch(kachel, /Serverhinweis/, `${name}: der Abo-Hinweis gilt nur im Zustand Aus`) } }) -test('Terminplaner-Kachel: Aus ohne Abo ⇒ kein Knopf und kein Weg in die Ansicht, Hinweis je Grund', async () => { - const hinweise = [ - ['kein_abo', { bestellbar: false, grund: 'kein_abo' }, KEIN_ABO_HINWEIS], - ['gekuendigt', { bestellbar: false, grund: 'gekuendigt' }, new RegExp(`${aboMeldung(TERMINE_REGISTER, 'gekuendigt')}`)], - ['nicht_aktiv', { bestellbar: false, grund: 'nicht_aktiv' }, new RegExp(aboMeldung(TERMINE_REGISTER, 'nicht_aktiv').replace(/[()]/g, '\\$&'))], - ['null (Lesefehler)', { bestellbar: null, grund: null }, /Das Abo konnte gerade nicht geprüft werden\./], - ['Feld fehlt (ältere Antwort)', { bestellbar: undefined, grund: undefined }, /Das Abo konnte gerade nicht geprüft werden\./], - ['unbekannter Grund', { bestellbar: false, grund: 'etwas_neues' }, /Das Abo konnte gerade nicht geprüft werden\./], +test('Terminplaner-Kachel: Aus ohne Abo => kein Knopf und kein Weg in die Ansicht, der Hinweis kommt vom Server (Link zu Verträge nur bei kein_abo)', async () => { + const faelle = [ + ['kein_abo', { bestellbar: false, grund: 'kein_abo', hinweis: 'Serverhinweis ohne Abo.' }, `Serverhinweis ohne Abo. ${ZU_VERTRAEGE}`], + ['gekuendigt', { bestellbar: false, grund: 'gekuendigt', hinweis: 'Serverhinweis gekündigt & beendet.' }, 'Serverhinweis gekündigt & beendet.'], + ['nicht_aktiv', { bestellbar: false, grund: 'nicht_aktiv', hinweis: 'Serverhinweis „nicht aktiv“ (offen).' }, 'Serverhinweis „nicht aktiv“ (offen).'], + ['unbekannt (Lesefehler: bestellbar null)', { bestellbar: null, grund: null, hinweis: 'Serverhinweis nicht prüfbar.' }, 'Serverhinweis nicht prüfbar.'], + ['unbekannter Grund', { bestellbar: false, grund: 'etwas_neues', hinweis: 'Serverhinweis zu etwas Neuem.' }, 'Serverhinweis zu etwas Neuem.'], ] // mit und ohne Entwurf: auch ein vorhandener Entwurf oeffnet ohne Abo keinen Weg for (const projekte of [[withTermine('a', 'off')], [withTermine('a', 'off', { siteKey: 'abcdefghijklmnop' })]]) { - for (const [name, abo, hinweis] of hinweise) { + for (const [name, abo, erwartet] of faelle) { const { html, calls } = await renderTermine(projekte, { verfuegbar: true, abo }) const kachel = tile(html, 'termine') assert.match(kachel, /fn-pill-off">Aus${erwartet}

`), `${name}: ${kachel}`) assert.doesNotMatch(kachel, /data-view="termine"/, name) assert.doesNotMatch(kachel, /fn-tile-cta/, `${name}: kein Knopf`) assert.doesNotMatch(html, /data-view="termine"/, `${name}: nirgends ein Weg in die Ansicht`) assert.deepEqual(calls, ['/api/funktionen'], name) + // nur kein_abo verweist auf die Vertraege; die uebrigen Hinweise sind reiner Text + assert.equal(kachel.split('data-view=').length - 1, abo.grund === 'kein_abo' ? 1 : 0, `${name}: Links`) } } - // nur kein_abo verweist auf die Vertraege; die uebrigen Hinweise sind reiner Text - const gekuendigt = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'gekuendigt' } })).html, 'termine') - assert.doesNotMatch(gekuendigt, /data-view=/) }) -test('Terminplaner-Kachel: die Abo-Hinweise stimmen wortgleich mit aboMeldung überein (gekündigt, nicht aktiv, nicht lesbar)', async () => { - for (const grund of ['gekuendigt', 'nicht_aktiv']) { - const { html } = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund } }) - assert.ok(tile(html, 'termine').includes(`

${aboMeldung(TERMINE_REGISTER, grund)}

`), grund) +test('Terminplaner-Kachel: der Hinweis des Servers kommt maskiert in die Kachel (auch bei kein_abo neben dem Link)', async () => { + const bose = '"\'&' + const maskiert = '<img src=x onerror=1>"'&' + for (const grund of ['gekuendigt', 'nicht_aktiv', 'kein_abo']) { + const { html } = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund, hinweis: bose } }) + const kachel = tile(html, 'termine') + assert.doesNotMatch(html, /${maskiert}${grund === 'kein_abo' ? ` ${ZU_VERTRAEGE}` : ''}

`), `${grund}: ${kachel}`) } - const { html } = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: null, grund: null } }) - assert.ok(tile(html, 'termine').includes(`

${aboMeldung(TERMINE_REGISTER, 'unbekannt')}

`)) - assert.equal(aboMeldung(TERMINE_REGISTER, 'unbekannt'), 'Das Abo konnte gerade nicht geprüft werden.') +}) + +test('Terminplaner-Kachel: fehlt der Hinweis (leer, null, kein Text) gibt es keine Zeile und keinen Knopf; kein_abo behält den Link', async () => { + for (const hinweis of [undefined, null, '', 5, { text: 'x' }]) { + const ohne = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'gekuendigt', hinweis } })).html, 'termine') + assert.match(ohne, /fn-pill-off">Aus${ZU_VERTRAEGE}

`), String(hinweis)) + assert.doesNotMatch(keinAbo, /fn-tile-cta|data-view="termine"/) + } +}) + +test('Terminplaner-Kachel: mit Abo (bestellbar true) steht der Knopf da und ein Hinweis erscheint nicht', async () => { + const kachel = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { hinweis: 'Serverhinweis darf nicht erscheinen.' } })).html, 'termine') + assert.equal(knopfDerKachel(kachel)[1], 'Terminplaner einrichten · 149 €') + assert.doesNotMatch(kachel, /Serverhinweis|fn-count/) +}) + +test('Der Browser hat keine Kopie der Abo-Hinweise; der Link zu Verträge steht einmal im Quelltext (Analyse und Terminplaner teilen ihn)', async () => { + assert.doesNotMatch(src, /TERMINE_ABO_TEXTE|termineAboHinweis|KEIN_ABO_HINWEIS|ist gekündigt\. Für|gerade nicht aktiv|nicht geprüft werden/) + assert.equal(src.split('data-view="vertraege"').length - 1, 1) + // Analyse-Kachel und -Seite (unveraendert) und Terminplaner-Kachel zeigen genau diesen Link + const analyse = tile(await render([project('a', { eligible: false, reason: 'kein_abo', reasonText: 'Kein Abo' })]), 'analyse') + assert.ok(analyse.includes(`

Im Wartungs-Abo enthalten. ${ZU_VERTRAEGE}

`), analyse) + const andere = tile(await render([project('a', { eligible: false, reason: 'gekuendigt', reasonText: 'Gekündigt' })]), 'analyse') + assert.ok(andere.includes('

Gekündigt

') && !andere.includes('data-view='), andere) + const stage = await renderActivation({ state: undefined, eligible: false, reason: 'kein_abo', reasonText: 'Kein Abo' }) + assert.ok(stage.innerHTML.includes(`

Kein Abo ${ZU_VERTRAEGE}

`), stage.innerHTML) + const termine = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'kein_abo', hinweis: 'T' } })).html, 'termine') + assert.ok(termine.includes(ZU_VERTRAEGE), termine) }) test('Terminplaner-Kachel: ordered und setup fragen /api/termine nicht nach einer Zahl (nur aktive Websites)', async () => { @@ -347,11 +376,11 @@ test('Terminplaner-Kachel: Zielprojekt = aktiv, dann ordered/setup, dann abgesch }) test('Terminplaner-Kachel: mehrere Websites ohne Abo ⇒ die Pille bleibt „Aus“, nirgends ein Knopf (auch mit Entwurf)', async () => { - const { html } = await renderTermine([withTermine('a', 'off'), withTermine('b', 'off', { siteKey: 'abcdefghijklmnop' })], { verfuegbar: true, abo: { bestellbar: false, grund: 'kein_abo' } }) + const { html } = await renderTermine([withTermine('a', 'off'), withTermine('b', 'off', { siteKey: 'abcdefghijklmnop' })], { verfuegbar: true, abo: { bestellbar: false, grund: 'kein_abo', hinweis: 'Serverhinweis ohne Abo.' } }) const kachel = tile(html, 'termine') assert.match(kachel, /fn-pill-off">AusServerhinweis ohne Abo. ${ZU_VERTRAEGE}

`), kachel) }) test('Terminplaner-Kachel: Projektname und Zustand kommen nur maskiert in die Kachel', async () => { diff --git a/test/ui/termine-portal.ui.mjs b/test/ui/termine-portal.ui.mjs index cd9d7cd..4dd2255 100644 --- a/test/ui/termine-portal.ui.mjs +++ b/test/ui/termine-portal.ui.mjs @@ -125,6 +125,15 @@ function alleStatus() { ] } +// Der Abo-Text kommt in der Antwort von /api/funktionen vom Server (termine.hinweis, aboMeldung in featureOrder.js); die Fixture +// liefert feste Texte, die Kachel zeigt sie nur an +const FX_ABO_HINWEISE = { + kein_abo: 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.', + gekuendigt: 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.', + nicht_aktiv: 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.', + unbekannt: 'Das Abo konnte gerade nicht geprüft werden.', +} + /** Antworten der Fixture auf /api/funktionen und /api/termine (Teil von page.route). */ async function fixtureAntwort(fx, route, req, url, json) { const p = url.pathname @@ -132,7 +141,14 @@ async function fixtureAntwort(fx, route, req, url, json) { const m = req.method() if (p === '/api/funktionen') { const termine = fx.verfuegbar - ? { verfuegbar: true, bestellbar: fx.bestellbar, grund: fx.aboGrund, preis: '149 €', einrichtung: 'meist innerhalb von 1–2 Wochen' } + ? { + verfuegbar: true, + bestellbar: fx.bestellbar, + grund: fx.aboGrund, + preis: '149 €', + einrichtung: 'meist innerhalb von 1–2 Wochen', + hinweis: fx.bestellbar === true ? null : FX_ABO_HINWEISE[fx.aboGrund ?? 'unbekannt'], + } : { verfuegbar: false } return json({ projects: fx.projekte, termine }) } @@ -324,7 +340,7 @@ async function pruefungen() { await knopf(kachel, 'Fragebogen fortsetzen').waitFor() // Aus ohne Abo: kein Knopf, Hinweis; die Hinweise der uebrigen Gruende const faelle = [ - ['kein_abo', 'Im Wartungs-Abo enthalten.'], + ['kein_abo', 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.'], ['gekuendigt', 'Dein Wartungs-Abo ist gekündigt.'], ['nicht_aktiv', 'Dein Wartungs-Abo ist gerade nicht aktiv'], ]