From e30318a4703aa6dc435b49a90ef7279303dbd2e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:07:30 +0000 Subject: [PATCH] Kundenansicht P5: POST /api/funktionen/:key/einschalten (nur neue Ansicht) Co-Authored-By: Claude Sonnet 5.5 --- server/routes/funktionen.js | 112 ++++++++- server/services/funktionEinschalten.js | 36 +-- test/funktionenEinschaltenRoute.test.js | 298 ++++++++++++++++++++++++ 3 files changed, 431 insertions(+), 15 deletions(-) create mode 100644 test/funktionenEinschaltenRoute.test.js diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 35778e0..4acbbf2 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -25,7 +25,11 @@ import { zeitenFuerKunde } from '../services/zeiten/schalter.js' import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../services/zeiten/einschalten.js' import { starteBehalten } from '../services/zeiten/behalten.js' import { aboMemo } from '../services/aboStatus.js' -import { featureConfig, featureHaken, featureKeys, featureSperre, freiweg, oeffentlicheAnsicht } from '../services/featureRegistry.js' +import { aboProdukt } from '../services/aboFunktionen.js' +import { datumDe, einschalten, EINSCHALTEN_TEXTE, pruefeAboDeckung, pruefeEinschaltZustand } from '../services/funktionEinschalten.js' +import { kundenartWirksam, NUR_BETRIEB_TEXT } from '../services/kundenart.js' +import { neueAnsicht } from '../services/kundenansicht/schalter.js' +import { aboweg, consentAbo, consentAboAktiv, featureConfig, featureHaken, featureKeys, featureSperre, freiweg, katalogFelder, oeffentlicheAnsicht } from '../services/featureRegistry.js' import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js' import { @@ -390,7 +394,10 @@ const EINSCHALTEN_ERROR = 'Öffnungszeiten live konnte gerade nicht eingeschalte // 1 Kundenansicht, 2 Zustimmung (Gratis-Fassung: ein Haken, Knopf "Einschalten"), 3 Projekt (eigen, nicht archiviert), // 4 Schalter/Pilot, 5 Unterlagen. Das Abo wird erst unter der Sperre frisch geprueft (einschaltenZeiten), dort entstehen auch Nachweis, // Zustand, Neubau, Ticket und K27; ein zweiter Klick holt Fehlendes nach. -router.post('/zeiten/einschalten', requireSession, async (req, res) => { +// Weiche (Kundenansicht neu): wer die neue Ansicht sieht, geht an POST /:key/einschalten (unten); alle anderen behalten diesen Weg unveraendert +const alteAnsichtNur = (req, _res, next) => (neueAnsicht(req) ? next('route') : next()) + +router.post('/zeiten/einschalten', requireSession, alteAnsichtNur, async (req, res) => { const now = new Date() try { // 1 Kundenansicht: Mitarbeiter schalten nicht fuer den Kunden ein @@ -426,6 +433,107 @@ router.post('/zeiten/einschalten', requireSession, async (req, res) => { } }) +const EINSCHALTEN_NEU_ERROR = 'Die Funktion konnte gerade nicht eingeschaltet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.' +const ZUSTIMMUNG_EINSCHALTEN = 'Bitte bestätige die Erklärung, um einzuschalten.' + +/** Kundendokument einmal lesen; ein unbekannter Kunde ergibt {} (Kundenart privat). */ +async function kundenDokument(customerId) { + try { + return await getDocument(config.collections.customers, customerId) + } catch (err) { + if (err.status === 404) return {} + throw err + } +} + +// Einschalten im Abo-Modell (V5, nur neue Ansicht; Spec 5.4): ohne Zahlung, mit Einrichtungs-Ticket. Vorpruefungen, jede ohne Seiteneffekt: +// 0 neue Ansicht (sonst 404), 1 Kundenansicht (403), 2 Funktionsschluessel, 3 Eingabe (Projekt, Zustimmung), 4 Projekt, +// 5 409 in dieser Reihenfolge: nicht_verfuegbar (dann 503 unterlagen_fehlen), abo_fehlt, nur_betrieb, bestellung_offen, beginn_noetig, +// bereits_eingeschaltet | bestellung_laeuft | abgeschaltet, fassung_veraltet; 6 einschalten() (Sperre, Abo frisch, Nachweis, Zustand, Ticket). +// Die Kundenart kommt nur aus dem Kundendokument, nie aus dem Koerper. +router.post('/:key/einschalten', requireSession, async (req, res) => { + const jetzt = einschaltenDeps.jetzt() + try { + if (!neueAnsicht(req)) return res.status(404).json({ error: 'Nicht gefunden' }) + if (req.session.viewAs) return res.status(403).json({ error: 'In der Kundenansicht kann nicht eingeschaltet werden.' }) + const key = req.params.key + const feature = featureConfig(key) + if (!feature) return res.status(404).json({ error: 'Unbekannte Funktion' }) + const body = req.body || {} + const { projectId, zustimmung } = body + if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Bitte wähle ein Projekt.' }) + const zustimmungOk = + zustimmung !== null && typeof zustimmung === 'object' && !Array.isArray(zustimmung) && typeof zustimmung.version === 'string' && + Array.isArray(zustimmung.haken) && zustimmung.haken.every((id) => id === 'beginn') + if (!zustimmungOk) throw bestellFehler(400, ZUSTIMMUNG_EINSCHALTEN, 'zustimmung_fehlt') + const customerId = getSessionCustomerId(req) + await assertOwnProject(customerId, projectId, { forNewOrder: true }) + + // nicht_verfuegbar (Schalter der Funktion), danach die Vertragsunterlagen + const sperre = featureSperre(key, req.session.email) + if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar') + const haken = featureHaken(key) + await pruefeUnterlagen(haken) + // abo_fehlt: das Abo frisch lesen, Lesefehler sind 503 + await pruefeAboDeckung(customerId, key, jetzt) + // nur_betrieb + const kunde = await kundenDokument(customerId) + const privat = kundenartWirksam(kunde) === 'privat' + if (privat && katalogFelder(key).nurBetrieb) throw bestellFehler(409, NUR_BETRIEB_TEXT, 'nur_betrieb') + // bestellung_offen: offene Punkte der Funktion (Fragebogen, Freigabe der Anfrage) + if (haken.bestellpruefung) { + const offen = await haken.bestellpruefung({ customerId, projectId, feature }) + if (!Array.isArray(offen)) throw new TypeError('bestellpruefung muss eine Liste liefern') + if (offen.length > 0) throw bestellFehler(409, 'Es fehlen noch Angaben, bevor du einschalten kannst.', 'bestellung_offen', { offen }) + } + // beginn_noetig: Privatkunden innerhalb der Widerrufsfrist brauchen das Haekchen fuer den sofortigen Beginn + let fristBis = null + if (privat) { + try { + fristBis = (await aboProdukt(customerId, { now: jetzt })).widerrufsfristBis + } catch (err) { + console.error(`[funktionen] Widerrufsfrist für das Einschalten nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) + throw bestellFehler(503, EINSCHALTEN_TEXTE.nichtPruefbar) + } + } + const beginnNoetig = privat && fristBis !== null && Date.parse(fristBis) >= jetzt.getTime() + const gesetzt = privat && zustimmung.haken.includes('beginn') + if (beginnNoetig && !gesetzt) { + const ab = new Date(Date.parse(fristBis) + 1).toISOString() + throw bestellFehler(409, `Für den sofortigen Beginn brauchst du das Häkchen. Ohne Häkchen kannst du ab dem ${datumDe(ab)} einschalten.`, 'beginn_noetig', { ab }) + } + // bereits_eingeschaltet | bestellung_laeuft | abgeschaltet (setup ohne Ticket durch Abo oder Projekt heilt der Dienst) + pruefeEinschaltZustand(await getFeatureDoc(customerId, projectId, key)) + // fassung_veraltet: der Kunde hat einen alten Wortlaut gesehen + if (zustimmung.version !== consentAbo(key).version) { + throw bestellFehler(409, 'Die Erklärung wurde inzwischen geändert. Bitte lies sie noch einmal und schalte dann ein.', 'fassung_veraltet', { + consent: consentAboAktiv(key, { beginn: beginnNoetig }), + }) + } + + const { doc, ticketWoid } = await einschalten({ + customerId, + projectId, + key, + by: 'kunde', + abrechnung: 'abo', + now: jetzt, + // Nachweis ohne Stripe-Sitzung und ohne Belehrung; ein mitgeschicktes beginn zaehlt nur fuer Privatkunden + nachweis: () => + speichereNachweisAusAnfrage(req, { + feature: aboweg(feature, { beginn: gesetzt }), + customerId, + projectId, + klickAt: jetzt.toISOString(), + kundenart: kundenartWirksam(kunde), + }), + }) + res.json({ feature: await featureView(doc, key), ticketWoid }) + } catch (err) { + fail(res, err, EINSCHALTEN_NEU_ERROR) + } +}) + const BEHALTEN_ERROR = 'Das Behalten konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.' // Behalten (Spec 3.3): nach dem Ende des Wartungs-Abos Öffnungszeiten live fuer einmalig 30 € behalten, ohne neuen Einbau. Vorpruefungen wie im diff --git a/server/services/funktionEinschalten.js b/server/services/funktionEinschalten.js index 647af40..24f4533 100644 --- a/server/services/funktionEinschalten.js +++ b/server/services/funktionEinschalten.js @@ -34,6 +34,14 @@ export const EINSCHALTEN_TEXTE = Object.freeze({ nichtPruefbar: 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.', }) +/** Berliner Kalendertag eines Zeitpunkts (ISO) als TT.MM.JJJJ; kein Zeitpunkt ist ein TypeError. */ +export function datumDe(zeitpunkt) { + const ms = Date.parse(zeitpunkt) + if (Number.isNaN(ms)) throw new TypeError(`datumDe: kein Zeitpunkt: ${String(zeitpunkt)}`) + const [jahr, monat, tag] = berlinDay(new Date(ms)).split('-') + return `${tag}.${monat}.${jahr}` +} + /** * Kundentext zu einem Abo-Grund. Bei beginnt_spaeter steht der Berliner Tag von `ab` (ISO) als TT.MM.JJJJ im Text, * z. B. "Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten." @@ -41,11 +49,7 @@ export const EINSCHALTEN_TEXTE = Object.freeze({ export function aboFehlerText(grund, ab = null) { if (!Object.hasOwn(EINSCHALTEN_TEXTE.abo, grund)) throw new TypeError(`aboFehlerText: unbekannter Grund ${String(grund)}`) const text = EINSCHALTEN_TEXTE.abo[grund] - if (grund !== 'beginnt_spaeter') return text - const ms = Date.parse(ab) - if (Number.isNaN(ms)) throw new TypeError('aboFehlerText: beginnt_spaeter braucht ab als Zeitpunkt') - const [jahr, monat, tag] = berlinDay(new Date(ms)).split('-') - return text.replace('{datum}', `${tag}.${monat}.${jahr}`) + return grund === 'beginnt_spaeter' ? text.replace('{datum}', datumDe(ab)) : text } /** 409 abo_fehlt: body.grund, body.wechsel (Hosting-Kunde: Wechsel ins Wartungs-Abo anbieten) und bei beginnt_spaeter body.ab. */ @@ -54,8 +58,11 @@ const aboFehlt = (grund, ab) => const konflikt = (code, text) => bestellFehler(409, text, code) -/** Abo frisch lesen und gegen die Funktion pruefen (immer ohne Zwischenspeicher); Lesefehler sind 503, nie stillschweigend erlaubt. */ -async function pruefeAbo(customerId, key, now) { +/** + * Abo frisch lesen und gegen die Funktion pruefen (immer ohne Zwischenspeicher): 409 abo_fehlt oder nichts. Lesefehler sind 503, + * nie stillschweigend erlaubt. Auch fuer die Vorpruefungen der Route. + */ +export async function pruefeAboDeckung(customerId, key, now = new Date()) { let ergebnis try { ergebnis = await aboDeckt(customerId, key, { now }) @@ -69,8 +76,11 @@ async function pruefeAbo(customerId, key, now) { /** setup ohne Ticket und durch Abo oder Projekt eingeschaltet: der Dienst heilt (nur das Ticket), es entsteht nichts Neues. */ const heilbar = (doc) => doc?.state === 'setup' && !doc.ticketId && ['abo', 'projekt'].includes(unlockedByVon(doc)) -/** Zustand des Dokuments pruefen: wirft den Konflikt mit Code; Heilung und ein frisches oder ausgeschaltetes Dokument sind frei. */ -function pruefeZustand(doc) { +/** + * Zustand des Dokuments pruefen: wirft den 409 mit Code (bestellung_laeuft, abgeschaltet, bereits_eingeschaltet); ein fehlendes oder + * ausgeschaltetes (off) Dokument und die Heilung (setup ohne Ticket, durch Abo oder Projekt) sind frei. Auch fuer die Vorpruefungen der Route. + */ +export function pruefeEinschaltZustand(doc) { if (!doc || doc.state === 'off') return if (doc.state === 'ordered') throw konflikt('bestellung_laeuft', EINSCHALTEN_TEXTE.laeuft) if (doc.state === 'disabled') throw konflikt('abgeschaltet', EINSCHALTEN_TEXTE.abgeschaltet) @@ -118,8 +128,8 @@ export async function einschalten({ customerId, projectId, key, by, abrechnung, // liefert dieselben Codes wie unten (nur fuer den Kunden, das Team schaltet wie bisher) if (key === 'zeiten') { const vorpruefung = async () => { - await pruefeAbo(customerId, key, now) - pruefeZustand(await getFeatureDoc(customerId, projectId, key)) + await pruefeAboDeckung(customerId, key, now) + pruefeEinschaltZustand(await getFeatureDoc(customerId, projectId, key)) } const { doc } = await einschaltenZeiten({ customerId, projectId, by, staff, nachweis, now, abrechnung, vorpruefung }) return { doc, ticketWoid: await ticketWoid(doc.ticketId) } @@ -127,11 +137,11 @@ export async function einschalten({ customerId, projectId, key, by, abrechnung, const doc = await withLocks([`feature-order:${customerId}:${projectId}`], async () => { // 1 Abo frisch (nie aus einem Zwischenspeicher); fuer das Team entfaellt es - if (by === 'kunde') await pruefeAbo(customerId, key, now) + if (by === 'kunde') await pruefeAboDeckung(customerId, key, now) // 2 Zustand: Heilung (setup ohne Ticket, durch Abo oder Projekt) legt nur das Ticket an const bisher = await getFeatureDoc(customerId, projectId, key) - pruefeZustand(bisher) + pruefeEinschaltZustand(bisher) if (heilbar(bisher)) { const gespeichert = Object.hasOwn(ABRECHNUNGS_ZEILEN, bisher.einrichtungAbrechnung) ? bisher.einrichtungAbrechnung : abrechnung return legeTicketAn(bisher, { key, feature, project, customerId, abrechnung: gespeichert }) diff --git a/test/funktionenEinschaltenRoute.test.js b/test/funktionenEinschaltenRoute.test.js new file mode 100644 index 0000000..59332d7 --- /dev/null +++ b/test/funktionenEinschaltenRoute.test.js @@ -0,0 +1,298 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' + +// POST /api/funktionen/:key/einschalten (V5): nur in der neuen Ansicht, Pruefreihenfolge, Nachweis mit Kundenart, Weiche an /zeiten/einschalten +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + featureConsents: schemaKeys('featureConsents'), + shopConfig: schemaKeys('shopConfig'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + }, + datetimes: { + ...ZEITEN_DATETIMES, + portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'], + featureConsents: ['acceptedAt'], + }, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, post, sessionCookie } = await import('./support/testApp.js') +const { customerCookie, customers, hostingDoc, kundenansichtCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') +const { zeitenDeps } = await import('../server/services/zeiten/neubau.js') +const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') +const { einschaltenDeps } = await import('../server/services/zeiten/einschalten.js') +const { consentAbo } = await import('../server/services/featureRegistry.js') +const { EINSCHALTEN_TEXTE } = await import('../server/services/funktionEinschalten.js') + +const app = await startTestApp({ '/api/funktionen': funktionenRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) +after(() => setMailTransportForTests(null)) + +const JETZT = new Date('2026-10-17T08:00:00.000Z') // ein Samstag +const c1 = customerCookie('c1') +const vorher = { + neu: { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] }, + termine: config.termine.mode, + shop: config.shop.mode, + zeiten: { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] }, + unterlagen: Object.fromEntries(['news', 'termine', 'shop', 'unterkunft', 'zeiten'].map((k) => [k, config.features[k].unterlagenBereit])), +} +after(() => { + Object.assign(config.kundenansichtNeu, vorher.neu) + config.termine.mode = vorher.termine + config.shop.mode = vorher.shop + Object.assign(config.zeiten, vorher.zeiten) + for (const [k, fn] of Object.entries(vorher.unterlagen)) { + if (fn === undefined) delete config.features[k].unterlagenBereit + else config.features[k].unterlagenBereit = fn + } +}) + +const Z = (key, haken = []) => ({ version: consentAbo(key)?.version ?? 'unbekannt', haken }) // consentAbo aus featureRegistry.js (null für analyse und unbekannte Schlüssel) +const go = (key, body = {}, cookie = c1) => post(`/api/funktionen/${key}/einschalten`, { projectId: 'p1', zustimmung: Z(key), ...body }, { cookie }) +const abschluss = (contractAt = JETZT.toISOString()) => + aw.seed('contractEvents', { $id: 'ev_a', customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'wartung', status: 'erledigt', consentAt: contractAt, details: JSON.stringify({ contractAt }) }) +const nichts = () => assert.deepEqual([aw.docs('portalFeatures').size, aw.docs('workorders').size, aw.docs('featureConsents').size], [0, 0, 0]) +// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht und geaendert +const leere = async (...collections) => { + for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id) +} +const projekt = (id, over = {}) => aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over }) + +let sink +const originalRebuild = zeitenDeps.rebuild +beforeEach(async () => { + await leere('subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects', 'contractEvents', 'shopConfig') + resetZeitenCacheForTests() + seedFixtures(aw) + aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc)) + for (const id of ['p1', 'p1b', 'p1c']) projekt(id) + projekt('p_archiv', { archived: true }) + projekt('p_fremd', { customerId: 'c2' }) + config.kundenansichtNeu.mode = 'pilot' + config.kundenansichtNeu.pilotEmails = ['k@example.invalid', 'test@example.invalid'] + config.termine.mode = config.shop.mode = 'on' + config.zeiten.mode = 'pilot' + config.zeiten.pilotEmails = ['*'] + for (const k of ['news', 'termine', 'shop', 'unterkunft', 'zeiten']) config.features[k].unterlagenBereit = async () => true + einschaltenDeps.jetzt = () => JETZT + sink = mailSink() + setMailTransportForTests(sink) + zeitenDeps.rebuild = async () => ({ queued: true }) + mock.method(console, 'error', () => {}) + mock.method(console, 'warn', () => {}) + mock.method(console, 'log', () => {}) +}) +afterEach(() => { + mock.restoreAll() + zeitenDeps.rebuild = originalRebuild + einschaltenDeps.jetzt = () => new Date() +}) + +test('Erfolg (Betrieb, Wartung): Form, Zustand, Ticket, Nachweis mit Kundenart, keine Mail, kein Preis', async () => { + aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) + const r = await go('news') + assert.deepEqual([r.status, Object.keys(r.body).sort(), r.body.feature.state, r.body.feature.unlockedBy], [200, ['feature', 'ticketWoid'], 'setup', 'abo']) + assert.equal(r.body.ticketWoid, r.body.feature.ticketWoid) + assert.equal(aw.docs('workorders').size, 1) + assert.deepEqual([sink.sent.length, st.calls.length], [0, 0], 'keine Mail, kein Stripe') + const n = [...aw.docs('featureConsents').values()][0] + assert.deepEqual([JSON.parse(n.texte).map((t) => t.id), n.kundenart, n.stripeSessionId], [['einschalten-zeile', 'knopf'], 'unternehmen', '']) + assert.doesNotMatch(JSON.stringify(r.body), /€/) + assert.equal(n.acceptedAt.slice(0, 19), '2026-10-17T08:00:00', 'Zeit des Klicks aus der Uhr der Route') +}) + +test('Einschalten nur in neuer Ansicht (Pilot-Ränder)', async () => { + const faelle = [['Kunde nicht in der Liste', 'pilot', ['k2@example.invalid']], ['off schlägt *', 'off', ['*']], ['Pilot mit leerer Liste', 'pilot', []]] + for (const [name, mode, liste] of faelle) { + config.kundenansichtNeu.mode = mode + config.kundenansichtNeu.pilotEmails = liste + assert.equal((await go('news')).status, 404, name) + nichts() + } + config.kundenansichtNeu.mode = 'pilot' + config.kundenansichtNeu.pilotEmails = ['*'] + assert.equal((await go('news', { projectId: 'p1b' })).status, 200) // * lässt jeden Kunden durch + config.kundenansichtNeu.pilotEmails = ['test@example.invalid'] // Session-E-Mail mit Großbuchstaben und Leerzeichen trifft die Liste (nicht 404) + const schreibweise = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', name: 'Kunde Eins', email: ' TEST@Example.INVALID ' }) + assert.notEqual((await go('news', { projectId: 'p1c' }, schreibweise)).status, 404) + assert.equal((await go('news', {}, kundenansichtCookie({ customerId: 'c1' }))).status, 403) // Mitarbeiter-Kundenansicht im Pilot: gesperrt, nicht 404 + assert.equal((await post('/api/funktionen/news/einschalten', {})).status, 401) + for (const key of ['analyse', 'foo', '__proto__']) assert.equal((await go(key)).status, 404, key) +}) + +test('Weiche an /zeiten/einschalten: alte Ansicht alter Weg, neue Ansicht neuer Weg', async () => { + config.kundenansichtNeu.mode = 'off' + const alt = await post('/api/funktionen/zeiten/einschalten', { projectId: 'p1', zustimmung: { version: config.features.zeiten.consentFrei.version, haken: config.features.zeiten.consentFrei.items.filter((i) => i.haken).map((i) => i.id) } }, { cookie: c1 }) + assert.deepEqual([alt.status, Object.keys(alt.body).sort()], [200, ['einbau', 'feature']]) + await leere('portalFeatures') + config.kundenansichtNeu.mode = 'pilot' + const neu = await go('zeiten') + assert.deepEqual([neu.status, Object.keys(neu.body).sort()], [200, ['feature', 'ticketWoid']]) +}) + +test('Kundenart (leer, unbekannt, gefälscht) und Frist: beginn_noetig mit ab, Häkchen, Betrieb ohne Häkchen', async () => { + abschluss() // Samstag 17.10. + 14 Tage -> Frist bis Mo 02.11., ab = Di 03.11. 00:00 Berlin + for (const kundenart of [undefined, '', 'kaputt', 'Unternehmen']) { + aw.seed('customers', { ...customers.c1, ...(kundenart === undefined ? {} : { kundenart }) }) + const r = await go('news', { kundenart: 'unternehmen', unternehmer: true }) + assert.deepEqual([r.status, r.body.code, r.body.ab], [409, 'beginn_noetig', '2026-11-02T23:00:00.000Z'], String(kundenart)) + assert.match(r.body.error, /03\.11\.2026/) + nichts() + } + assert.equal((await go('news', { zustimmung: Z('news', ['beginn']) })).status, 200) + const p = JSON.parse([...aw.docs('featureConsents').values()][0].texte) + assert.deepEqual(p.map((t) => [t.id, t.haken]), [['einschalten-zeile', false], ['beginn', true], ['knopf', false]]) + await leere('portalFeatures', 'workorders', 'featureConsents') + aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) + assert.equal((await go('news', { zustimmung: Z('news', ['beginn']) })).status, 200) // Betrieb: beginn wird ignoriert + assert.deepEqual(JSON.parse([...aw.docs('featureConsents').values()][0].texte).map((t) => t.id), ['einschalten-zeile', 'knopf']) + await leere('portalFeatures') + aw.seed('customers', { ...customers.c1 }) + abschluss('2026-09-01T09:00:00.000Z') // privat, Frist vorbei: kein Häkchen nötig + assert.equal((await go('news')).status, 200) +}) + +test('Prüfreihenfolge der 409-Codes', async () => { + config.termine.mode = 'off' + await leere('subscriptions') + assert.equal((await go('termine')).body.code, 'nicht_verfuegbar') + config.termine.mode = 'on' + assert.equal((await go('termine')).body.code, 'abo_fehlt') // vor nur_betrieb + aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc)) + assert.equal((await go('shop')).body.code, 'nur_betrieb') // privat, vor bestellung_offen + aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) + const o = await go('shop') + assert.deepEqual([o.body.code, o.body.offen], ['bestellung_offen', ['Deine Webshop-Anfrage ist noch nicht freigegeben.']]) + assert.equal((await go('news')).status, 200) + assert.equal((await go('news', { zustimmung: { version: 'alt', haken: [] } })).body.code, 'bereits_eingeschaltet') // vor fassung_veraltet + const alt = await go('zeiten', { zustimmung: { version: 'alt', haken: [] } }) + assert.deepEqual([alt.status, alt.body.code, alt.body.consent.version], [409, 'fassung_veraltet', config.aboFunktionen.fassung]) + assert.equal(alt.body.error, 'Die Erklärung wurde inzwischen geändert. Bitte lies sie noch einmal und schalte dann ein.') +}) + +test('Hinweis (Pflichten der alten Häkchen): der Satz der Funktion steht im Nachweis; news hat keinen', async () => { + aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) + const alt = ['termine', 'shop'].map((k) => config.features[k].bestellpruefung) + for (const k of ['termine', 'shop']) config.features[k].bestellpruefung = async () => [] // Fragebogen und Freigabe sind hier nicht das Thema + try { + for (const key of ['termine', 'shop']) { + const r = await go(key) + assert.equal(r.status, 200, `${key} ${JSON.stringify(r.body)}`) + const t = JSON.parse([...aw.docs('featureConsents').values()].at(-1).texte) + assert.deepEqual(t.map((x) => x.id), ['einschalten-zeile', 'hinweis', 'knopf'], key) + assert.equal(t[1].text, consentAbo(key).items[1].text, key) + assert.equal(t[1].haken, false) + } + } finally { + ['termine', 'shop'].forEach((k, i) => { + if (alt[i] === undefined) delete config.features[k].bestellpruefung + else config.features[k].bestellpruefung = alt[i] + }) + } + assert.match(consentAbo('shop').items[1].text, /eigenem Namen.*eigenes Stripe-Konto/) + assert.match(consentAbo('termine').items[1].text, /Gesundheitsbereich/) + await leere('featureConsents') + assert.equal((await go('news')).status, 200) + assert.deepEqual(JSON.parse([...aw.docs('featureConsents').values()][0].texte).map((x) => x.id), ['einschalten-zeile', 'knopf']) +}) + +test('Probezeit (Privatkunde bestellte das Abo ohne Häkchen): abo_fehlt beginnt_spaeter mit Datum statt Zahlungsmeldung', async () => { + aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-11-03T05:00:00.000Z' })) + const r = await go('news') + assert.deepEqual([r.status, r.body.code, r.body.grund, r.body.wechsel, r.body.ab], [409, 'abo_fehlt', 'beginnt_spaeter', false, '2026-11-03T05:00:00.000Z']) + assert.equal(r.body.error, 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.') + assert.doesNotMatch(r.body.error, /Zahlung/) + nichts() + aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-10-01T05:00:00.000Z' })) // Probezeit vorbei, Stripe noch nicht nachgezogen + assert.equal((await go('news')).body.grund, 'nicht_aktiv') +}) + +test('Doppelt und widersprüchlich: nacheinander, gleichzeitig, zweite Website, Zustände', async () => { + aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) + assert.deepEqual([(await go('news')).status, (await go('news')).body.code], [200, 'bereits_eingeschaltet']) + assert.deepEqual([aw.docs('workorders').size, aw.docs('featureConsents').size], [1, 1]) + assert.equal((await go('news', { projectId: 'p1b' })).status, 200) + assert.equal(aw.docs('workorders').size, 2) // zweite Website, eigenes Ticket + const gl = await Promise.all([go('news', { projectId: 'p1c' }), go('news', { projectId: 'p1c' })]) + assert.deepEqual(gl.map((x) => x.status).sort(), [200, 409]) + assert.equal(aw.docs('workorders').size, 3) + await updateDocument('portalFeatures', 'f_p1_news', { state: 'active' }) + assert.equal((await go('news')).body.code, 'bereits_eingeschaltet') + await updateDocument('portalFeatures', 'f_p1_news', { state: 'ordered' }) + assert.equal((await go('news')).body.code, 'bestellung_laeuft') + await updateDocument('portalFeatures', 'f_p1_news', { state: 'disabled' }) + assert.equal((await go('news')).body.code, 'abgeschaltet') + aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'setup', unlockedBy: 'abo', unlockedByPurchase: false }) + assert.equal((await go('news')).status, 200) // setup ohne Ticket: Heilung, kein 409 +}) + +test('Abo-Grenzfälle', async () => { + const faelle = [ + ['Hosting + Wartungs-Funktion', hostingDoc, 'falsches_abo', true], + ['gekündigt', { ...wartungDoc, cancelAtPeriodEnd: true }, 'gekuendigt', false], + ['past_due', { ...wartungDoc, status: 'past_due' }, 'nicht_aktiv', false], + ['beendet', { ...wartungDoc, status: 'canceled' }, 'kein_abo', false], + ] + for (const [name, doc, grund, wechsel] of faelle) { + aw.seed('subscriptions', subscriptionDoc('sub_h', doc)) + const r = await go('termine') + assert.deepEqual([r.status, r.body.code, r.body.grund, r.body.wechsel], [409, 'abo_fehlt', grund, wechsel], name) + } + aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc)) + config.features.news.bestellpruefung = async () => { + await leere('subscriptions') // Abo endet nach der Vorprüfung, vor der Sperre + return [] + } + try { + const spaet = await go('news') + assert.deepEqual([spaet.status, spaet.body.code], [409, 'abo_fehlt']) + nichts() + } finally { + delete config.features.news.bestellpruefung + } +}) + +test('Abo nicht lesbar: 503 mit Text, nichts geschrieben', async () => { + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) + const r = await go('news') + assert.deepEqual([r.status, r.body.error], [503, EINSCHALTEN_TEXTE.nichtPruefbar]) + nichts() +}) + +test('Eingabe und Archiv', async () => { + const faelle = [ + [{ projectId: '' }, 400], + [{ zustimmung: undefined }, 400], + [{ zustimmung: { version: 'x', haken: 'beginn' } }, 400], + [{ zustimmung: { version: 7, haken: [] } }, 400], + [{ zustimmung: Z('news', ['x']) }, 400], + [{ projectId: 'p_fremd' }, 403], + [{ projectId: 'p_archiv' }, 409], + ] + for (const [body, status] of faelle) assert.equal((await go('news', body)).status, status, JSON.stringify(body)) + assert.equal((await go('news', { zustimmung: undefined })).body.code, 'zustimmung_fehlt') + nichts() +}) + +test('Unterlagen nicht abrufbar: 503 unterlagen_fehlen vor dem Abo', async () => { + config.features.news.unterlagenBereit = async () => false + const r = await go('news') + assert.deepEqual([r.status, r.body.code], [503, 'unterlagen_fehlen']) + nichts() +})