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' // Gratisweg "Einschalten" von Oeffnungszeiten live im Wartungs-Abo (Aufgabe 16, Spec 3.1 und 3.2): // POST /api/funktionen/zeiten/einschalten, einschaltenZeiten/holeZeitenNach (zeiten/einschalten.js), pruefeAboFuerEinschalten // (featureOrder.js) und das Angebot in GET /api/funktionen (zeiten, features.zeiten, zeitenStatus, zeitenLeer). const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents'), oeffnungszeiten: schemaKeys('oeffnungszeiten'), zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), }, // Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null) 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, send, post } = await import('./support/testApp.js') const { customerCookie, 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 sf = await import('../server/services/siteFeatures.js') const { zeitenDeps } = await import('../server/services/zeiten/neubau.js') const { einschaltenZeiten, holeZeitenNach, einschaltenDeps } = await import('../server/services/zeiten/einschalten.js') const { pruefeAboFuerEinschalten } = await import('../server/services/featureOrder.js') const { speichereZeiten } = await import('../server/services/zeiten/speicher.js') const { statusZu, hauptplan } = await import('../server/services/zeiten/zeitlogik.js') const { loeschTag } = await import('../server/services/termine/frist.js') const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') const app = await startTestApp({ '/api/funktionen': funktionenRoutes }) after(() => Promise.all([app.close(), aw.close(), st.close()])) after(() => setMailTransportForTests(null)) const Z = config.features.zeiten const MODUS = { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] } const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin const c1 = customerCookie('c1') const KEY_P1 = 'f_p1_zeiten' const KLEIN = 'Öffnungszeiten live' const KEIN_ABO_TEXT = 'Ohne laufendes Wartungs-Abo kannst Du Öffnungszeiten live für einmalig 30 € bestellen.' const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch })) const projekt = (id, over = {}) => aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over }) const einschalten = (body, cookie = c1) => post('/api/funktionen/zeiten/einschalten', body, { cookie }) const haken = () => Z.consentFrei.items.filter((item) => item.haken).map((item) => item.id) const zustimmung = (patch = {}) => ({ version: Z.consentFrei.version, haken: haken(), ...patch }) const koerper = (extra = {}) => ({ projectId: 'p1', zustimmung: zustimmung(), ...extra }) const liste = (cookie = c1) => send('GET', '/api/funktionen', { cookie }) const featureDocs = () => [...aw.docs('portalFeatures').values()] const zeitenDoc = (projectId = 'p1') => aw.docs('portalFeatures').get(`f_${projectId}_zeiten`) const tickets = () => [...aw.docs('workorders').values()] const nachweise = () => [...aw.docs('featureConsents').values()] const spuren = () => ({ features: aw.docs('portalFeatures').size, consents: aw.docs('featureConsents').size, tickets: aw.docs('workorders').size }) const keineSpuren = () => assert.deepEqual({ ...spuren(), mails: sink.sent.length, neubauten: rebuilds.length }, { features: 0, consents: 0, tickets: 0, mails: 0, neubauten: 0 }) let sink let rebuilds const originalRebuild = zeitenDeps.rebuild beforeEach(async () => { for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects']) { for (const id of aw.docs(col).keys()) await deleteDocument(col, id) } resetZeitenCacheForTests() seedFixtures(aw) wartung() projekt('p1') projekt('p3', { customerId: 'c3' }) aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true }) config.zeiten.mode = 'pilot' config.zeiten.pilotEmails = ['*'] for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'freischaltbar']) delete Z[name] sink = mailSink() setMailTransportForTests(sink) rebuilds = [] zeitenDeps.rebuild = async (p) => { rebuilds.push({ projektId: p.$id, tickets: aw.docs('workorders').size, einbau: zeitenDoc(p.$id)?.einbau, state: zeitenDoc(p.$id)?.state }) return { queued: true } } einschaltenDeps.jetzt = () => JETZT mock.method(console, 'error', () => {}) mock.method(console, 'warn', () => {}) mock.method(console, 'log', () => {}) }) afterEach(() => { mock.restoreAll() zeitenDeps.rebuild = originalRebuild einschaltenDeps.jetzt = () => new Date() }) after(() => { config.zeiten.mode = MODUS.mode config.zeiten.pilotEmails = MODUS.pilotEmails }) // -------------------------------------------------------------------------------------------------------- Erfolg test('Einschalten mit laufendem Abo: setup, unlockedBy abo, Nachweis ohne Sitzung', async () => { const res = await einschalten(koerper()) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.deepEqual(Object.keys(res.body).sort(), ['einbau', 'feature']) assert.equal(res.body.einbau, 'angestossen') assert.equal(res.body.feature.state, 'setup') assert.equal(res.body.feature.unlockedBy, 'abo') assert.equal(res.body.feature.einbau, 'angestossen') assert.equal(typeof res.body.feature.ticketWoid, 'string') const doc = zeitenDoc() assert.deepEqual([doc.featureKey, doc.state, doc.enabled, doc.unlockedBy, doc.unlockedByPurchase], ['zeiten', 'setup', false, 'abo', false]) assert.match(doc.siteKey, /^[a-z2-7]{16}$/) assert.equal(doc.einbau, 'angestossen') assert.ok(doc.ticketId) assert.ok(doc.einschaltMailAt) assert.equal(featureDocs().length, 1, 'nur das Dokument der Zeiten') // Nachweis: Fassung und Texte der Gratis-Zustimmung, ohne Stripe-Sitzung, mit dem handelnden Konto const [nachweis] = nachweise() assert.equal(nachweise().length, 1) assert.deepEqual([nachweis.customerId, nachweis.projectId, nachweis.featureKey, nachweis.stripeSessionId], ['c1', 'p1', 'zeiten', '']) assert.equal(nachweis.konto, 'Kunde Eins ') assert.deepEqual(JSON.parse(nachweis.fassungen), { zustimmung: Z.consentFrei.version, leistungsbeschreibung: Z.leistungsbeschreibung.version }) assert.deepEqual(JSON.parse(nachweis.texte), [ { id: 'leistung', text: Z.consentFrei.items[0].text, haken: true }, { id: 'knopf', text: 'Einschalten', haken: false }, ]) }) test('Reihenfolge: Neubau vor Ticket, Ticket-Text nennt einbau, dann K27, dann einschaltMailAt', async () => { const beimVersand = [] setMailTransportForTests({ sent: sink.sent, async sendMail(mail) { beimVersand.push({ einschaltMailAt: zeitenDoc()?.einschaltMailAt ?? null, tickets: aw.docs('workorders').size }) return sink.sendMail(mail) }, }) const res = await einschalten(koerper()) assert.equal(res.status, 200, JSON.stringify(res.body)) // Neubau: Zustand schon gespeichert (setup), noch kein Ticket, einbau noch nicht eingetragen assert.deepEqual(rebuilds, [{ projektId: 'p1', tickets: 0, einbau: '', state: 'setup' }]) // Ticket: einmal, Text nennt das Ergebnis des Neubaus und den siteKey assert.equal(tickets().length, 1) const [ticket] = tickets() assert.equal(ticket.topic, 'Öffnungszeiten live einbauen – Projekt p1') assert.match(ticket.details, /Einbau: automatisch eingesetzt \(nach dem nächsten Veröffentlichen prüfen\)/) assert.ok(ticket.details.includes(`data-site="${zeitenDoc().siteKey}"`)) assert.equal(zeitenDoc().ticketId, ticket.$id) // K27: genau eine Mail, nach dem Ticket, einschaltMailAt erst danach assert.equal(sink.sent.length, 1) assert.deepEqual(beimVersand, [{ einschaltMailAt: null, tickets: 1 }]) const mail = sink.sent[0] assert.equal(mail.subject, 'Öffnungszeiten live ist eingeschaltet') assert.deepEqual(mail.to.map((a) => a.address), ['k@example.invalid']) assert.match(mail.text, /erscheinen nach dem nächsten Veröffentlichen/) assert.ok(mail.text.includes(`(Ticket #${ticket.woid})`)) assert.doesNotMatch(mail.text, /nicht automatisch aktualisieren/) assert.ok(Date.parse(zeitenDoc().einschaltMailAt) > 0) }) test('Neubau nicht möglich: einbau manuell, Ticket und K27 sagen „Team baut ein“', async () => { aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Ohne Repo' }) // kein Repo ⇒ manuell const res = await einschalten(koerper()) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(res.body.einbau, 'manuell') assert.deepEqual(rebuilds, []) assert.equal(zeitenDoc().einbau, 'manuell') assert.match(tickets()[0].details, /Einbau: bitte von Hand einsetzen/) assert.match(sink.sent[0].text, /nicht automatisch aktualisieren/) assert.doesNotMatch(sink.sent[0].text, /erscheinen nach dem nächsten Veröffentlichen/) }) // ------------------------------------------------------------------------------------------------------ Abo test('kein Abo / gekündigt ⇒ 409 mit Hinweis auf 30 €, keine Spur', async () => { aw.seed('subscriptions', hostingDoc) const keins = await einschalten(koerper()) assert.equal(keins.status, 409) assert.equal(keins.body.error, KEIN_ABO_TEXT) assert.equal(keins.body.code, 'kein_abo') assert.equal(keins.body.grund, 'kein_abo') keineSpuren() wartung({ cancelAtPeriodEnd: true }) const gekuendigt = await einschalten(koerper()) assert.equal(gekuendigt.status, 409) assert.equal(gekuendigt.body.error, KEIN_ABO_TEXT) assert.equal(gekuendigt.body.code, 'gekuendigt') keineSpuren() const ohne = await einschalten({ projectId: 'p3', zustimmung: zustimmung() }, customerCookie('c3')) assert.equal(ohne.status, 409) assert.equal(ohne.body.code, 'kein_abo') keineSpuren() }) test('Abo nicht aktiv ⇒ 409 „sobald Dein Abo aktiv ist“, keine Spur', async () => { for (const status of ['trialing', 'past_due']) { wartung({ status }) const res = await einschalten(koerper()) assert.equal(res.status, 409, status) assert.equal(res.body.error, 'Einschaltbar, sobald Dein Abo aktiv ist.', status) assert.equal(res.body.code, 'nicht_aktiv', status) keineSpuren() } }) test('Abo-Lesefehler ⇒ 503, nichts geschrieben, kein Rohtext', async () => { aw.failNext({ collection: 'subscriptions', status: 500, message: 'Interner Appwrite-Text mit Kunde c1' }) const res = await einschalten(koerper()) assert.equal(res.status, 503) assert.equal(res.body.error, 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.') assert.equal(JSON.stringify(res.body).includes('Appwrite'), false) keineSpuren() }) test('pruefeAboFuerEinschalten liest frisch: ok ⇒ nichts, Lesefehler ⇒ 503 ohne Rohtext im Log', async () => { await pruefeAboFuerEinschalten('c1') // Wartung aktiv aw.failNext({ collection: 'subscriptions', status: 500, message: 'Geheimer Text' }) await assert.rejects(pruefeAboFuerEinschalten('c1'), (err) => err.status === 503 && err.message === 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.') const log = console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n') assert.doesNotMatch(log, /Geheimer Text/) // zwei Aufrufe hintereinander lesen zweimal (kein Zwischenspeicher): nach dem Ende des Abos sieht der zweite es sofort wartung({ status: 'canceled', endedAt: '2026-09-01T00:00:00.000Z' }) await assert.rejects(pruefeAboFuerEinschalten('c1'), (err) => err.status === 409 && err.code === 'kein_abo') }) // ------------------------------------------------------------------------------------------- Vorpruefungen test('Kundenansicht ⇒ 403, keine Spur', async () => { const res = await einschalten(koerper(), kundenansichtCookie()) assert.equal(res.status, 403) assert.equal(res.body.error, 'In der Kundenansicht kann nicht eingeschaltet werden.') // die Sperre kommt vor jeder anderen Pruefung, auch vor der Zustimmung assert.equal((await einschalten({}, kundenansichtCookie())).status, 403) keineSpuren() }) test('ohne Anmeldung 401', async () => { assert.equal((await post('/api/funktionen/zeiten/einschalten', koerper())).status, 401) keineSpuren() }) test('Zustimmung fehlt oder veraltet ⇒ 400/409 wie beim Checkout, mit der Gratis-Fassung, keine Spur', async () => { for (const body of [{ projectId: 'p1' }, koerper({ zustimmung: zustimmung({ haken: [] }) }), koerper({ zustimmung: zustimmung({ haken: ['leistung', 'beginn'] }) }), koerper({ zustimmung: 'ja' })]) { const res = await einschalten(body) assert.equal(res.status, 400, JSON.stringify(body)) assert.equal(res.body.code, 'zustimmung_fehlt') assert.equal(res.body.error, 'Bitte stimme der Erklärung zu, um zu bestellen.') } const alt = await einschalten(koerper({ zustimmung: zustimmung({ version: '2020-01-01' }) })) assert.equal(alt.status, 409) assert.equal(alt.body.code, 'fassung_veraltet') // die neue Fassung ist die des Gratisweges (ein Haken, Knopf „Einschalten“), nicht die der Bestellung assert.deepEqual(alt.body.consent, { version: Z.consentFrei.version, knopf: 'Einschalten', items: Z.consentFrei.items.map(({ id, text }) => ({ id, text, haken: true })), }) keineSpuren() }) test('Altform { consent: true } (News, ohne Fassungsprüfung) wird bei dieser neuen Route nicht angenommen: 400, keine Spur', async () => { // sonst würde nach einer Textänderung der Gratis-Zustimmung ein alter Tab den neuen Text bestätigen, ohne ihn gesehen zu haben for (const body of [{ projectId: 'p1', consent: true }, { projectId: 'p1', consent: true, zustimmung: null }, { projectId: 'p1', consent: true, zustimmung: {} }]) { const res = await einschalten(body) assert.equal(res.status, 400, JSON.stringify(body)) assert.equal(res.body.code, 'zustimmung_fehlt') } keineSpuren() // die Form mit Fassung und Haken geht weiter assert.equal((await einschalten(koerper({ consent: true }))).status, 200) }) test('Projekt: fehlt ⇒ 400, fremd oder unbekannt ⇒ 403, archiviert ⇒ 409', async () => { assert.equal((await einschalten({ zustimmung: zustimmung() })).status, 400) assert.equal((await einschalten(koerper({ projectId: 'p3' }))).status, 403) assert.equal((await einschalten(koerper({ projectId: 'gibtsnicht' }))).status, 403) keineSpuren() projekt('p_alt', { archived: true }) const res = await einschalten(koerper({ projectId: 'p_alt' })) assert.equal(res.status, 409) assert.match(res.body.error, /archiviert/) keineSpuren() }) test('Schalter und Pilotliste: aus oder nicht in der Liste ⇒ 409 nicht verfügbar, keine Spur', async () => { config.zeiten.mode = 'off' const aus = await einschalten(koerper()) assert.equal(aus.status, 409) assert.equal(aus.body.code, 'nicht_verfuegbar') assert.equal(aus.body.error, Z.unavailableMessage) config.zeiten.mode = 'pilot' config.zeiten.pilotEmails = ['andere@example.test'] assert.equal((await einschalten(koerper())).status, 409) keineSpuren() config.zeiten.pilotEmails = ['k@example.invalid'] assert.equal((await einschalten(koerper())).status, 200) }) test('Vertragsunterlagen nicht abrufbar ⇒ 503, keine Spur', async () => { Z.unterlagenBereit = async () => false const res = await einschalten(koerper()) assert.equal(res.status, 503) assert.equal(res.body.code, 'unterlagen_fehlen') keineSpuren() }) test('Nachweis nicht speicherbar ⇒ 500 ohne Rohtext, Zustand bleibt off, kein Ticket, keine Mail', async () => { aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500, message: 'Interner Text' }) const res = await einschalten(koerper()) assert.equal(res.status, 500) assert.equal(JSON.stringify(res.body).includes('Interner Text'), false) keineSpuren() }) // ------------------------------------------------------------------------------------------- Zustaende test('zweiter Klick ist idempotent und holt Fehlendes nach: Ticket, dann genau ein K27', async () => { aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticketsystem down' }) const erster = await einschalten(koerper()) // Zustand ist gespeichert, das Ticket fehlt: der Kunde bekommt einen Hinweis zum erneuten Versuch, nicht den Rohtext assert.equal(erster.status, 503) assert.equal(JSON.stringify(erster.body).includes('Ticketsystem'), false) assert.equal(zeitenDoc().state, 'setup') assert.equal(zeitenDoc().unlockedBy, 'abo') assert.ok(!zeitenDoc().ticketId) assert.ok(!zeitenDoc().einschaltMailAt) assert.equal(tickets().length, 0) assert.equal(sink.sent.length, 0) assert.equal(nachweise().length, 1) assert.equal(rebuilds.length, 1) const zweiter = await einschalten(koerper()) assert.equal(zweiter.status, 200, JSON.stringify(zweiter.body)) assert.equal(tickets().length, 1) assert.equal(zeitenDoc().ticketId, tickets()[0].$id) assert.equal(sink.sent.length, 1) assert.ok(zeitenDoc().einschaltMailAt) assert.equal(nachweise().length, 1, 'kein zweiter Nachweis') assert.equal(rebuilds.length, 1, 'einbau steht schon, kein zweiter Neubau') assert.equal(zweiter.body.einbau, 'angestossen') // dritter Klick: alles da, nichts passiert const dritter = await einschalten(koerper()) assert.equal(dritter.status, 200) assert.deepEqual([tickets().length, sink.sent.length, nachweise().length, rebuilds.length], [1, 1, 1, 1]) }) test('K27 scheitert: Zustand und Ticket bleiben, einschaltMailAt leer, der nächste Klick sendet nach (ein Ticket, eine Mail)', async () => { setMailTransportForTests({ sent: [], async sendMail() { throw new Error('SMTP down') } }) const erster = await einschalten(koerper()) assert.equal(erster.status, 200, 'Mail-Fehler kosten die Antwort nichts') assert.equal(tickets().length, 1) assert.ok(!zeitenDoc().einschaltMailAt) setMailTransportForTests(sink) assert.equal((await einschalten(koerper())).status, 200) assert.equal(tickets().length, 1) assert.equal(sink.sent.length, 1) assert.ok(zeitenDoc().einschaltMailAt) assert.match(sink.sent[0].text, new RegExp(`Ticket #${tickets()[0].woid}`)) }) test('zwei gleichzeitige Klicks: ein Nachweis, ein Neubau, ein Ticket, eine K27', async () => { const [a, b] = await Promise.all([einschalten(koerper()), einschalten(koerper())]) assert.deepEqual([a.status, b.status], [200, 200], JSON.stringify([a.body, b.body])) assert.deepEqual([nachweise().length, rebuilds.length, tickets().length, sink.sent.length], [1, 1, 1, 1]) assert.equal(zeitenDoc().ticketId, tickets()[0].$id) }) test('Zustand active mit abo: idempotent 200 ohne Schreibzugriff', async () => { assert.equal((await einschalten(koerper())).status, 200) await updateDocument('portalFeatures', KEY_P1, { state: 'active', enabled: true }) const vorher = { ...zeitenDoc() } const res = await einschalten(koerper()) assert.equal(res.status, 200) assert.equal(res.body.feature.state, 'active') assert.deepEqual(zeitenDoc(), vorher) assert.deepEqual([nachweise().length, rebuilds.length, tickets().length, sink.sent.length], [1, 1, 1, 1]) }) test('andere Zustände: ordered, setup/active vom Kauf ⇒ 409 Meldung der Bestellung; disabled ⇒ 409 Meldung der Abschaltung', async () => { const faelle = [ [{ state: 'ordered' }, Z.orderedConflictMessage], [{ state: 'setup', unlockedBy: 'kauf', unlockedByPurchase: true }, Z.orderedConflictMessage], [{ state: 'active', enabled: true, unlockedBy: 'kauf', unlockedByPurchase: true }, Z.orderedConflictMessage], [{ state: 'disabled', disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' }, Z.disabledMessage], [{ state: 'disabled', disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z', unlockedBy: 'abo' }, Z.disabledMessage], ] for (const [felder, text] of faelle) { for (const id of aw.docs('portalFeatures').keys()) await deleteDocument('portalFeatures', id) aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', enabled: false, siteKey: 'abcdefghijklmnop', ...felder }) const vorher = { ...zeitenDoc() } const res = await einschalten(koerper()) assert.equal(res.status, 409, felder.state + felder.unlockedBy) assert.equal(res.body.error, text, felder.state) assert.deepEqual(zeitenDoc(), vorher) } assert.deepEqual([nachweise().length, rebuilds.length, tickets().length, sink.sent.length], [0, 0, 0, 0]) }) test('Abo zuerst: ist es beendet, hilft auch ein vorhandenes setup nicht (409 kein_abo), nichts wird nachgeholt', async () => { aw.failNext({ collection: 'workorders', method: 'POST', status: 500 }) assert.equal((await einschalten(koerper())).status, 503) wartung({ status: 'canceled', endedAt: '2026-09-01T00:00:00.000Z' }) const res = await einschalten(koerper()) assert.equal(res.status, 409) assert.equal(res.body.code, 'kein_abo') assert.equal(tickets().length, 0) }) test('Dokument im Zustand off mit Resten wird sauber auf setup gestellt', async () => { aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'off', enabled: false, siteKey: 'abcdefghijklmnop', disabledAt: '2026-08-01T10:00:00.000Z', disabledReason: 'abo_ende', aboEndeAt: '2026-07-01T10:00:00.000Z', behaltenSessionId: 'cs_alt', }) assert.equal((await einschalten(koerper())).status, 200) const doc = zeitenDoc() assert.deepEqual([doc.state, doc.siteKey, doc.disabledAt, doc.disabledReason, doc.aboEndeAt, doc.behaltenSessionId], ['setup', 'abcdefghijklmnop', null, null, null, '']) }) // --------------------------------------------------------------------------------------------- Team und Nachholen test('Team: kein Abo nötig, unlockedBy team, kein Nachweis, Neubau, Ticket, keine K27, Notiz', async () => { aw.seed('subscriptions', hostingDoc) const { doc, changed } = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', staff: { name: 'Andrej' }, now: JETZT }) assert.equal(changed, true) assert.deepEqual([doc.state, doc.unlockedBy, doc.unlockedByPurchase, doc.einbau], ['setup', 'team', false, 'angestossen']) assert.match(doc.adminNote, /Andrej/) assert.equal(nachweise().length, 0) assert.equal(tickets().length, 1) assert.equal(sink.sent.length, 0) assert.ok(!zeitenDoc().einschaltMailAt) assert.equal(rebuilds.length, 1) // ein zweiter Aufruf aendert nichts const nochmal = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', staff: { name: 'Andrej' }, now: JETZT }) assert.equal(nochmal.changed, false) assert.deepEqual([tickets().length, rebuilds.length], [1, 1]) }) test('Team aus disabled: zurück auf setup, Neubau erneut, kein neues Ticket', async () => { aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'disabled', enabled: false, siteKey: 'abcdefghijklmnop', unlockedBy: 'abo', einbau: 'angestossen', ticketId: 'ticket_alt', disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z', aboEndeAt: '2026-09-01T10:00:00.000Z', }) const { doc } = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', now: JETZT }) assert.deepEqual([doc.state, doc.unlockedBy, doc.disabledAt, doc.disabledReason, doc.aboEndeAt, doc.ticketId], ['setup', 'team', null, null, null, 'ticket_alt']) assert.equal(rebuilds.length, 1) assert.equal(doc.einbau, 'angestossen') assert.equal(tickets().length, 0) }) test('Team aus disabled bei einem gekauften Dokument: kauf und unlockedByPurchase bleiben (Widerrufs-Zuordnung), einbau bleibt, kein Neubau', async () => { for (const [nr, kauf] of [[1, { unlockedBy: 'kauf', unlockedByPurchase: true }], [2, { unlockedByPurchase: true }]]) { const projectId = `p_k${nr}` projekt(projectId) aw.seed('portalFeatures', { $id: `f_${projectId}_zeiten`, customerId: 'c1', projectId, featureKey: 'zeiten', state: 'disabled', enabled: false, siteKey: 'abcdefghijklmnop', einbau: 'angestossen', ticketId: 'ticket_alt', disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z', ...kauf, }) const { doc } = await einschaltenZeiten({ customerId: 'c1', projectId, by: 'team', now: JETZT }) assert.deepEqual([doc.state, sf.unlockedByVon(doc), doc.unlockedByPurchase, doc.disabledAt, doc.disabledReason, doc.ticketId, doc.einbau], ['setup', 'kauf', true, null, null, 'ticket_alt', 'angestossen'], `Fall ${nr}`) } assert.deepEqual([rebuilds.length, tickets().length], [0, 0], 'bei gekauften Dokumenten kümmert sich markPaid um Neubau und Ticket, nicht das Nachholen') // ein Dokument, das nie gekauft war, wird weiter ein Team-Dokument; ohne Dokument ebenso aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'disabled', enabled: false, siteKey: 'abcdefghijklmnop', unlockedBy: 'abo', unlockedByPurchase: false, einbau: 'angestossen', ticketId: 'ticket_alt', }) const team = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', now: JETZT }) assert.deepEqual([team.doc.unlockedBy, team.doc.unlockedByPurchase], ['team', false]) }) test('holeZeitenNach: Ticket fehlt in setup ⇒ anlegen; K27 nur bei unlockedBy abo und leerem einschaltMailAt, danach Marker', async () => { aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop', unlockedBy: 'abo', einbau: 'manuell' }) const doc = await holeZeitenNach(zeitenDoc(), { now: JETZT }) assert.equal(tickets().length, 1) assert.equal(doc.ticketId, tickets()[0].$id) assert.equal(Date.parse(doc.einschaltMailAt), JETZT.getTime()) assert.equal(sink.sent.length, 1) assert.deepEqual(rebuilds, [], 'einbau steht schon: kein Neubau') // noch einmal: nichts await holeZeitenNach(zeitenDoc(), { now: JETZT }) assert.deepEqual([tickets().length, sink.sent.length], [1, 1]) }) test('holeZeitenNach: Neubau nur bei leerem einbau; kauf, active und off bleiben unberührt; team bekommt keine K27', async () => { const seed = (id, felder) => { projekt(id) aw.seed('portalFeatures', { $id: `f_${id}_zeiten`, customerId: 'c1', projectId: id, featureKey: 'zeiten', siteKey: 'abcdefghijklmnop', enabled: false, ...felder }) return aw.docs('portalFeatures').get(`f_${id}_zeiten`) } for (const doc of [ seed('p_kauf', { state: 'setup', unlockedBy: 'kauf', unlockedByPurchase: true }), seed('p_aktiv', { state: 'active', enabled: true, unlockedBy: 'abo' }), seed('p_aus', { state: 'off' }), seed('p_wartet', { state: 'ordered' }), ]) { const vorher = { ...doc } assert.deepEqual(await holeZeitenNach(doc, { now: JETZT }), vorher) } assert.deepEqual([tickets().length, sink.sent.length, rebuilds.length], [0, 0, 0]) const team = seed('p_team', { state: 'setup', unlockedBy: 'team' }) const fertig = await holeZeitenNach(team, { now: JETZT }) assert.deepEqual([rebuilds.map((r) => r.projektId), tickets().length, sink.sent.length], [['p_team'], 1, 0]) assert.equal(fertig.einbau, 'angestossen') assert.ok(!fertig.einschaltMailAt) assert.match(tickets()[0].details, /automatisch eingesetzt/, 'das Ticket kennt das frische einbau') }) test('holeZeitenNach: Kunde ohne Adresse oder gelöscht ⇒ einschaltMailAt wird gesetzt (nichts zu senden), Lesefehler ⇒ wirft und lässt den Marker leer', async () => { aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: '', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true }) aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop', unlockedBy: 'abo', einbau: 'manuell', ticketId: 'ticket_da' }) const doc = await holeZeitenNach(zeitenDoc(), { now: JETZT }) assert.ok(doc.einschaltMailAt) assert.equal(sink.sent.length, 0) await updateDocument('portalFeatures', KEY_P1, { einschaltMailAt: null }) aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true }) aw.failNext({ collection: 'customers', status: 500 }) await assert.rejects(holeZeitenNach(zeitenDoc(), { now: JETZT })) assert.ok(!zeitenDoc().einschaltMailAt) }) // ----------------------------------------------------------------------------------------------------- Angebot const angebot = async (cookie = c1) => (await liste(cookie)).body.zeiten const FREI = () => ({ consent: { version: Z.consentFrei.version, knopf: 'Einschalten', items: Z.consentFrei.items.map(({ id, text }) => ({ id, text, haken: true })) }, leistungsbeschreibung: { version: Z.leistungsbeschreibung.version, url: Z.leistungsbeschreibung.url }, }) test('Angebot: nicht verfügbar ⇒ genau { verfuegbar: false }, das Abo wird nicht gelesen', async () => { config.zeiten.mode = 'off' aw.resetListCalls() assert.deepEqual(await angebot(), { verfuegbar: false }) assert.equal(aw.listCalls('subscriptions').length, 0) config.zeiten.mode = 'pilot' config.zeiten.pilotEmails = ['andere@example.test'] assert.deepEqual(await angebot(), { verfuegbar: false }) }) test('Angebot: inklusive true/false/null je nach Abo-Lage, Preis und Texte aus dem Register', async () => { // Ruling R32: die Belehrung der Bestellseite ist genau der Text, den der Haken belehrung der Zeiten auch in den Nachweis schreibt const { featureHaken } = await import('../server/services/featureRegistry.js') const belehrung = await featureHaken('zeiten').belehrung() assert.match(belehrung, /Muster-Widerrufsformular/) const erwartet = (inklusive, grund) => ({ verfuegbar: true, inklusive, grund, preis: '30 €', einschalten: FREI(), belehrung }) assert.deepEqual(await angebot(), erwartet(true, null)) assert.deepEqual(Object.keys(await angebot()), ['verfuegbar', 'inklusive', 'grund', 'preis', 'einschalten', 'belehrung']) aw.seed('subscriptions', hostingDoc) assert.deepEqual(await angebot(), erwartet(false, 'kein_abo'), 'nur Hosting') assert.deepEqual(await angebot(customerCookie('c3')), erwartet(false, 'kein_abo'), 'gar kein Abo') wartung({ cancelAtPeriodEnd: true }) assert.deepEqual(await angebot(), erwartet(false, 'gekuendigt')) wartung({ status: 'past_due' }) assert.deepEqual(await angebot(), erwartet(false, 'nicht_aktiv')) wartung() aw.failNext({ collection: 'subscriptions', status: 500, message: 'Geheim' }) const lesefehler = await liste() assert.equal(lesefehler.status, 200, 'ein Abo-Lesefehler kostet die Liste nichts') assert.deepEqual(lesefehler.body.zeiten, erwartet(null, null)) assert.doesNotMatch(console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n'), /Geheim/) }) test('Angebot: die Register-Ansicht der Zeiten bleibt unverändert (keine neuen Schlüssel)', async () => { const { features } = (await liste()).body assert.deepEqual(Object.keys(features.zeiten).sort(), ['avv', 'consent', 'key', 'kurz', 'leistungsbeschreibung', 'name', 'priceLabel', 'requiresWartungsAbo', 'returnHash', 'setupEstimate', 'widerruf']) assert.deepEqual(features.zeiten.consent.items.map((i) => i.id), ['leistung', 'beginn'], 'die bezahlte Zustimmung') }) test('Ansicht features.zeiten: unlockedBy, aboEndeAt, einbau; loeschungAm nur bei disabled mit abo_ende', async () => { const ansicht = async (felder) => { for (const id of aw.docs('portalFeatures').keys()) await deleteDocument('portalFeatures', id) if (felder) aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', enabled: false, siteKey: 'abcdefghijklmnop', ...felder }) return (await liste()).body.projects.find((p) => p.projectId === 'p1').features.zeiten } const neu = await ansicht(null) assert.deepEqual([neu.state, neu.unlockedBy, neu.aboEndeAt, neu.einbau, neu.loeschungAm], ['off', null, null, null, null]) const setup = await ansicht({ state: 'setup', unlockedBy: 'abo', einbau: 'manuell', aboEndeAt: '2026-10-01T10:00:00.000Z' }) assert.deepEqual([setup.state, setup.unlockedBy, setup.einbau, setup.loeschungAm], ['setup', 'abo', 'manuell', null]) assert.equal(Date.parse(setup.aboEndeAt), Date.parse('2026-10-01T10:00:00.000Z')) const alt = await ansicht({ state: 'active', enabled: true, unlockedByPurchase: true }) assert.equal(alt.unlockedBy, 'kauf', 'Altbestand ohne unlockedBy: aus unlockedByPurchase abgeleitet') const teamAlt = await ansicht({ state: 'active', enabled: true }) assert.equal(teamAlt.unlockedBy, 'team') const aboEnde = await ansicht({ state: 'disabled', unlockedBy: 'abo', disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' }) assert.deepEqual([aboEnde.disabledReason, aboEnde.loeschungAm], ['abo_ende', '2026-11-04']) assert.equal(aboEnde.loeschungAm, loeschTag('2026-10-04T23:30:00.000Z'), 'Berliner Tag') const team = await ansicht({ state: 'disabled', unlockedBy: 'team', disabledReason: 'team', disabledAt: '2026-10-04T23:30:00.000Z' }) assert.equal(team.loeschungAm, null) // andere Funktionen behalten ihre Ansicht; einzige Erweiterung (V7, Kundenansicht neu): unlockedBy gilt fuer jede Funktion (null solange sie aus ist) const news = (await liste()).body.projects[0].features.news assert.equal('einbau' in news || 'aboEndeAt' in news, false) assert.equal(news.unlockedBy, null) }) // ----------------------------------------------------------------------------------------------------- Projektliste function tage(modus) { const t = {} for (let d = 1; d <= 7; d += 1) t[d] = { modus, fenster: modus === 'zeiten' ? [['09:00', '18:00']] : [] } return t } const eingabe = (modus) => ({ land: 'RP', plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage: tage(modus) }], feiertage: {}, ausnahmen: [], hinweise: [], kontakt: { an: false, telefon: '', adresse: '', whatsapp: null }, }) async function mitZeiten(projectId, state, modus) { aw.seed('portalFeatures', { $id: `f_${projectId}_zeiten`, customerId: 'c1', projectId, featureKey: 'zeiten', state, enabled: state === 'active', siteKey: 'abcdefghijklmnop', unlockedBy: 'abo' }) if (modus) await speichereZeiten({ feature: zeitenDoc(projectId), eingabe: eingabe(modus), version: 0, now: JETZT }) } test('Projektliste enthält zeitenStatus und zeitenLeer ohne Zusatzabfrage pro Kachel', async () => { projekt('p_offen') projekt('p_zu') projekt('p_leer') projekt('p_aus') projekt('p_bestellt') projekt('p_abgeschaltet') await mitZeiten('p_offen', 'active', 'zeiten') await mitZeiten('p_zu', 'setup', 'zu') await mitZeiten('p_leer', 'setup', null) await mitZeiten('p_bestellt', 'ordered', 'zeiten') await mitZeiten('p_abgeschaltet', 'disabled', 'zeiten') // p1 und p_aus ohne Dokument const abfragen = [] const echt = globalThis.fetch globalThis.fetch = async (url, optionen) => { if (/\/collections\/oeffnungszeiten\/documents/.test(String(url))) abfragen.push(`${optionen?.method ?? 'GET'} ${String(url).replace(/\?.*/, '').split('/documents')[1]}`) return echt(url, optionen) } let res try { res = await liste() } finally { globalThis.fetch = echt } assert.equal(res.status, 200) const von = (id) => res.body.projects.find((p) => p.projectId === id) const daten = eingabe('zeiten') assert.deepEqual(von('p_offen').zeitenStatus, statusZu(daten, hauptplan(daten), JETZT)) assert.equal(von('p_offen').zeitenStatus.offen, true) assert.deepEqual(Object.keys(von('p_offen').zeitenStatus).sort(), ['kurz', 'offen', 'text']) assert.equal(von('p_offen').zeitenLeer, false) assert.equal(von('p_zu').zeitenStatus.offen, false) assert.equal(von('p_zu').zeitenLeer, false) // keine gespeicherten Zeiten: kein Status, leer for (const id of ['p_leer', 'p1', 'p_aus']) assert.deepEqual([von(id).zeitenStatus, von(id).zeitenLeer], [null, true], id) // bestellt oder abgeschaltet: Daten da, aber nicht live for (const id of ['p_bestellt', 'p_abgeschaltet']) assert.deepEqual([von(id).zeitenStatus, von(id).zeitenLeer], [null, false], id) // je Projekt mit Dokument eine Abfrage, keine fuer die anderen assert.equal(abfragen.length, 5, abfragen.join(' | ')) assert.equal(abfragen.every((a) => a.startsWith('GET /')), true) }) test('Projektliste: nicht verfügbar ⇒ keine Zeiten gelesen; Lesefehler bei einem Projekt kostet die Liste nichts', async () => { await mitZeiten('p1', 'active', 'zeiten') config.zeiten.mode = 'off' const abfragen = [] const echt = globalThis.fetch globalThis.fetch = async (url, optionen) => { if (/\/collections\/oeffnungszeiten\//.test(String(url))) abfragen.push(String(url)) return echt(url, optionen) } try { const aus = await liste() assert.deepEqual([aus.body.projects[0].zeitenStatus, aus.body.projects[0].zeitenLeer], [null, true]) } finally { globalThis.fetch = echt } assert.equal(abfragen.length, 0) config.zeiten.mode = 'pilot' aw.failNext({ collection: 'oeffnungszeiten', status: 500, message: 'Geheim' }) const res = await liste() assert.equal(res.status, 200) const p1 = res.body.projects.find((p) => p.projectId === 'p1') assert.deepEqual([p1.zeitenStatus, p1.zeitenLeer], [null, false]) assert.doesNotMatch(console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n'), /Geheim/) }) // ---------------------------------------------------------------------------------------------------- siteFeatures test('assertOwnProject: forNewOrder sperrt archivierte Projekte mit 409, ohne die Option bleibt alles wie vorher', async () => { projekt('p_alt', { archived: true }) assert.equal((await sf.assertOwnProject('c1', 'p_alt')).$id, 'p_alt') await assert.rejects(sf.assertOwnProject('c1', 'p_alt', { forNewOrder: true }), (err) => err.status === 409) assert.equal((await sf.assertOwnProject('c1', 'p1', { forNewOrder: true })).$id, 'p1') })