Files
Webklar-Kundenbereich/test/zeitenEinschalten.test.js
T
ClaudeandClaude Opus 5.5 c7cd436ec2 Öffnungszeiten: Mails K24–K27 heißen jetzt K27–K30, Feld k24At heißt einschaltMailAt
K24–K26 sind seit dem Webshop-Livegang (main) vergeben; der Mail-Lader sucht die Vorlage über den
Dateianfang, doppelte Nummern hätten die falsche Mail geschickt. Das Feld ist noch nicht in der
Produktion angelegt (zeiten-setup.mjs lief noch nicht), daher jetzt ein sprechender Name.
Spec und Plan behalten die alten Nummern (Hinweis oben im Dokument).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:00:17 +00:00

720 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <k@example.invalid>')
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
const news = (await liste()).body.projects[0].features.news
assert.equal('einbau' in news || 'unlockedBy' in news || 'aboEndeAt' in news, false)
})
// ----------------------------------------------------------------------------------------------------- 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')
})