Files
Webklar-Kundenbereich/test/funktionenTermine.test.js
T
ClaudeandClaude Sonnet 5.5 f830698dc3 Funktionen: Freischalten nach Abnahme und Ticket nachlegen (Team)
Neue Team-Aktionen abnahme (Freischalten ohne Ticket-Schliessung, K16
einmal, unter feature-activate:<id> mit Haken freischaltHindernis) und
ticket (Ticket nachlegen: Zahlung bei Stripe pruefen und Webhook-Weg,
geloeschtes Ticket ersetzen, vorhandenes unberuehrt). Ticketanlage aus
markPaid in legeTicketAn herausgeloest.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 01:08:03 +00:00

1042 lines
60 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 } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Terminplaner als zweite Funktion (Aufgabe 17): Team-Route /:customerId/:key, GET /api/funktionen mit Schalter,
// Kunde loeschen schaltet den Terminplaner ab und entfernt seine Entwuerfe (C6). News verhaelt sich wie bisher (test/funktionenRoutes.test.js).
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), termineConfig: schemaKeys('termineConfig'), termineBookings: schemaKeys('termineBookings') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, rawGet, post } = await import('./support/testApp.js')
const { customerCookie, seedFixtures, subscriptionDoc, wartungDoc, hostingDoc } = await import('./support/fixtures.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { default: teamRoutes } = await import('../server/routes/team/index.js')
const sf = await import('../server/services/siteFeatures.js')
const { loeschTag } = await import('../server/services/termine/frist.js')
const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js')
const { aboMeldung } = await import('../server/services/featureOrder.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/team': teamRoutes })
// Mails (K16 der Abnahme, K15/T16 des nachgelegten Tickets) landen in einer Senke
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' })
aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' })
aw.seed('websiteProjects', { $id: 'pt2', customerId: 'c1', projectName: 'Studio' })
aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremder Salon' })
const STAFF = { headers: { Authorization: 'Bearer jwt-staff' } }
const cookie = customerCookie('c1')
const team = (customerId, key, body, opts = STAFF) => post(`/api/team/funktionen/${customerId}/${key}`, body, opts)
const featuresOf = (res, projectId) => res.body.projects.find((p) => p.projectId === projectId).features
const workorders = () => aw.docs('workorders').size
const docOf = (projectId, key) => sf.getFeatureDoc('c1', projectId, key)
test('Team aktiviert termine fuer ein Projekt, News bleibt unveraendert', async () => {
const tickets = workorders()
const res = await team('c1', 'termine', { projectId: 'pt1', action: 'activate', note: 'Pilot' })
assert.equal(res.status, 200)
assert.deepEqual(res.body.projects.map((p) => p.projectId), ['pt1'])
const view = featuresOf(res, 'pt1')
assert.equal(view.termine.state, 'active')
assert.equal(view.termine.ticketWoid, null)
assert.match(view.termine.siteKey, /^[a-z2-7]{16}$/)
assert.match(view.termine.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: activate – Pilot$/)
assert.equal(view.termine.ticketMissing, false)
assert.equal(view.news.state, 'off')
assert.equal(view.news.adminNote, '')
const doc = await docOf('pt1', 'termine')
assert.deepEqual([doc.featureKey, doc.state, doc.enabled, doc.unlockedByPurchase], ['termine', 'active', true, false])
assert.equal(await docOf('pt1', 'news'), null, 'kein News-Dokument entstanden')
assert.equal(workorders(), tickets, 'kein Ticket')
// das Projekt ohne Aktion bleibt aus
const list = await get('/api/team/funktionen/c1', STAFF)
assert.equal(list.projects.find((p) => p.projectId === 'pt2').features.termine.state, 'off')
})
test('/news bleibt gleichwertig und aendert den Terminplaner nicht', async () => {
const res = await team('c1', 'news', { projectId: 'pt1', action: 'activate', note: 'Kulanz' })
assert.equal(res.status, 200)
const view = featuresOf(res, 'pt1')
assert.equal(view.news.state, 'active')
assert.match(view.news.adminNote, /Andrej: activate – Kulanz$/)
assert.equal(view.termine.state, 'active')
assert.doesNotMatch(view.termine.adminNote, /Kulanz/)
assert.notEqual((await docOf('pt1', 'news')).siteKey, (await docOf('pt1', 'termine')).siteKey)
assert.equal((await team('c1', 'news', { projectId: 'pt1', action: 'disable' })).status, 200)
const after = await docOf('pt1', 'termine')
assert.equal(after.state, 'active')
})
test('Abschalten setzt disabledAt und den Grund team, erneutes Aktivieren leert beides (R36)', async () => {
const off = await team('c1', 'termine', { projectId: 'pt1', action: 'disable' })
assert.equal(off.status, 200)
assert.equal(featuresOf(off, 'pt1').termine.state, 'disabled')
const doc = await docOf('pt1', 'termine')
assert.equal(doc.enabled, false)
assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000)
assert.equal(doc.disabledReason, 'team', 'das WOMS schaltet mit dem Vorgabegrund ab')
// zweiter Klick (Doppelklick im WOMS): Beginn der 30 Tage, Grund und Notiz bleiben
const nochmal = await team('c1', 'termine', { projectId: 'pt1', action: 'disable', note: 'zweiter Klick' })
assert.equal(nochmal.status, 200)
const gleich = await docOf('pt1', 'termine')
assert.deepEqual([gleich.disabledAt, gleich.disabledReason, gleich.adminNote], [doc.disabledAt, 'team', doc.adminNote])
const on = await team('c1', 'termine', { projectId: 'pt1', action: 'activate' })
assert.equal(featuresOf(on, 'pt1').termine.state, 'active')
const aktiv = await docOf('pt1', 'termine')
assert.equal(aktiv.disabledAt, null)
assert.equal(aktiv.disabledReason, null)
})
test('unbekannter Schluessel -> 404, auch /kalender; nichts wird angelegt', async () => {
const docs = aw.docs('portalFeatures').size
for (const key of ['kalender', 'analyse', 'Termine', 'termine2']) {
const res = await team('c1', key, { projectId: 'pt2', action: 'activate' })
assert.equal(res.status, 404, key)
assert.equal(typeof res.body.error, 'string', key)
}
// der Schluessel wird vor dem Rumpf geprueft
assert.equal((await team('c1', 'kalender', {})).status, 404)
assert.equal(aw.docs('portalFeatures').size, docs)
assert.equal(await docOf('pt2', 'termine'), null)
})
test('Meldungen sind je Funktion grammatisch', async () => {
aw.seed('websiteProjects', { $id: 'pt4', customerId: 'c1', projectName: 'Ohne Dokument' })
const termine = await team('c1', 'termine', { projectId: 'pt4', action: 'disable' })
assert.equal(termine.status, 409)
assert.equal(termine.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
const news = await team('c1', 'news', { projectId: 'pt4', action: 'disable' })
assert.equal(news.status, 409)
assert.equal(news.body.error, 'Für dieses Projekt gibt es noch keine News-Pinnwand')
assert.equal(await docOf('pt4', 'termine'), null)
})
test('Pruefungen wie bei News: Aktion, Projekt, fremdes Projekt, Kunde', async () => {
assert.equal((await team('c1', 'termine', { projectId: 'pt1', action: 'loeschen' })).status, 400)
assert.equal((await team('c1', 'termine', { action: 'activate' })).status, 400)
assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'activate' })).status, 403)
assert.equal((await team('c1', 'termine', { projectId: 'gibtsnicht', action: 'activate' })).status, 403)
assert.equal(await sf.getFeatureDoc('c2', 'pt3', 'termine'), null)
const asCustomer = await team('c1', 'termine', { projectId: 'pt2', action: 'activate' }, { cookie })
assert.ok(asCustomer.status === 401 || asCustomer.status === 403)
assert.equal(await docOf('pt2', 'termine'), null)
})
test('Die Ansicht des Teams liest die Funktionsdokumente des Kunden einmal', async (t) => {
const echt = globalThis.fetch
const abfragen = []
globalThis.fetch = async (url, optionen) => {
if (/\/collections\/portalFeatures\/documents/.test(String(url))) abfragen.push(`${optionen?.method ?? 'GET'} ${String(url).replace(/\?.*/, '')}`)
return echt(url, optionen)
}
t.after(() => (globalThis.fetch = echt))
const list = await get('/api/team/funktionen/c1', STAFF)
assert.ok(list.projects.length >= 3)
assert.equal(abfragen.length, 1, abfragen.join(' | '))
assert.match(abfragen[0], /^GET .*\/collections\/portalFeatures\/documents$/)
})
// ------------------------------------------------------------- Kundenliste ---
const MODUS = { mode: config.termine.mode, pilotEmails: [...config.termine.pilotEmails] }
const termineMit = (mode, pilotEmails = []) => {
config.termine.mode = mode
config.termine.pilotEmails = pilotEmails
}
after(() => termineMit(MODUS.mode, MODUS.pilotEmails))
const termineAntwort = async (c = cookie) => (await get('/api/funktionen', { cookie: c })).termine
const verfuegbar = async (c = cookie) => (await termineAntwort(c)).verfuegbar
test('GET /api/funktionen: termine.verfuegbar je Modus und Pilot-E-Mail; false bleibt exakt { verfuegbar: false }', async () => {
termineMit('off')
assert.deepEqual(await termineAntwort(), { verfuegbar: false })
termineMit('pilot', ['k@example.invalid'])
assert.equal(await verfuegbar(), true)
assert.deepEqual(await termineAntwort(customerCookie('c2')), { verfuegbar: false }, 'Kunde nicht in der Pilotliste')
termineMit('pilot', [])
assert.deepEqual(await termineAntwort(), { verfuegbar: false })
termineMit('pilot', ['*'])
assert.equal(await verfuegbar(customerCookie('c2')), true)
termineMit('on')
assert.equal(await verfuegbar(customerCookie('c2')), true)
})
// ---- Abo-Felder der Kachel (C10a): bestellbar, grund, preis, einrichtung, hinweis nur bei verfuegbar: true
// hinweis (R111) = aboMeldung des Registers: der Browser zeigt den Text nur an und hat keine Kopie; bei bestellbar true null
const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch }))
const angebot = (bestellbar, grund) => ({
verfuegbar: true,
bestellbar,
grund,
preis: config.features.termine.priceLabel,
einrichtung: config.features.termine.setupEstimate,
hinweis: bestellbar === true ? null : aboMeldung(config.features.termine, grund ?? 'unbekannt'),
})
test('GET /api/funktionen: Kunde mit Wartungs-Abo => bestellbar true, grund null, Preis und Einrichtung aus dem Register', async (t) => {
t.after(() => aw.seed('subscriptions', hostingDoc))
termineMit('pilot', ['*'])
wartung()
const termine = await termineAntwort()
assert.deepEqual(termine, angebot(true, null))
assert.ok(termine.preis && termine.einrichtung, 'das Register liefert beide Werte')
assert.deepEqual(Object.keys(termine), ['verfuegbar', 'bestellbar', 'grund', 'preis', 'einrichtung', 'hinweis'])
assert.equal(termine.hinweis, null, 'bestellbar: kein Hinweis (das Feld steht da, ist aber null)')
})
test('GET /api/funktionen: hinweis ist der Text von aboMeldung mit dem Register des Terminplaners, je Grund', async (t) => {
t.after(() => aw.seed('subscriptions', hostingDoc))
termineMit('pilot', ['*'])
const faelle = [
['kein_abo', () => {}, 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.'],
['gekuendigt', () => wartung({ cancelAtPeriodEnd: true }), 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.'],
[
'nicht_aktiv',
() => wartung({ status: 'past_due' }),
'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.',
],
]
for (const [grund, richteEin, text] of faelle) {
aw.seed('subscriptions', hostingDoc)
richteEin()
const termine = await termineAntwort()
assert.equal(termine.bestellbar, false, grund)
assert.equal(termine.grund, grund)
assert.equal(termine.hinweis, text, grund)
assert.equal(termine.hinweis, aboMeldung(config.features.termine, grund), grund)
}
// ein Lesefehler: bestellbar null, grund null => Text zu 'unbekannt'
aw.seed('subscriptions', hostingDoc)
wartung()
t.mock.method(console, 'error', () => {})
aw.failNext({ collection: 'subscriptions', status: 500, message: 'x' })
const unbekannt = await termineAntwort()
assert.deepEqual([unbekannt.bestellbar, unbekannt.grund, unbekannt.hinweis], [null, null, 'Das Abo konnte gerade nicht geprüft werden.'])
})
test('GET /api/funktionen: ohne Wartungs-Abo (nur Hosting, gar kein Abo, beendet) => bestellbar false, grund kein_abo', async (t) => {
t.after(() => aw.seed('subscriptions', hostingDoc))
termineMit('pilot', ['*'])
assert.deepEqual(await termineAntwort(), angebot(false, 'kein_abo'), 'c1 hat seit seedFixtures nur Hosting')
assert.deepEqual(await termineAntwort(customerCookie('c3')), angebot(false, 'kein_abo'), 'c3 hat gar kein Abo')
wartung({ status: 'canceled', endedAt: '2026-09-01T00:00:00.000Z' })
assert.deepEqual(await termineAntwort(), angebot(false, 'kein_abo'), 'beendetes Wartungs-Abo')
})
test('GET /api/funktionen: Kuendigung vorgemerkt => grund gekuendigt; past_due und trialing => nicht_aktiv', async (t) => {
t.after(() => aw.seed('subscriptions', hostingDoc))
termineMit('pilot', ['*'])
wartung({ cancelAtPeriodEnd: true })
assert.deepEqual(await termineAntwort(), angebot(false, 'gekuendigt'))
wartung({ cancelAt: '2026-11-30T00:00:00.000Z' })
assert.deepEqual(await termineAntwort(), angebot(false, 'gekuendigt'))
wartung({ status: 'past_due' })
assert.deepEqual(await termineAntwort(), angebot(false, 'nicht_aktiv'))
wartung({ status: 'trialing' })
assert.deepEqual(await termineAntwort(), angebot(false, 'nicht_aktiv'))
})
test('GET /api/funktionen: Lesefehler im Abo => bestellbar null, grund null, die Projekte kommen trotzdem; nichts Technisches nach aussen', async (t) => {
t.after(() => aw.seed('subscriptions', hostingDoc))
const fehler = t.mock.method(console, 'error', () => {})
termineMit('pilot', ['*'])
wartung()
// eigenes Projekt mit aktivem Terminplaner: der Test haengt nicht von den Zustaenden aus frueheren Tests ab
aw.seed('websiteProjects', { $id: 'pt_lesefehler', customerId: 'c1', projectName: 'Lesefehler Salon' })
aw.seed('portalFeatures', { $id: 'f_pt_lesefehler_termine', customerId: 'c1', projectId: 'pt_lesefehler', featureKey: 'termine', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true })
aw.failNext({ collection: 'subscriptions', status: 500, message: 'geheimer Appwrite-Text' })
const res = await get('/api/funktionen', { cookie })
assert.deepEqual(res.termine, angebot(null, null))
assert.equal(res.termine.hinweis, 'Das Abo konnte gerade nicht geprüft werden.')
assert.ok(res.projects.some((p) => p.projectId === 'pt_lesefehler'), 'das Projekt ist in der Liste')
assert.equal(featuresOf({ body: res }, 'pt_lesefehler').termine.state, 'active', 'die Zustaende der Projekte bleiben')
assert.ok(!JSON.stringify(res).includes('geheimer'))
assert.ok(fehler.mock.calls.every((aufruf) => !String(aufruf.arguments[0]).includes('geheimer')), 'auch nicht im Log')
assert.ok(fehler.mock.calls.length >= 1, 'der Fehler steht im Log')
// der naechste Aufruf liest wieder, nichts wird gemerkt
assert.deepEqual(await termineAntwort(), angebot(true, null))
})
test('GET /api/funktionen: ist der Terminplaner nicht verfuegbar, wird das Abo nicht gelesen', async (t) => {
const echt = globalThis.fetch
const abfragen = []
globalThis.fetch = async (url, optionen) => {
if (/\/collections\/subscriptions\/documents/.test(String(url))) abfragen.push(String(url))
return echt(url, optionen)
}
t.after(() => (globalThis.fetch = echt))
termineMit('off')
assert.deepEqual(await termineAntwort(), { verfuegbar: false })
assert.equal(abfragen.length, 0, abfragen.join(' | '))
})
test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher', async () => {
termineMit('pilot', ['*'])
const body = await get('/api/funktionen', { cookie })
assert.equal(body.setupEstimate, 'meist innerhalb von 1–2 Wochen')
const pt1 = body.projects.find((p) => p.projectId === 'pt1')
assert.equal(pt1.features.termine.state, 'active')
assert.equal(pt1.features.news.state, 'disabled')
const pt2 = body.projects.find((p) => p.projectId === 'pt2')
assert.deepEqual([pt2.features.termine.state, pt2.features.news.state], ['off', 'off'])
assert.deepEqual(Object.keys(pt2.features), ['news', 'termine'])
// Abschaltgrund und Loeschfrist (E2, R105): News ist hier vom Team abgeschaltet: Grund und Zeitpunkt ja, ein Loeschdatum nein
// (der 30-Tage-Loeschlauf loescht nur Terminplaner-Daten); der aktive Terminplaner und das leere Projekt haben nichts
const aus = pt1.features.news
assert.equal(aus.disabledReason, 'team')
assert.match(aus.disabledAt, /^\d{4}-\d\d-\d\dT[\d:.]+Z$/)
assert.equal(aus.loeschungAm, null)
for (const view of [pt1.features.termine, pt2.features.news, pt2.features.termine]) {
assert.deepEqual([view.disabledReason, view.disabledAt, view.loeschungAm], [null, null, null])
}
// ohne Sitzung wie bisher gesperrt
assert.equal((await rawGet('/api/funktionen')).status, 401)
})
test('GET /api/funktionen: abgeschalteter Terminplaner nennt Grund, Zeitpunkt und Berliner Loeschtag; Altbestand ohne Grund', async () => {
const projekte = [
['pe2_aboende', { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z' }],
['pe2_team', { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' }],
['pe2_alt', { state: 'disabled', enabled: false, disabledAt: '2026-10-01T10:00:00.000Z' }],
['pe2_aktiv', { state: 'active', enabled: true }],
]
for (const [id, felder] of projekte) {
aw.seed('websiteProjects', { $id: id, customerId: 'ce2', projectName: id })
aw.seed('portalFeatures', { $id: `f_${id}_termine`, customerId: 'ce2', projectId: id, featureKey: 'termine', siteKey: 'abcdefghijklmnop', ...felder })
}
const body = await get('/api/funktionen', { cookie: customerCookie('ce2') })
const termine = (id) => body.projects.find((p) => p.projectId === id).features.termine
const felder = (id) => ({ disabledReason: termine(id).disabledReason, disabledAt: termine(id).disabledAt, loeschungAm: termine(id).loeschungAm })
assert.deepEqual(felder('pe2_aboende'), { disabledReason: 'abo_ende', disabledAt: '2026-10-04T23:30:00.000Z', loeschungAm: '2026-11-04' }, 'Berliner Tag, nicht der UTC-Tag 2026-11-03')
assert.deepEqual(felder('pe2_team'), { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z', loeschungAm: '2026-10-31' })
assert.deepEqual(felder('pe2_alt'), { disabledReason: null, disabledAt: '2026-10-01T10:00:00.000Z', loeschungAm: '2026-10-31' }, 'Altbestand ohne Grund')
assert.deepEqual(felder('pe2_aktiv'), { disabledReason: null, disabledAt: null, loeschungAm: null })
assert.equal(termine('pe2_aboende').state, 'disabled')
})
test('Abgeschaltetes News hat Grund und Zeitpunkt, aber kein Loeschdatum; die Teamansicht nennt es fuer den Terminplaner (R105)', async () => {
const lange_her = '2026-01-15T10:00:00.000Z'
aw.seed('websiteProjects', { $id: 'pe3_p', customerId: 'ce3', projectName: 'Alt abgeschaltet' })
aw.seed('portalFeatures', { $id: 'f_pe3_news', customerId: 'ce3', projectId: 'pe3_p', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: lange_her })
aw.seed('portalFeatures', { $id: 'f_pe3_termine', customerId: 'ce3', projectId: 'pe3_p', featureKey: 'termine', siteKey: 'bcdefghijklmnopq', state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: lange_her })
// Kundenansicht
const kunde = (await get('/api/funktionen', { cookie: customerCookie('ce3') })).projects.find((p) => p.projectId === 'pe3_p').features
assert.deepEqual([kunde.news.state, kunde.news.disabledReason, kunde.news.disabledAt, kunde.news.loeschungAm], ['disabled', 'team', lange_her, null])
assert.deepEqual([kunde.termine.disabledReason, kunde.termine.disabledAt, kunde.termine.loeschungAm], ['abo_ende', lange_her, '2026-02-14'])
// Teamansicht (adminView, GET /api/team/funktionen/:customerId): dieselben Felder, die WOMS-Anzeige (E12) liest loeschungAm
const team_ansicht = (await get('/api/team/funktionen/ce3', STAFF)).projects.find((p) => p.projectId === 'pe3_p').features
assert.deepEqual([team_ansicht.termine.state, team_ansicht.termine.disabledReason, team_ansicht.termine.disabledAt], ['disabled', 'abo_ende', lange_her])
assert.equal(team_ansicht.termine.loeschungAm, '2026-02-14', 'abgeschalteter Terminplaner traegt loeschungAm (Berliner Tag, 30 x 24 h spaeter)')
assert.equal(team_ansicht.termine.loeschungAm, loeschTag(lange_her))
assert.deepEqual([team_ansicht.news.disabledReason, team_ansicht.news.disabledAt, team_ansicht.news.loeschungAm], ['team', lange_her, null])
})
// --------------------------------------------------------- Kunde loeschen ---
/** Entwurf: Terminplaner im Zustand off samt Einstellung (termineConfig), nie bestellt. */
function legeEntwurf(customerId, projectId) {
const id = `f_${projectId}_termine`
aw.seed('portalFeatures', { $id: id, customerId, projectId, featureKey: 'termine', siteKey: 'abcdefghijklmnop', state: 'off', enabled: false })
aw.seed('termineConfig', { $id: id, customerId, projectId, einstellung: '{}', version: 1, updatedBy: 'kunde' })
}
test('Kunde loeschen schaltet seine Terminplaner ab (Notiz Kunde geloescht, Grund kunde_geloescht), nur seine', async () => {
aw.seed('customers', { $id: 'kt1', name: 'Weg Salon', email: 'weg@example.invalid' })
aw.seed('websiteProjects', { $id: 'ktp1', customerId: 'kt1', projectName: 'Weg Eins' })
aw.seed('websiteProjects', { $id: 'ktp2', customerId: 'kt1', projectName: 'Weg Zwei' })
aw.seed('websiteProjects', { $id: 'ktp3', customerId: 'kt1', projectName: 'Weg Drei' })
await sf.adminActivate('kt1', 'ktp1', 'termine', { by: 'test' })
await sf.adminActivate('kt1', 'ktp1', 'news', { by: 'test' })
await sf.adminActivate('kt1', 'ktp2', 'termine', { by: 'test' })
const schonAus = await sf.adminActivate('kt1', 'ktp3', 'termine', { by: 'test' })
const frueher = (await sf.disable(schonAus.$id, { by: 'test' })).disabledAt
const fremd = await sf.getFeatureDoc('c1', 'pt1', 'termine')
// Entwuerfe (off): der des Kunden samt Einstellung geht mit ihm, der eines anderen Kunden bleibt
aw.seed('websiteProjects', { $id: 'ktp5', customerId: 'kt1', projectName: 'Weg Fuenf' })
legeEntwurf('kt1', 'ktp5')
legeEntwurf('kt9', 'ktp9')
await deleteCustomerWithPortalAccess('kt1')
assert.equal(aw.docs('portalFeatures').has('f_ktp5_termine'), false, 'Entwurf des Kunden: Funktionsdokument weg')
assert.equal(aw.docs('termineConfig').has('f_ktp5_termine'), false, 'Entwurf des Kunden: Einstellung weg')
assert.equal(aw.docs('portalFeatures').has('f_ktp9_termine'), true, 'Entwurf eines anderen Kunden bleibt')
assert.equal(aw.docs('termineConfig').has('f_ktp9_termine'), true)
assert.equal(aw.docs('customers').has('kt1'), false)
for (const projekt of ['ktp1', 'ktp2']) {
const doc = await sf.getFeatureDoc('kt1', projekt, 'termine')
assert.deepEqual([doc.state, doc.enabled, doc.adminNote, doc.disabledReason], ['disabled', false, 'Kunde gelöscht', 'kunde_geloescht'], projekt)
assert.ok(Math.abs(Date.parse(doc.disabledAt) - Date.now()) < 5000, projekt)
}
const schonAusNachher = await sf.getFeatureDoc('kt1', 'ktp3', 'termine')
assert.equal(schonAusNachher.disabledAt, frueher, 'Beginn der 30 Tage bleibt')
assert.equal(schonAusNachher.disabledReason, 'team', 'ein schon abgeschalteter Terminplaner behaelt seinen Grund')
assert.equal((await sf.getFeatureDoc('kt1', 'ktp1', 'news')).state, 'active', 'News ist nicht Sache des Terminplaners')
assert.equal((await sf.getFeatureDoc('c1', 'pt1', 'termine')).state, fremd.state, 'anderer Kunde')
})
test('Kunde loeschen gelingt auch, wenn das Abschalten scheitert (Fehler im Log)', async (t) => {
const log = t.mock.method(console, 'error', () => {})
aw.seed('customers', { $id: 'kt2', name: 'Weg Zwei', email: 'weg2@example.invalid' })
aw.seed('websiteProjects', { $id: 'ktp4', customerId: 'kt2', projectName: 'Weg Vier' })
await sf.adminActivate('kt2', 'ktp4', 'termine', { by: 'test' })
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' })
await deleteCustomerWithPortalAccess('kt2')
assert.equal(aw.docs('customers').has('kt2'), false)
assert.ok(log.mock.calls.some((c) => /Terminplaner von Kunde kt2 nicht abgeschaltet/.test(String(c.arguments[0]))))
})
test('Kunde loeschen gelingt auch, wenn das Loeschen der Entwuerfe scheitert (Fehler im Log, die uebrigen Entwuerfe gehen trotzdem)', async (t) => {
const log = t.mock.method(console, 'error', () => {})
aw.seed('customers', { $id: 'kt3', name: 'Weg Drei', email: 'weg3@example.invalid' })
aw.seed('websiteProjects', { $id: 'ktp6', customerId: 'kt3', projectName: 'Weg Sechs' })
aw.seed('websiteProjects', { $id: 'ktp7', customerId: 'kt3', projectName: 'Weg Sieben' })
legeEntwurf('kt3', 'ktp6')
legeEntwurf('kt3', 'ktp7')
aw.failNext({ collection: 'termineConfig', method: 'DELETE', status: 500, message: 'kaputt' })
await deleteCustomerWithPortalAccess('kt3')
assert.equal(aw.docs('customers').has('kt3'), false, 'der Kunde ist geloescht')
assert.ok(log.mock.calls.some((c) => /Entwürfe von Kunde kt3 nicht gelöscht/.test(String(c.arguments[0]))), 'Logzeile')
assert.equal(aw.docs('portalFeatures').has('f_ktp6_termine'), true, 'der Entwurf mit Fehler bleibt (ein spaeterer Lauf nach 90 Tagen holt ihn nach)')
assert.equal(aw.docs('portalFeatures').has('f_ktp7_termine'), false, 'der andere Entwurf ist weg')
assert.equal(aw.docs('termineConfig').has('f_ktp7_termine'), false)
})
test('Kunde loeschen laesst Terminplaner, die nicht off sind, den Abschaltweg gehen (Frist von 30 Tagen) und loescht ihre Einstellung nicht', async () => {
aw.seed('customers', { $id: 'kt4', name: 'Weg Vier', email: 'weg4@example.invalid' })
aw.seed('websiteProjects', { $id: 'ktp8', customerId: 'kt4', projectName: 'Weg Acht' })
await sf.adminActivate('kt4', 'ktp8', 'termine', { by: 'test' })
aw.seed('termineConfig', { $id: 'f_ktp8_termine', customerId: 'kt4', projectId: 'ktp8', einstellung: '{}', version: 1, updatedBy: 'kunde' })
await deleteCustomerWithPortalAccess('kt4')
const doc = await sf.getFeatureDoc('kt4', 'ktp8', 'termine')
assert.equal(doc.state, 'disabled')
assert.equal(aw.docs('termineConfig').has('f_ktp8_termine'), true, 'die Einstellung eines aktiven Betriebs loescht erst der Tageslauf nach 30 Tagen')
})
// ------------------------------------------------ Team-Aktivierung nur mit laufendem Abo (E9) ---
// Ein bezahlter Terminplaner (unlockedByPurchase) darf das Team nur einschalten, solange das Wartungs-Abo laeuft: sonst nimmt der
// naechste Abgleichslauf die Kulanz zurueck und K22 ginge ein zweites Mal. Pilot und Kulanz ohne Kauf sowie News sind nicht betroffen.
const KEIN_ABO = 'Das Wartungs-Abo des Kunden läuft nicht. Der Terminplaner kann erst wieder freigeschaltet werden, wenn es läuft.'
let kaNr = 0
/** Kunde mit Projekt und Funktionsdokument (key, over); abo: 'wartung' | 'hosting' | null (kein Abo). Liefert { customerId, projectId, docId }. */
function legeKunde({ abo = null, aboOver = {}, key = 'termine', over = {} } = {}) {
kaNr += 1
const customerId = `ka${kaNr}`
const projectId = `kap${kaNr}`
const docId = `f_${projectId}_${key}`
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Salon ${kaNr}` })
if (abo) aw.seed('subscriptions', subscriptionDoc(`sub_ka${kaNr}`, { ...(abo === 'wartung' ? wartungDoc : hostingDoc), customerId, ...aboOver }))
aw.seed('portalFeatures', {
$id: docId, customerId, projectId, featureKey: key, siteKey: 'abcdefghijklmnop', state: 'disabled', enabled: false,
unlockedByPurchase: true, purchaseStatus: 'paid', disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z',
aboEndeMailAt: '2026-10-01T10:05:00.000Z', adminNote: 'Abgeschaltet: Wartungs-Abo beendet', ...over,
})
return { customerId, projectId, docId }
}
const docById = (id) => aw.docs('portalFeatures').get(id)
const aktivieren = (k, key = 'termine') => team(k.customerId, key, { projectId: k.projectId, action: 'activate', note: 'Kulanz' })
test('Team aktiviert einen bezahlten, abgeschalteten Terminplaner ohne laufendes Abo nicht: 409 mit Text, Zustand unveraendert', async () => {
for (const abo of [null, 'hosting']) {
const k = legeKunde({ abo })
const vorher = { ...docById(k.docId) }
const res = await aktivieren(k)
assert.equal(res.status, 409, `abo ${abo}`)
assert.equal(res.body.error, KEIN_ABO)
assert.deepEqual(docById(k.docId), vorher, 'kein Schreibzugriff: Zustand, Grund, Frist und Notiz bleiben')
}
// auch ein bezahlter Terminplaner, den das Team abgeschaltet hat, und ein ausgebliebenes Abo nach Kuendigung (canceled)
const team_aus = legeKunde({ over: { disabledReason: 'team', aboEndeMailAt: null } })
assert.equal((await aktivieren(team_aus)).status, 409)
assert.equal(docById(team_aus.docId).state, 'disabled')
const beendet = legeKunde({ abo: 'wartung', aboOver: { status: 'canceled', endedAt: '2026-10-01T10:00:00.000Z' } })
assert.equal((await aktivieren(beendet)).status, 409)
assert.equal(docById(beendet.docId).state, 'disabled')
})
test('Bezahlter Terminplaner in Einrichtung (setup) ohne Abo: das Team kann ihn auch nicht von Hand freischalten', async () => {
const k = legeKunde({ over: { state: 'setup', disabledReason: null, disabledAt: null, aboEndeMailAt: null, adminNote: '' } })
const res = await aktivieren(k)
assert.equal(res.status, 409)
assert.equal(res.body.error, KEIN_ABO)
assert.equal(docById(k.docId).state, 'setup')
})
test('Mit laufendem Wartungs-Abo aktiviert das Team den bezahlten Terminplaner: active, Grund und Frist leer, Marker leer', async () => {
const k = legeKunde({ abo: 'wartung' })
const res = await aktivieren(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
const doc = docById(k.docId)
assert.deepEqual([doc.state, doc.enabled, doc.disabledReason, doc.disabledAt, doc.aboEndeMailAt], ['active', true, null, null, null])
assert.equal(doc.unlockedByPurchase, true, 'der Kauf bleibt vermerkt')
assert.equal(featuresOf(res, k.projectId).termine.state, 'active')
// eine vorgemerkte Kuendigung laesst das Abo bis zum Ende laufen
const gekuendigt = legeKunde({ abo: 'wartung', aboOver: { cancelAtPeriodEnd: true } })
assert.equal((await aktivieren(gekuendigt)).status, 200)
assert.equal(docById(gekuendigt.docId).state, 'active')
})
test('Wechsel widerrufen (Spiegel steht noch auf Wartung): 409, denn das Wartungs-Abo laeuft nicht', async () => {
const k = legeKunde({ abo: 'wartung' })
aw.seed('contractEvents', {
$id: `ev_${k.customerId}`, customerId: k.customerId, subscriptionId: `sub_ka${kaNr}`, type: 'wechsel', productKey: 'wartung', details: '{}', status: 'widerrufen',
})
const res = await aktivieren(k)
assert.equal(res.status, 409)
assert.equal(res.body.error, KEIN_ABO)
assert.equal(docById(k.docId).state, 'disabled')
})
test('Ohne Kauf (unlockedByPurchase false: Pilot, Kulanz) braucht die Aktivierung kein Abo und liest es nicht', async () => {
for (const unlockedByPurchase of [false, undefined]) {
const k = legeKunde({ over: { unlockedByPurchase, purchaseStatus: '', disabledReason: 'team', aboEndeMailAt: null } })
const vorher = aw.listCalls('subscriptions').length
const res = await aktivieren(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(docById(k.docId).state, 'active')
assert.equal(aw.listCalls('subscriptions').length, vorher, 'keine Abo-Abfrage')
}
// kein Dokument bisher (ensureFeatureDoc legt es an): ebenfalls frei
aw.seed('websiteProjects', { $id: 'kap_neu', customerId: 'ka_neu', projectName: 'Neu' })
const neu = await team('ka_neu', 'termine', { projectId: 'kap_neu', action: 'activate' })
assert.equal(neu.status, 200)
})
test('News ist nicht betroffen: auch mit unlockedByPurchase und ohne Abo aktiviert das Team', async () => {
const k = legeKunde({ key: 'news', over: { disabledReason: 'team', aboEndeMailAt: null } })
const res = await aktivieren(k, 'news')
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(docById(k.docId).state, 'active')
})
test('Abo nicht lesbar: Fehler (5xx), nicht aktiviert; danach geht es mit lesbarem Abo', async (t) => {
const log = t.mock.method(console, 'error', () => {})
const k = legeKunde({ abo: 'wartung' })
const vorher = { ...docById(k.docId) }
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'kaputt' })
const res = await aktivieren(k)
assert.ok(res.status >= 500 && res.status < 600, `Status ${res.status}`)
assert.equal(typeof res.body.error, 'string')
assert.deepEqual(docById(k.docId), vorher, 'nicht aktiviert')
assert.ok(log.mock.calls.length >= 1, 'der Fehler steht im Log')
assert.equal((await aktivieren(k)).status, 200)
assert.equal(docById(k.docId).state, 'active')
})
test('Ereignisse nicht lesbar (Wechsel-Regel): Fehler, nicht aktiviert', async (t) => {
t.mock.method(console, 'error', () => {})
const k = legeKunde({ abo: 'wartung' })
aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 })
const res = await aktivieren(k)
assert.ok(res.status >= 500 && res.status < 600, `Status ${res.status}`)
assert.equal(docById(k.docId).state, 'disabled')
})
test('adminActivate (Dienst): bezahlter Terminplaner ohne Abo => Fehler 409, bei laufendem Abo und fuer Dokumente ohne Kauf aktiv', async () => {
const k = legeKunde()
await assert.rejects(() => sf.adminActivate(k.customerId, k.projectId, 'termine', { by: 'test' }), { status: 409, message: KEIN_ABO })
assert.equal(docById(k.docId).state, 'disabled')
const mit = legeKunde({ abo: 'wartung' })
assert.equal((await sf.adminActivate(mit.customerId, mit.projectId, 'termine', { by: 'test' })).state, 'active')
const pilot = legeKunde({ over: { unlockedByPurchase: false } })
assert.equal((await sf.adminActivate(pilot.customerId, pilot.projectId, 'termine', { by: 'test' })).state, 'active')
})
// ------------------------------------------ Team-Aktionen Freischalten (Abnahme) und Ticket nachlegen (D7) ---
// abnahme: Terminplaner in setup freischalten, ohne dass das Einrichtungs-Ticket geschlossen wird (Ticket und Kauf bleiben, K16 einmal).
// ticket: ein fehlendes Einrichtungs-Ticket nachlegen - (a) Ticket nie angelegt: Zahlung bei Stripe pruefen und den Weg des Webhooks gehen
// (Ticket, K15, T16 genau einmal), (b) Ticket geloescht: neues Ticket, keine Mails, (c) Ticket da: nichts.
const K16_BETREFF = { termine: 'Dein Terminplaner ist bereit', news: 'Deine News-Pinnwand ist bereit' }
const K15_BETREFF = 'Deine Bestellung: Terminplaner'
const T16_BETREFF = 'Neue Funktion bestellt: Terminplaner'
const NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.'
const NUR_SETUP_TICKET = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.'
const OHNE_ABO = 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.'
const OHNE_EINSTELLUNG = 'Es sind noch keine Einstellungen gespeichert.'
const ABO_UNKLAR = 'Das Abo konnte gerade nicht geprüft werden.'
const NICHT_BEZAHLT = 'Die Zahlung ist noch nicht eingegangen.'
const KEINE_AKTION = 'Diese Aktion gibt es für diese Funktion nicht.'
const mailsAn = (email, subject) => mails.sent.filter((m) => m.subject === subject && m.to.some((t) => t.address === email))
const seit = (n) => mails.sent.slice(n)
let d7Nr = 0
/**
* Kunde mit Projekt, Funktionsdokument (Zustand state), Wartungs-Abo und gespeicherter Einstellung.
* ticket: 'Open' | 'Closed' (Ticket da) | 'geloescht' (ticketId gesetzt, Ticket fehlt) | null (nie angelegt).
* session: null | 'paid' | 'unpaid' legt die Stripe-Sitzung samt Rechnung im Nachbau an und traegt sie am Dokument ein.
*/
function legeEinrichtung({ key = 'termine', abo = 'wartung', einstellung = true, ticket = 'Open', session = null, state = 'setup', over = {} } = {}) {
d7Nr += 1
const n = d7Nr
const customerId = `cd${n}`
const projectId = `cdp${n}`
const docId = `f_${projectId}_${key}`
const ticketId = `t_cd${n}`
const sessionId = session ? `cs_cd${n}` : ''
const email = `cd${n}@example.invalid`
aw.seed('customers', { $id: customerId, name: `Salon ${n}`, email, status: 'active' })
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Salon ${n}` })
if (abo) aw.seed('subscriptions', subscriptionDoc(`sub_cd${n}`, { ...(abo === 'wartung' ? wartungDoc : hostingDoc), customerId }))
if (ticket === 'Open' || ticket === 'Closed') {
aw.seed('workorders', { $id: ticketId, woid: 7000 + n, status: ticket, topic: `Terminplaner einbauen – Salon ${n}`, details: 'Original-Ticket' })
}
if (session) {
st.invoices.set(`in_cd${n}`, { id: `in_cd${n}`, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/in_cd${n}` })
st.checkoutSessions.set(sessionId, {
id: sessionId, object: 'checkout.session', mode: 'payment', status: 'complete', payment_status: session, invoice: `in_cd${n}`,
metadata: { customerId, projectId, featureKey: key },
})
}
aw.seed('portalFeatures', {
$id: docId, customerId, projectId, featureKey: key, siteKey: 'abcdefghijklmnop', state, enabled: state === 'active',
unlockedByPurchase: true, purchaseStatus: 'paid', orderedAt: '2026-10-01T10:00:00.000Z', stripeSessionId: sessionId,
...(ticket ? { ticketId } : {}), adminNote: '', ...over,
})
if (einstellung) {
aw.seed('termineConfig', {
$id: docId, customerId, projectId, einstellung: JSON.stringify({ betrieb: { name: `Salon ${n}` } }), version: 1, updatedAt: '2026-10-03T08:00:00.000Z', updatedBy: 'test',
})
}
return { customerId, projectId, docId, ticketId, sessionId, email }
}
const abnahme = (k, key = 'termine', opts) => team(k.customerId, key, { projectId: k.projectId, action: 'abnahme' }, opts)
const nachlegen = (k, key = 'termine', opts) => team(k.customerId, key, { projectId: k.projectId, action: 'ticket' }, opts)
const ticketDoc = (id) => aw.docs('workorders').get(id)
const dienst = (k, fn, key = 'termine') => sf[fn]({ customerId: k.customerId, projectId: k.projectId, key, by: 'Andrej', adminNote: `x Andrej: ${fn}` })
// ----------------------------------------------------------------------------- abnahme ---
test('abnahme auf setup mit Einstellung und Abo: active, Ticket (Open) und Kauf bleiben, adminNote, K16 genau einmal', async () => {
const k = legeEinrichtung()
const res = await abnahme(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(res.body.projects.map((p) => p.projectId), [k.projectId])
const doc = docById(k.docId)
assert.deepEqual([doc.state, doc.enabled], ['active', true])
assert.ok(Math.abs(Date.parse(doc.activatedAt) - Date.now()) < 5000, 'activatedAt gesetzt')
assert.equal(featuresOf(res, k.projectId).termine.state, 'active')
assert.equal(featuresOf(res, k.projectId).termine.activatedAt, doc.activatedAt)
assert.equal(doc.ticketId, k.ticketId, 'ticketId bleibt')
assert.equal(ticketDoc(k.ticketId).status, 'Open', 'das Ticket wird nicht geschlossen')
assert.equal(doc.unlockedByPurchase, true)
assert.equal(doc.purchaseStatus, 'paid')
assert.match(doc.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: abnahme$/)
const k16 = mailsAn(k.email, K16_BETREFF.termine)
assert.equal(k16.length, 1, 'K16 genau einmal')
assert.match(k16[0].text, /dashboard\.html#termine/)
})
test('abnahme: zweiter Klick => 200 ohne zweite Mail und ohne Schreibzugriff; Dienst meldet activated true, dann false', async () => {
const k = legeEinrichtung()
assert.equal((await abnahme(k)).status, 200)
const vorher = { ...docById(k.docId) }
const nochmal = await abnahme(k)
assert.equal(nochmal.status, 200)
assert.equal(featuresOf(nochmal, k.projectId).termine.state, 'active')
assert.deepEqual(docById(k.docId), vorher, 'nichts geschrieben: activatedAt und Notiz bleiben')
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1)
const k2 = legeEinrichtung()
const erster = await dienst(k2, 'freischaltenNachAbnahme')
assert.equal(erster.activated, true)
assert.equal(erster.doc.state, 'active')
const zweiter = await dienst(k2, 'freischaltenNachAbnahme')
assert.equal(zweiter.activated, false)
assert.equal(zweiter.doc.state, 'active')
assert.equal(mailsAn(k2.email, K16_BETREFF.termine).length, 1)
})
test('abnahme: zwei parallele Klicks => eine Mail, ein Zustandswechsel', async () => {
const k = legeEinrichtung()
const [a, b] = await Promise.all([abnahme(k), abnahme(k)])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(docById(k.docId).state, 'active')
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1)
// auch ohne die Sperre der Route: zwei gleichzeitige Dienstaufrufe aktivieren nur einmal
const k2 = legeEinrichtung()
const [x, y] = await Promise.all([dienst(k2, 'freischaltenNachAbnahme'), dienst(k2, 'freischaltenNachAbnahme')])
assert.deepEqual([x.activated, y.activated].sort(), [false, true])
assert.equal(mailsAn(k2.email, K16_BETREFF.termine).length, 1)
})
test('abnahme und der Freischalt-Job (runActivation) bei geschlossenem Ticket gleichzeitig => eine Mail insgesamt, in beiden Reihenfolgen', async () => {
for (const abnahmeZuerst of [true, false]) {
const k = legeEinrichtung({ ticket: 'Closed' })
const start = [() => abnahme(k), () => sf.runActivation({ customerId: k.customerId })]
if (!abnahmeZuerst) start.reverse()
await Promise.all(start.map((f) => f()))
assert.equal(docById(k.docId).state, 'active', `abnahmeZuerst ${abnahmeZuerst}`)
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1, `abnahmeZuerst ${abnahmeZuerst}`)
}
})
test('abnahme: bereits aktive Funktion (Pilot, Kulanz) => 200 ohne Mail und ohne Schreibzugriff', async () => {
const k = legeEinrichtung({ state: 'active', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '', activatedAt: '2026-10-02T10:00:00.000Z' } })
const vorher = { ...docById(k.docId) }
const res = await abnahme(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(docById(k.docId), vorher)
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
})
test('abnahme: off, ordered und disabled => 409 mit Text, nichts geschrieben, keine Mail', async () => {
const faelle = [
legeEinrichtung({ state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } }),
legeEinrichtung({ state: 'ordered', ticket: null, over: { purchaseStatus: 'ordered', unlockedByPurchase: false } }),
legeEinrichtung({ state: 'disabled', over: { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' } }),
]
for (const k of faelle) {
const vorher = { ...docById(k.docId) }
const res = await abnahme(k)
assert.equal(res.status, 409, vorher.state)
assert.equal(res.body.error, NUR_SETUP, vorher.state)
assert.deepEqual(docById(k.docId), vorher, vorher.state)
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0, vorher.state)
}
})
test('abnahme ohne laufendes Wartungs-Abo (kein Abo, nur Hosting, beendet) => 409, setup bleibt, keine Mail', async () => {
const faelle = [
legeEinrichtung({ abo: null }),
legeEinrichtung({ abo: 'hosting' }),
legeEinrichtung({ abo: 'wartung' }),
]
// beendetes Wartungs-Abo: das dritte Abo auf canceled stellen
aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: faelle[2].customerId, status: 'canceled', endedAt: '2026-10-01T10:00:00.000Z' }))
for (const k of faelle) {
const vorher = { ...docById(k.docId) }
const res = await abnahme(k)
assert.equal(res.status, 409, k.customerId)
assert.equal(res.body.error, OHNE_ABO)
assert.deepEqual(docById(k.docId), vorher, 'setup bleibt, nichts geschrieben')
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
}
})
test('abnahme: eine vorgemerkte Kuendigung und past_due laufen noch => 200', async () => {
for (const aboOver of [{ cancelAtPeriodEnd: true }, { status: 'past_due' }]) {
const k = legeEinrichtung()
aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: k.customerId, ...aboOver }))
assert.equal((await abnahme(k)).status, 200, JSON.stringify(aboOver))
assert.equal(docById(k.docId).state, 'active')
}
})
test('abnahme ohne gespeicherte Einstellung => 409, setup bleibt', async () => {
const k = legeEinrichtung({ einstellung: false })
const vorher = { ...docById(k.docId) }
const res = await abnahme(k)
assert.equal(res.status, 409)
assert.equal(res.body.error, OHNE_EINSTELLUNG)
assert.deepEqual(docById(k.docId), vorher)
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
})
test('abnahme: Abo nicht lesbar => 503 mit Text (geloggt), setup bleibt; danach geht es', async (t) => {
const log = t.mock.method(console, 'error', () => {})
const k = legeEinrichtung()
const vorher = { ...docById(k.docId) }
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Nachbau: Abo kaputt' })
const res = await abnahme(k)
assert.equal(res.status, 503)
assert.equal(res.body.error, ABO_UNKLAR)
assert.deepEqual(docById(k.docId), vorher, 'nie freischalten, wenn etwas nicht lesbar war')
assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0)
assert.ok(log.mock.calls.some((c) => /nicht prüfbar/.test(c.arguments.join(' '))), 'der Fehler steht im Log')
assert.equal((await abnahme(k)).status, 200)
assert.equal(docById(k.docId).state, 'active')
})
test('abnahme: Projekt eines anderen Kunden => 403, Kundensitzung statt JWT => 401/403, ohne Dokument => 409 und nichts angelegt', async () => {
const k = legeEinrichtung()
const vorher = { ...docById(k.docId) }
assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'abnahme' })).status, 403, 'pt3 gehoert c2')
assert.equal((await team(k.customerId, 'termine', { projectId: 'pt3', action: 'abnahme' })).status, 403)
assert.equal((await team('c1', 'termine', { projectId: 'gibtsnicht', action: 'abnahme' })).status, 403)
const alsKunde = await abnahme(k, 'termine', { cookie: customerCookie(k.customerId) })
assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`)
assert.deepEqual(docById(k.docId), vorher)
const docs = aw.docs('portalFeatures').size
aw.seed('websiteProjects', { $id: 'pt_d7_leer', customerId: 'c1', projectName: 'Ohne Dokument D7' })
const ohneDoc = await team('c1', 'termine', { projectId: 'pt_d7_leer', action: 'abnahme' })
assert.equal(ohneDoc.status, 409)
assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
assert.equal(aw.docs('portalFeatures').size, docs, 'kein Dokument entstanden')
assert.equal(mails.sent.filter((m) => m.subject === K16_BETREFF.termine && m.to.some((to) => to.address === 'k@example.invalid')).length, 0)
})
test('abnahme bei News funktioniert ohne Abo-Pruefung (es gibt keinen Haken freischaltbar): active, K16 News, Abo nicht gelesen', async () => {
const k = legeEinrichtung({ key: 'news', abo: null, einstellung: false })
const abfragen = aw.listCalls('subscriptions').length
const res = await abnahme(k, 'news')
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(featuresOf(res, k.projectId).news.state, 'active')
assert.equal(docById(k.docId).state, 'active')
assert.equal(docById(k.docId).ticketId, k.ticketId)
assert.equal(aw.listCalls('subscriptions').length, abfragen, 'keine Abo-Abfrage')
assert.equal(mailsAn(k.email, K16_BETREFF.news).length, 1)
})
test('abnahme und ticket bei analyse und unbekannten Schluesseln => 404, nichts angelegt', async () => {
const k = legeEinrichtung()
const docs = aw.docs('portalFeatures').size
for (const action of ['abnahme', 'ticket']) {
for (const key of ['analyse', 'kalender']) {
const res = await team(k.customerId, key, { projectId: k.projectId, action })
assert.equal(res.status, 404, `${key} ${action}`)
}
}
assert.equal(aw.docs('portalFeatures').size, docs)
})
test('abnahme und ticket brauchen ticketFreischaltung im Register, sonst 409 ohne Schreibzugriff', async () => {
const k = legeEinrichtung({ key: 'news' })
const vorher = { ...docById(k.docId) }
const alt = config.features.news.ticketFreischaltung
config.features.news.ticketFreischaltung = false
try {
for (const action of ['abnahme', 'ticket']) {
const res = await team(k.customerId, 'news', { projectId: k.projectId, action })
assert.equal(res.status, 409, action)
assert.equal(res.body.error, KEINE_AKTION, action)
}
} finally {
config.features.news.ticketFreischaltung = alt
}
assert.deepEqual(docById(k.docId), vorher)
assert.equal(mailsAn(k.email, K16_BETREFF.news).length, 0)
// activate und disable gehoeren nicht dazu und gehen weiter
assert.equal((await team(k.customerId, 'news', { projectId: k.projectId, action: 'disable' })).status, 200)
})
test('abnahme: scheitert der Versand von K16, ist die Funktion trotzdem freigeschaltet (Fehler im Log)', async (t) => {
const log = t.mock.method(console, 'error', () => {})
const k = legeEinrichtung()
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } })
t.after(() => setMailTransportForTests(mails))
const res = await abnahme(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(docById(k.docId).state, 'active')
assert.ok(log.mock.calls.some((c) => /K16 an Kunde .* fehlgeschlagen/.test(c.arguments.join(' '))))
})
// ----------------------------------------------------------------------------- ticket ---
test('ticket (a): setup ohne ticketId, Sitzung bezahlt => Ticket mit Kurzfassung, K15 und T16 je einmal; zweiter Klick und Webhook-Wiederholung aendern nichts', async () => {
const k = legeEinrichtung({ ticket: null, session: 'paid' })
const tickets = workorders()
const mailsVorher = mails.sent.length
const res = await nachlegen(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
const doc = docById(k.docId)
assert.ok(doc.ticketId, 'ticketId gesetzt')
assert.equal(workorders(), tickets + 1)
const ticket = ticketDoc(doc.ticketId)
assert.match(ticket.topic, /^Terminplaner einbauen – Salon \d+$/)
assert.ok(ticket.details.includes('Konfigurationsstand: Version 1'), ticket.details)
assert.equal(ticket.status, 'Open')
assert.deepEqual([doc.state, doc.unlockedByPurchase], ['setup', true])
assert.match(doc.adminNote, /Andrej: ticket/)
assert.equal(featuresOf(res, k.projectId).termine.ticketMissing, false)
// K15 an den Kunden, T16 ans Team, je einmal
assert.equal(mailsAn(k.email, K15_BETREFF).length, 1)
const neu = seit(mailsVorher)
assert.equal(neu.filter((m) => m.subject === T16_BETREFF).length, 1)
assert.match(neu.find((m) => m.subject === T16_BETREFF).text, new RegExp(String(ticket.woid)))
assert.equal(neu.length, 2, 'sonst nichts')
// zweiter Klick: Ticket vorhanden => nichts Neues
const nochmal = await nachlegen(k)
assert.equal(nochmal.status, 200)
assert.equal(workorders(), tickets + 1)
assert.equal(docById(k.docId).ticketId, doc.ticketId)
assert.equal(seit(mailsVorher).length, 2, 'keine weiteren Mails')
// ein folgender Webhook mit derselben Sitzung (Stripe-Retry) sendet und legt nichts doppelt an
await sf.handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: st.checkoutSessions.get(k.sessionId) } })
assert.equal(workorders(), tickets + 1)
assert.equal(seit(mailsVorher).length, 2, 'Webhook-Wiederholung: keine weiteren Mails')
assert.equal(docById(k.docId).ticketId, doc.ticketId)
})
test('ticket (a), Dienst: created true beim Anlegen, false danach; zwei gleichzeitige Aufrufe legen ein Ticket und senden K15/T16 einmal', async () => {
const k = legeEinrichtung({ ticket: null, session: 'paid' })
const tickets = workorders()
const mailsVorher = mails.sent.length
const [a, b] = await Promise.all([dienst(k, 'ticketNachlegen'), dienst(k, 'ticketNachlegen')])
assert.deepEqual([a.created, b.created].sort(), [false, true])
assert.equal(a.doc.ticketId, b.doc.ticketId)
assert.equal(workorders(), tickets + 1)
assert.equal(seit(mailsVorher).length, 2, 'K15 und T16 je einmal')
const spaeter = await dienst(k, 'ticketNachlegen')
assert.equal(spaeter.created, false)
assert.equal(workorders(), tickets + 1)
})
test('ticket (a): zwei parallele Klicks => ein Ticket, K15 und T16 je einmal', async () => {
const k = legeEinrichtung({ ticket: null, session: 'paid' })
const tickets = workorders()
const mailsVorher = mails.sent.length
const [a, b] = await Promise.all([nachlegen(k), nachlegen(k)])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(workorders(), tickets + 1)
assert.equal(seit(mailsVorher).length, 2)
assert.equal(mailsAn(k.email, K15_BETREFF).length, 1)
})
test('ticket (a): Sitzung nicht bezahlt => 409, kein Ticket, keine Mail; Stripe-Ausfall => 503, danach geht es', async (t) => {
const log = t.mock.method(console, 'error', () => {})
const unbezahlt = legeEinrichtung({ ticket: null, session: 'unpaid' })
const tickets = workorders()
const mailsVorher = mails.sent.length
const vorher = { ...docById(unbezahlt.docId) }
const res = await nachlegen(unbezahlt)
assert.equal(res.status, 409)
assert.equal(res.body.error, NICHT_BEZAHLT)
assert.deepEqual(docById(unbezahlt.docId), vorher)
assert.equal(workorders(), tickets)
const k = legeEinrichtung({ ticket: null, session: 'paid' })
const vorherK = { ...docById(k.docId) }
st.failNext({ method: 'GET', path: /^\/v1\/checkout\/sessions\//, status: 400, message: 'Nachbau: Stripe kaputt' })
const aus = await nachlegen(k)
assert.equal(aus.status, 503)
assert.equal(typeof aus.body.error, 'string')
assert.ok(!JSON.stringify(aus.body).includes('Nachbau'), 'nichts Technisches nach aussen')
assert.deepEqual(docById(k.docId), vorherK)
assert.equal(workorders(), tickets)
assert.equal(seit(mailsVorher).length, 0)
assert.ok(log.mock.calls.some((c) => /Nachbau: Stripe kaputt/.test(c.arguments.join(' '))), 'Ursache im Log')
assert.equal((await nachlegen(k)).status, 200, 'Stripe wieder da')
assert.equal(workorders(), tickets + 1)
assert.equal(seit(mailsVorher).length, 2)
})
test('ticket (a): ohne vermerkte Sitzung => 409, Stripe wird nicht gefragt', async () => {
const k = legeEinrichtung({ ticket: null })
const aufrufe = st.calls.length
const res = await nachlegen(k)
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Zu dieser Funktion ist keine Zahlung vermerkt.')
assert.equal(st.calls.length, aufrufe)
})
test('ticket (b): Ticket geloescht => neues Ticket mit neuer ticketId, keine Mail, Stripe nicht gefragt', async () => {
const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
const tickets = workorders()
const mailsVorher = mails.sent.length
const aufrufe = st.calls.length
assert.equal(ticketDoc(k.ticketId), undefined, 'Ausgangslage: Ticket fehlt')
assert.equal(featuresOf({ body: await get(`/api/team/funktionen/${k.customerId}`, STAFF) }, k.projectId).termine.ticketMissing, true)
const res = await nachlegen(k)
assert.equal(res.status, 200, JSON.stringify(res.body))
const doc = docById(k.docId)
assert.ok(doc.ticketId && doc.ticketId !== k.ticketId, 'neue ticketId')
assert.equal(workorders(), tickets + 1)
const ticket = ticketDoc(doc.ticketId)
assert.match(ticket.topic, /^Terminplaner einbauen – Salon \d+$/)
assert.ok(ticket.details.includes('Konfigurationsstand: Version 1'))
assert.equal(ticket.status, 'Open')
assert.equal(doc.state, 'setup')
assert.match(doc.adminNote, /Andrej: ticket/)
assert.equal(featuresOf(res, k.projectId).termine.ticketMissing, false)
assert.equal(seit(mailsVorher).length, 0, 'keine Mails: K15/T16 gingen beim ersten Mal raus')
assert.equal(st.calls.length, aufrufe, 'Stripe nicht gefragt')
})
test('ticket (b): zwei parallele Klicks und zwei gleichzeitige Dienstaufrufe => genau ein neues Ticket', async () => {
const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
const tickets = workorders()
const [a, b] = await Promise.all([nachlegen(k), nachlegen(k)])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(workorders(), tickets + 1)
const k2 = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
const tickets2 = workorders()
const [x, y] = await Promise.all([dienst(k2, 'ticketNachlegen'), dienst(k2, 'ticketNachlegen')])
assert.deepEqual([x.created, y.created].sort(), [false, true])
assert.equal(workorders(), tickets2 + 1)
assert.equal(x.doc.ticketId, y.doc.ticketId)
})
test('ticket (c): Ticket vorhanden (offen oder geschlossen) => no-op, created false, nichts geschrieben, Stripe nicht gefragt', async () => {
for (const ticket of ['Open', 'Closed']) {
const k = legeEinrichtung({ ticket, session: 'paid' })
const tickets = workorders()
const mailsVorher = mails.sent.length
const aufrufe = st.calls.length
const vorher = { ...docById(k.docId) }
const res = await nachlegen(k)
assert.equal(res.status, 200, ticket)
assert.deepEqual(docById(k.docId), vorher, ticket)
assert.equal(workorders(), tickets, ticket)
assert.equal(seit(mailsVorher).length, 0, ticket)
assert.equal(st.calls.length, aufrufe, ticket)
const dienstErgebnis = await dienst(k, 'ticketNachlegen')
assert.equal(dienstErgebnis.created, false, ticket)
assert.equal(dienstErgebnis.doc.ticketId, k.ticketId, ticket)
}
})
test('ticket: nur in setup - off, ordered, active und disabled => 409; ohne Dokument => 409', async () => {
const faelle = [
legeEinrichtung({ state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } }),
legeEinrichtung({ state: 'ordered', ticket: null, session: 'paid', over: { purchaseStatus: 'ordered', unlockedByPurchase: false } }),
legeEinrichtung({ state: 'active', ticket: 'geloescht', session: 'paid' }),
legeEinrichtung({ state: 'disabled', ticket: null, session: 'paid', over: { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' } }),
]
const tickets = workorders()
const mailsVorher = mails.sent.length
for (const k of faelle) {
const vorher = { ...docById(k.docId) }
const res = await nachlegen(k)
assert.equal(res.status, 409, vorher.state)
assert.equal(res.body.error, NUR_SETUP_TICKET, vorher.state)
assert.deepEqual(docById(k.docId), vorher, vorher.state)
}
assert.equal(workorders(), tickets)
assert.equal(seit(mailsVorher).length, 0)
aw.seed('websiteProjects', { $id: 'pt_d7_leer2', customerId: 'c1', projectName: 'Ohne Dokument D7b' })
const docs = aw.docs('portalFeatures').size
const ohneDoc = await team('c1', 'termine', { projectId: 'pt_d7_leer2', action: 'ticket' })
assert.equal(ohneDoc.status, 409)
assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
assert.equal(aw.docs('portalFeatures').size, docs)
})
test('ticket: fremdes Projekt => 403, Kundensitzung statt JWT => 401/403, nichts geschrieben', async () => {
const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' })
const vorher = { ...docById(k.docId) }
const tickets = workorders()
assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'ticket' })).status, 403)
const alsKunde = await nachlegen(k, 'termine', { cookie: customerCookie(k.customerId) })
assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`)
assert.deepEqual(docById(k.docId), vorher)
assert.equal(workorders(), tickets)
})
test('ticket bei News (a): derselbe Weg des Webhooks, K15 und T16 der News-Pinnwand', async () => {
const k = legeEinrichtung({ key: 'news', abo: null, einstellung: false, ticket: null, session: 'paid' })
const mailsVorher = mails.sent.length
const res = await nachlegen(k, 'news')
assert.equal(res.status, 200, JSON.stringify(res.body))
const doc = docById(k.docId)
assert.ok(doc.ticketId)
assert.equal(doc.state, 'setup')
const neu = seit(mailsVorher)
assert.equal(neu.filter((m) => m.subject === 'Neue Funktion bestellt: News-Pinnwand').length, 1)
assert.equal(neu.filter((m) => m.to.some((to) => to.address === k.email)).length, 1, 'K15 an den Kunden')
})