diff --git a/public/termine-view.js b/public/termine-view.js index 585208a..8d1b007 100644 --- a/public/termine-view.js +++ b/public/termine-view.js @@ -456,3 +456,22 @@ export function aktivSeitText(iso) { if (!zeit || Number.isNaN(zeit.getTime())) return '' return `Aktiv seit ${datumKurz(heuteBerlin(zeit))}` } + +/** + * Text des Banners einer abgeschalteten Funktion nach Abschaltgrund. abgeschaltet = { grund, seit, loeschungAm } aus GET /api/termine/einstellungen + * (grund: 'abo_ende' | 'abo_ende_vor_einrichtung' | 'team' | 'kunde_geloescht' | null, loeschungAm: 'JJJJ-MM-TT' oder null; null/fehlend = bisheriger Satz). + * Liefert { davor, danach, mitExport }: mit Export steht zwischen den Teilen der Export-Link ([davor, Link 'exportieren', danach]), ohne + * Export (nur wenn das Abo vor der Einrichtung endete: es gibt nichts zu exportieren) folgen die Teile direkt aufeinander. + * Das Loeschdatum steht nur da, wenn loeschungAm ein gueltiger Kalendertag ist. Du-Form; der Wortlaut ist ein Vorschlag fuer die Rechtsrunde. + */ +export function abschaltText(abgeschaltet) { + const { grund, loeschungAm } = abgeschaltet || {} + const loeschen = istTag(loeschungAm) ? ` Ab dem ${datumKurz(loeschungAm)} löschen wir die Daten.` : '' + if (grund === 'abo_ende_vor_einrichtung') { + return { davor: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.', danach: loeschen, mitExport: false } + } + const davor = grund === 'abo_ende' + ? 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und ' + : 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und ' + return { davor, danach: `.${loeschen}`, mitExport: true } +} diff --git a/public/termine.js b/public/termine.js index 42c06f4..38b0ca2 100644 --- a/public/termine.js +++ b/public/termine.js @@ -8,7 +8,7 @@ hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (z. B. wenn Telefon und Anschrift nach dem Speichern nicht mehr fehlen). Server: server/routes/termine.js. Tage sind Berliner Kalendertage 'JJJJ-MM-TT'; gerechnet wird in termine-view.js. Helfer ohne Zustand: termine-dom.js. Styles: termine.css. */ -import { fehltText, heuteBerlin } from './termine-view.js' +import { abschaltText, fehltText, heuteBerlin } from './termine-view.js' import { exportLink, fehlerKarte, h, infoKarte, request } from './termine-dom.js' import { render as renderTermine } from './termine-tab-termine.js' import { render as renderLeistungen } from './termine-tab-leistungen.js' @@ -62,11 +62,15 @@ function projektwahl(s) { const ladeDaten = (projectId) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(projectId)}`) -/** Hinweis fuer abgeschaltete Funktionen: nur noch ansehen und exportieren. */ -const hinweisAbgeschaltet = (projectId) => h('div', { class: 'tm-box tm-box-warn', role: 'note' }, - 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und ', - exportLink(projectId, 'exportieren'), - '.') +/** + * Hinweis fuer abgeschaltete Funktionen: Grund, Loeschdatum und (ausser vor der Einrichtung) der Export-Link, der einzige Link im Hinweis. + * abgeschaltet: { grund, seit, loeschungAm } aus GET /einstellungen (fehlt bei einer aelteren Antwort: dann der bisherige Satz). + */ +function hinweisAbgeschaltet(projectId, abgeschaltet) { + const { davor, danach, mitExport } = abschaltText(abgeschaltet) + // ein einziger Textblock in der Flex-Zeile der Box, sonst zerfaellt der Satz in drei Spalten (Text, Link, Punkt) + return h('div', { class: 'tm-box tm-box-warn', role: 'note' }, h('span', null, davor, mitExport && exportLink(projectId, 'exportieren'), danach)) +} /** * Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. Jeder Aufruf zaehlt s.lauf hoch: die Antwort @@ -95,7 +99,7 @@ async function zeigeProjekt(s) { if (daten.einstellung) zeigeReiter(s, { projekt, aktuell }) else if (s.nurLesen) { s.buehne.replaceChildren( - hinweisAbgeschaltet(projekt.projectId), + hinweisAbgeschaltet(projekt.projectId, daten.abgeschaltet), infoKarte('Keine Termine eingerichtet', 'Für diese Website wurden keine Einstellungen und Termine angelegt.', { chat: true })) } else zeigeVorlagen(s, { projekt, aktuell }) } @@ -197,7 +201,7 @@ function zeigeReiter(s, { projekt, aktuell }) { function zeichneHinweise() { const hinweise = [] if (nurLesen) { - hinweise.push(hinweisAbgeschaltet(projectId)) + hinweise.push(hinweisAbgeschaltet(projectId, s.daten.abgeschaltet)) } else if ((s.daten.fehlt || []).length) { hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' }, h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`), diff --git a/server/routes/termine.js b/server/routes/termine.js index ffeef30..4376bfe 100644 --- a/server/routes/termine.js +++ b/server/routes/termine.js @@ -10,6 +10,7 @@ import { verschiebeZeiten } from '../services/termine/ablauf.js' import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js' import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js' import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js' +import { loeschTag, loeschzeit } from '../services/termine/frist.js' import { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js' import { zustellFehler } from '../services/termine/outbox.js' import { termineFuerKunde } from '../services/termine/schalter.js' @@ -57,6 +58,8 @@ const MELDUNG = { nichtVerfuegbar: 'Der Terminplaner ist noch nicht verfügbar.', nichtAktiv: 'Der Terminplaner ist für diese Website nicht aktiv.', abgeschaltet: 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und exportieren.', + abgeschaltetAboEnde: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren.', + abgeschaltetVorEinrichtung: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.', keineEinstellung: 'Bitte richte den Terminplaner zuerst ein.', zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.', vorlage: 'Bitte wähle eine Vorlage aus der Liste.', @@ -125,6 +128,25 @@ export function resetTermineRoutesForTests() { // ---------------------------------------------------------------- Projekt und Funktion +/** + * Was die Abschaltung dem Kunden sagt: Grund (disabledReason, ohne Grund = Altbestand => null), Zeitpunkt (disabledAt) und der Berliner + * Tag, ab dem die Daten gelöscht werden dürfen (loeschungAm, 30 Tage nach disabledAt, termine/frist.js; null ohne lesbaren Zeitpunkt). + * Diese Route gehört nur zum Terminplaner, deshalb gibt es das Löschdatum hier immer (R105: bei News nie, siehe siteFeatures.featureView). + */ +const abgeschaltetAnsicht = (feature) => ({ + grund: feature.disabledReason || null, + seit: feature.disabledAt || null, + loeschungAm: loeschTag(feature.disabledAt), +}) + +/** Antwort auf einen Schreibversuch bei abgeschalteter Funktion (409, Code `abgeschaltet`): je Grund ein Satz, mit Löschdatum, wenn es eines gibt. */ +function abgeschaltetMeldung(feature) { + const grund = feature.disabledReason + const satz = grund === 'abo_ende' ? MELDUNG.abgeschaltetAboEnde : grund === 'abo_ende_vor_einrichtung' ? MELDUNG.abgeschaltetVorEinrichtung : MELDUNG.abgeschaltet + const ende = loeschzeit(feature.disabledAt) + return ende ? `${satz} Ab dem ${datumKurz(ende)} löschen wir die Daten.` : satz +} + const projectIdOf = (req) => { const raw = req.query.projectId ?? req.body?.projectId return typeof raw === 'string' ? raw : '' @@ -149,7 +171,7 @@ const withTermine = ({ schreiben = false } = {}) => route(async (req, _res, next const feature = await getFeatureDoc(customerId, projectId, 'termine') const ctx = feature && ['active', 'disabled'].includes(feature.state) ? await kontextPerFeature(feature.$id) : null if (!ctx || ctx.feature.customerId !== customerId) throw stop(409, MELDUNG.nichtAktiv, 'nicht_aktiv') - if (schreiben && ctx.feature.state !== 'active') throw stop(409, MELDUNG.abgeschaltet, 'abgeschaltet') + if (schreiben && ctx.feature.state !== 'active') throw stop(409, abgeschaltetMeldung(ctx.feature), 'abgeschaltet') req.ctx = ctx return next() }) @@ -203,6 +225,7 @@ router.get('/einstellungen', requireSession, withTermine(), route(async (req, re const e = ctx.einstellung res.json({ state: ctx.feature.state, + abgeschaltet: ctx.feature.state === 'disabled' ? abgeschaltetAnsicht(ctx.feature) : null, aktivSeit: ctx.feature.activatedAt || null, einstellung: e ? fuerKunde(e) : null, version: ctx.version, diff --git a/test/termineRoutes.test.js b/test/termineRoutes.test.js index ad704ae..74ded21 100644 --- a/test/termineRoutes.test.js +++ b/test/termineRoutes.test.js @@ -1102,10 +1102,29 @@ test('exportSeite: ohne Funktion, mit ungueltigem Cursor oder ungueltiger Grenze // ---------------------------------------------------------------- Abgeschaltet (disabled) -test('abgeschaltete Funktion: Lesen und Export gehen, jedes Schreiben ist 409', async () => { - const disabled = featureDoc({ $id: 'f_p4_termine', projectId: 'p4', state: 'disabled', enabled: false, siteKey: 'cdefghijklmnopqr' }) +const MELDUNG_ALT = 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und exportieren.' + +/** Jede Schreib-Route des Portals, wie sie bei einer abgeschalteten Funktion von Projekt `projectId` aufgerufen wird. */ +const schreibVersuche = (projectId, einstellung) => [ + ['PATCH', '/api/termine/einstellungen', { projectId, version: 1, einstellung }], + ['POST', '/api/termine/einstellungen/vorlage', { projectId, vorlage: 'salon' }], + ['POST', '/api/termine/buchungen', { ...manuellBody(), projectId }], + ['POST', '/api/termine/buchungen/b_alt/aktion', { projectId, aktion: 'absagen', grund: 'x' }], + ['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId }], + ['DELETE', `/api/termine/buchungen/b_alt?projectId=${projectId}`], +] + +/** Funktion von p4 abgeschaltet mit Einstellung und einer Buchung; `over` setzt Grund und Zeitpunkt. */ +async function seedAbgeschaltet(over = {}) { + const disabled = featureDoc({ $id: 'f_p4_termine', projectId: 'p4', state: 'disabled', enabled: false, siteKey: 'cdefghijklmnopqr', ...over }) + aw.seed('portalFeatures', disabled) await speichere(einstellung(), disabled) aw.seed('termineBookings', buchungsDoc('b_alt', { featureId: 'f_p4_termine', projectId: 'p4' })) + return disabled +} + +test('abgeschaltete Funktion: Lesen und Export gehen, jedes Schreiben ist 409', async () => { + await seedAbgeschaltet() const lesen = await send('GET', einstellungenUrl('p4'), { cookie }) assert.equal(lesen.status, 200) assert.equal(lesen.body.state, 'disabled') @@ -1117,15 +1136,7 @@ test('abgeschaltete Funktion: Lesen und Export gehen, jedes Schreiben ist 409', assert.ok(export_.text.includes('Max Mustermann')) assert.equal((await send('GET', `/api/termine/zeiten?projectId=p4&leistung=${S_SOFORT}&tag=${TAG}`, { cookie })).status, 200) - const schreiben = [ - ['PATCH', '/api/termine/einstellungen', { projectId: 'p4', version: 1, einstellung: lesen.body.einstellung }], - ['POST', '/api/termine/einstellungen/vorlage', { projectId: 'p4', vorlage: 'salon' }], - ['POST', '/api/termine/buchungen', { ...manuellBody(), projectId: 'p4' }], - ['POST', '/api/termine/buchungen/b_alt/aktion', { projectId: 'p4', aktion: 'absagen', grund: 'x' }], - ['POST', `/api/termine/ressourcen/${R1}/feed`, { projectId: 'p4' }], - ['DELETE', '/api/termine/buchungen/b_alt?projectId=p4'], - ] - for (const [method, pfad, body] of schreiben) { + for (const [method, pfad, body] of schreibVersuche('p4', lesen.body.einstellung)) { const res = await send(method, pfad, { cookie, body }) assert.equal(res.status, 409, `${method} ${pfad}`) assert.equal(res.body.code, 'abgeschaltet') @@ -1134,6 +1145,81 @@ test('abgeschaltete Funktion: Lesen und Export gehen, jedes Schreiben ist 409', assert.equal(aw.docs('termineConfig').get('f_p4_termine').version, 1) }) +test('abgeschaltet ohne Grund und Zeitpunkt (Altbestand): abgeschaltet leer, die Meldung bleibt der bisherige Satz', async () => { + await seedAbgeschaltet() + const lesen = await send('GET', einstellungenUrl('p4'), { cookie }) + assert.deepEqual(lesen.body.abgeschaltet, { grund: null, seit: null, loeschungAm: null }) + for (const [method, pfad, body] of schreibVersuche('p4', lesen.body.einstellung)) { + const res = await send(method, pfad, { cookie, body }) + assert.equal(res.status, 409, `${method} ${pfad}`) + assert.equal(res.body.code, 'abgeschaltet') + assert.equal(res.body.error, MELDUNG_ALT, `${method} ${pfad}`) + } +}) + +test('abgeschaltet wegen Abo-Ende: Grund, Löschtag und Meldung mit Wartungs-Abo und Datum; Lesen und Export gehen weiter', async () => { + await seedAbgeschaltet({ disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' }) + const lesen = await send('GET', einstellungenUrl('p4'), { cookie }) + assert.equal(lesen.status, 200) + // 23:30 UTC am 4.10. + 30 Tage = 3.11. 23:30 UTC = 4.11. 00:30 Berliner Zeit (Winterzeit) + assert.deepEqual(lesen.body.abgeschaltet, { grund: 'abo_ende', seit: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' }) + assert.equal((await send('GET', `/api/termine/buchungen?projectId=p4&von=${TAG}&bis=${TAG}`, { cookie })).status, 200) + assert.equal((await rohExport('p4')).res.status, 200) + for (const [method, pfad, body] of schreibVersuche('p4', lesen.body.einstellung)) { + const res = await send(method, pfad, { cookie, body }) + assert.equal(res.status, 409, `${method} ${pfad}`) + assert.equal(res.body.code, 'abgeschaltet') + assert.ok(res.body.error.includes('Wartungs-Abo') && res.body.error.includes('04.11.2026'), `${method} ${pfad}: ${res.body.error}`) + assert.equal(res.body.error, 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren. Ab dem 04.11.2026 löschen wir die Daten.') + } + assert.equal(aw.docs('termineBookings').get('b_alt').status, 'bestaetigt') +}) + +test('abgeschaltet, weil das Abo vor der Einrichtung endete: Meldung nennt "eingerichtet" und das Löschdatum', async () => { + await seedAbgeschaltet({ disabledReason: 'abo_ende_vor_einrichtung', disabledAt: '2026-10-04T10:00:00.000Z' }) + const lesen = await send('GET', einstellungenUrl('p4'), { cookie }) + assert.deepEqual(lesen.body.abgeschaltet, { grund: 'abo_ende_vor_einrichtung', seit: '2026-10-04T10:00:00.000Z', loeschungAm: '2026-11-03' }) + for (const [method, pfad, body] of schreibVersuche('p4', lesen.body.einstellung)) { + const res = await send(method, pfad, { cookie, body }) + assert.equal(res.status, 409, `${method} ${pfad}`) + assert.equal(res.body.code, 'abgeschaltet') + assert.ok(res.body.error.includes('eingerichtet') && res.body.error.includes('Wartungs-Abo endete'), `${method} ${pfad}: ${res.body.error}`) + assert.ok(res.body.error.endsWith('Ab dem 03.11.2026 löschen wir die Daten.'), res.body.error) + assert.ok(!/exportieren/.test(res.body.error), 'es gibt nichts zu exportieren') + } +}) + +test('abgeschaltet durch das Team oder wegen gelöschtem Konto: bisheriger Satz, mit Zeitpunkt zusätzlich das Löschdatum', async () => { + for (const grund of ['team', 'kunde_geloescht']) { + await seedAbgeschaltet({ disabledReason: grund, disabledAt: '2026-10-04T23:30:00.000Z' }) + const lesen = await send('GET', einstellungenUrl('p4'), { cookie }) + assert.deepEqual(lesen.body.abgeschaltet, { grund, seit: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' }) + const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: 'p4', version: 1, einstellung: lesen.body.einstellung } }) + assert.equal(res.status, 409, grund) + assert.equal(res.body.code, 'abgeschaltet') + assert.equal(res.body.error, `${MELDUNG_ALT} Ab dem 04.11.2026 löschen wir die Daten.`, grund) + } +}) + +test('abgeschaltet mit unlesbarem Zeitpunkt: Grund bleibt, Löschtag fehlt und die Meldung nennt kein Datum', async () => { + await seedAbgeschaltet({ disabledReason: 'abo_ende', disabledAt: 'kein Datum' }) + const lesen = await send('GET', einstellungenUrl('p4'), { cookie }) + assert.deepEqual(lesen.body.abgeschaltet, { grund: 'abo_ende', seit: 'kein Datum', loeschungAm: null }) + const res = await send('PATCH', '/api/termine/einstellungen', { cookie, body: { projectId: 'p4', version: 1, einstellung: lesen.body.einstellung } }) + assert.equal(res.status, 409) + assert.equal(res.body.error, 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren.') +}) + +test('aktive Funktion: abgeschaltet ist null, auch wenn am Dokument noch Reste einer früheren Abschaltung stehen', async () => { + await speichere() + assert.equal((await lade()).abgeschaltet, null) + aw.seed('portalFeatures', featureDoc({ disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' })) + resetKontextForTests() + const body = await lade() + assert.equal(body.state, 'active') + assert.equal(body.abgeschaltet, null) +}) + // ---------------------------------------------------------------- Kundenansicht test('Kundenansicht darf PATCH, POST und DELETE (mit Header) und es wird protokolliert; ohne Gastdaten im Protokoll', async () => { diff --git a/test/termineView.test.js b/test/termineView.test.js index 8ab2d0f..ab3894d 100644 --- a/test/termineView.test.js +++ b/test/termineView.test.js @@ -2,7 +2,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync, readdirSync } from 'node:fs' import { - FILTER, ABSAGE_GRUENDE, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag, + FILTER, ABSAGE_GRUENDE, abschaltText, anfragenText, bereichFuer, datumKurz, datumLang, endeZeit, fensterParsen, fensterText, filterStatus, gruppiereNachTag, fehltText, fensterAufteilen, heuteBerlin, istSperre, istTag, monatVon, moeglicheAktionen, ressourcenWort, statusLabel, statusTon, tagPlus, tagUeberschrift, verschiebeTag, wocheVon, zeitNormal, zeitraumTitel, } from '../public/termine-view.js' import { @@ -252,6 +252,49 @@ test('fehltText: Pflichtangaben für Gäste in Worten', () => { }) // ---- Verdrahtung (Portal-Ansicht #termine) +test('abschaltText: Text je Abschaltgrund, mit und ohne Löschdatum, Du-Form', () => { + const alt = 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und ' + const loeschen = ' Ab dem 04.11.2026 löschen wir die Daten.' + // Abo beendet: Wartungs-Abo im Text, Export bleibt + assert.deepEqual(abschaltText({ grund: 'abo_ende', loeschungAm: '2026-11-04' }), { + davor: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und ', + danach: `.${loeschen}`, + mitExport: true, + }) + assert.deepEqual(abschaltText({ grund: 'abo_ende', loeschungAm: null }), { + davor: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und ', + danach: '.', + mitExport: true, + }) + // Abo endete vor der Einrichtung: nichts zu exportieren, also kein Export-Satz + const vor = abschaltText({ grund: 'abo_ende_vor_einrichtung', loeschungAm: '2026-11-04' }) + assert.equal(vor.mitExport, false) + assert.equal(vor.davor + vor.danach, `Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.${loeschen}`) + const vorOhne = abschaltText({ grund: 'abo_ende_vor_einrichtung', loeschungAm: null }) + assert.equal(vorOhne.mitExport, false) + assert.equal(vorOhne.davor + vorOhne.danach, 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.') + // Team, gelöschtes Konto, kein Grund (Altbestand), unbekannter Grund, fehlende Angabe: der bisherige Satz, mit Datum ergänzt + for (const grund of ['team', 'kunde_geloescht', null, undefined, 'frist', 5]) { + assert.deepEqual(abschaltText({ grund, loeschungAm: '2026-11-04' }), { davor: alt, danach: `.${loeschen}`, mitExport: true }, String(grund)) + assert.deepEqual(abschaltText({ grund, loeschungAm: null }), { davor: alt, danach: '.', mitExport: true }, String(grund)) + } + assert.deepEqual(abschaltText(), { davor: alt, danach: '.', mitExport: true }) + assert.deepEqual(abschaltText(null), { davor: alt, danach: '.', mitExport: true }) + // mitExport ist nur bei vor_einrichtung falsch + for (const grund of ['abo_ende', 'team', 'kunde_geloescht', null]) assert.equal(abschaltText({ grund }).mitExport, true, String(grund)) +}) + +test('abschaltText: ein Datum, das kein Berliner Kalendertag ist, wird nicht angezeigt; nie Sie/Ihr', () => { + for (const kaputt of ['04.11.2026', '2026-13-45', '', 'morgen', 20261104, {}]) { + const t = abschaltText({ grund: 'abo_ende', loeschungAm: kaputt }) + assert.ok(!/löschen/.test(t.davor + t.danach), `kein Datum für ${JSON.stringify(kaputt)}`) + } + for (const grund of ['abo_ende', 'abo_ende_vor_einrichtung', 'team', 'kunde_geloescht', null]) { + const t = abschaltText({ grund, loeschungAm: '2026-11-04' }) + assert.doesNotMatch(t.davor + t.danach, /\bSie\b|\bIhr(e[nmrs]?)?\b/, String(grund)) + } +}) + const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8') test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminplaner-Module', () => { diff --git a/test/ui/termine-portal.ui.mjs b/test/ui/termine-portal.ui.mjs index 8fe8b0f..71bd362 100644 --- a/test/ui/termine-portal.ui.mjs +++ b/test/ui/termine-portal.ui.mjs @@ -87,6 +87,7 @@ function neueFixture() { fx.daten = (state = fx.projekte[0].features.termine.state) => ({ state, aktivSeit: '2026-10-01T09:00:00.000Z', einstellung: fx.einstellung, version: fx.einstellung ? 3 : 0, vorlagen: fx.vorlagen, feeds: {}, siteKey: KEY, einbau: { snippet: '' }, anfragenOffen: fx.buchungen.filter((b) => b.status === 'angefragt').length || fx.anfragenOffen, fehlt: fx.fehlt, + abgeschaltet: state === 'disabled' ? fx.abgeschaltet ?? { grund: null, seit: null, loeschungAm: null } : null, }) return fx } @@ -735,6 +736,49 @@ async function pruefungen() { })(), }) + // ------------------------------------------------------------ Abgeschaltet nach Grund (E11) + await check('fx_abgeschaltet_abo_ende', async (page) => { + await oeffne(page, '#termine') + await warteAnsicht(page) + const hinweis = page.locator('.tm-box-warn') + await hinweis.waitFor() + const text = await textVon(hinweis) + assert(text.includes('Wartungs-Abo') && text.includes('04.11.2026'), `Banner nennt Abo und Löschdatum: ${text}`) + assert(text.includes('nur noch ansehen'), `Banner: ${text}`) + eq(await page.locator('.tm-box-warn a').count(), 1, 'genau ein Link im Banner') + eq(await page.locator('.tm-box-warn a').innerText(), 'exportieren', 'Linktext') + eq(await page.locator('.tm-box-warn a').getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link') + eq(await page.locator('[data-werkzeug]').count(), 0, 'keine Schreib-Knöpfe') + await shot(page, 'fx-abgeschaltet-abo-ende') + }, { + fixture: (() => { + const fx = neueFixture() + fx.projekte[0].features.termine = { state: 'disabled' } + fx.abgeschaltet = { grund: 'abo_ende', seit: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' } + return fx + })(), + }) + + await check('fx_abgeschaltet_vor_einrichtung', async (page) => { + await oeffne(page, '#termine') + await warteAnsicht(page) + const hinweis = page.locator('.tm-box-warn') + await hinweis.waitFor() + const text = await textVon(hinweis) + assert(text.includes('eingerichtet') && text.includes('Wartungs-Abo') && text.includes('04.11.2026'), `Banner: ${text}`) + assert(!text.includes('exportieren'), `Banner ohne Export-Satz: ${text}`) + eq(await hinweis.locator('a').count(), 0, 'kein Link: es gibt nichts zu exportieren') + assert((await textVon(page.locator('.tm-info'))).includes('Keine Termine eingerichtet'), 'Info-Karte') + }, { + fixture: (() => { + const fx = neueFixture() + fx.projekte[0].features.termine = { state: 'disabled' } + fx.einstellung = null + fx.abgeschaltet = { grund: 'abo_ende_vor_einrichtung', seit: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' } + return fx + })(), + }) + // ------------------------------------------------------------ Datumssprung mit Unsinn await check('fx_datumssprung_unsinn', async (page) => { await oeffne(page, '#termine') @@ -1037,6 +1081,7 @@ async function pruefungen() { await warteAnsicht(page) const hinweis = await textVon(page.locator('.tm-box-warn')) assert(hinweis.includes('abgeschaltet') && hinweis.includes('nur noch ansehen'), `Banner: ${hinweis}`) + assert(!hinweis.includes('Wartungs-Abo') && !hinweis.includes('löschen'), `ohne Grund und Zeitpunkt der bisherige Satz: ${hinweis}`) eq(await page.locator('.tm-box-warn a').getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link') eq(await page.locator('[data-werkzeug]').count(), 0, 'keine Schreib-Knoepfe oben') await page.getByRole('button', { name: 'Tag', exact: true }).click() @@ -1049,6 +1094,28 @@ async function pruefungen() { eq(r, 409, 'Server: Schreiben bei abgeschalteter Funktion') }) + await check('echt_abgeschaltet_abo_ende', async (page) => { + await srv.setzeFunktion('disabled', { disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' }) + await oeffne(page, '#termine') + await warteAnsicht(page) + const hinweis = page.locator('.tm-box-warn') + await hinweis.waitFor() + const text = await textVon(hinweis) + assert(text.includes('Wartungs-Abo') && text.includes('04.11.2026'), `Banner nennt Abo und Löschdatum: ${text}`) + eq(await page.locator('.tm-box-warn a').count(), 1, 'genau ein Link im Banner') + eq(await page.locator('.tm-box-warn a').innerText(), 'exportieren', 'Linktext') + eq(await page.locator('.tm-box-warn a').getAttribute('href'), '/api/termine/export.csv?projectId=p1', 'Export-Link') + // die Schnittstelle nennt dasselbe beim Schreiben + const antwort = await page.evaluate(async () => { + const res = await fetch('/api/termine/buchungen/b_alt/aktion', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: 'p1', aktion: 'annehmen' }) }) + return { status: res.status, body: await res.json() } + }) + eq(antwort.status, 409, 'Server: Schreiben bei abgeschalteter Funktion') + eq(antwort.body.code, 'abgeschaltet', 'Code bleibt') + assert(antwort.body.error.includes('Wartungs-Abo') && antwort.body.error.includes('04.11.2026'), `Meldung: ${antwort.body.error}`) + await shot(page, 'echt-abgeschaltet-abo-ende') + }) + await check('echt_390_liste_und_formular', async (page) => { srv.aw.seed('termineBookings', { $id: 'b_390', featureId: srv.FEATURE, customerId: srv.KUNDE, projectId: srv.PROJEKT, leistungId: S_DAMEN, leistungName: 'Damenschnitt', ressourceId: R_ANNA, ressourceName: 'Anna', diff --git a/test/ui/termineTestServer.mjs b/test/ui/termineTestServer.mjs index 02ad472..db125e3 100644 --- a/test/ui/termineTestServer.mjs +++ b/test/ui/termineTestServer.mjs @@ -188,13 +188,13 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false, setzeUhr(datum) { versatz = new Date(datum).getTime() - Date.now() }, /** Portal-Funktion des Termin-Features (portalFeatures-Dokument). */ - featureDoc(state = 'active') { - return { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state, enabled: state === 'active', siteKey: SITE_KEY, activatedAt: '2026-10-01T09:00:00.000Z' } + featureDoc(state = 'active', extra = {}) { + return { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine', state, enabled: state === 'active', siteKey: SITE_KEY, activatedAt: '2026-10-01T09:00:00.000Z', ...extra } }, - /** Zustand der Funktion aendern ('active' | 'disabled' | 'off'); der Kontext-Speicher des Servers wird geleert. */ - async setzeFunktion(state) { - aw.seed('portalFeatures', srv.featureDoc(state)) + /** Zustand der Funktion aendern ('active' | 'disabled' | 'off'); extra: weitere Felder des Dokuments (z. B. disabledReason, disabledAt). Der Kontext-Speicher des Servers wird geleert. */ + async setzeFunktion(state, extra = {}) { + aw.seed('portalFeatures', srv.featureDoc(state, extra)) resetKontextForTests() },