1366 lines
81 KiB
JavaScript
1366 lines
81 KiB
JavaScript
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 { ausVorlage } = await import('../server/services/termine/vorlagen.js')
|
||
const { neuerFeedToken } = await import('../server/services/termine/einstellungen.js')
|
||
const { updateDocument } = await import('../server/services/appwriteAdmin.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')
|
||
})
|
||
|
||
// ------------------------------------------- Team-Route Einbauanleitung (D8) ---
|
||
// GET /api/team/funktionen/:customerId/termine/anleitung?projectId=: die volle Anleitung (ohne `kurz`) fuer das WOMS-Panel, nur lesend,
|
||
// in jedem Zustand und bei jedem Modus. Dazu hindernis je setup-Dokument in der Kundenansicht des Teams (GET /:customerId).
|
||
const ANLEITUNG_IDS = [
|
||
'modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen',
|
||
'einbau', 'snippet', 'css', 'datenschutz_sie', 'datenschutz_du', 'checkliste', 'abrechnung',
|
||
]
|
||
const ANLEITUNG_KEYS = ['abschnitte', 'hindernis', 'stand', 'state', 'text', 'ticketVersion', 'ticketWoid', 'warnungen'] // sortiert
|
||
const D8_BETRIEB = {
|
||
name: 'Salon Muster', email: 'inhaber@salon-muster.example', telefon: '0631 123456', adresse: 'Hauptstraße 1, 67655 Kaiserslautern',
|
||
kleinunternehmer: false, impressumUrl: 'https://salon-muster.example/impressum', datenschutzUrl: 'https://salon-muster.example/datenschutz', bundesland: 'RP',
|
||
}
|
||
/** Salon-Vorlage mit einer zweiten Person; beide Personen tragen einen feedToken (nie in die Antwort). */
|
||
function d8Einstellung() {
|
||
const e = ausVorlage('salon', D8_BETRIEB, new Date('2026-10-05T10:00:00Z'))
|
||
e.ressourcen.push({ id: 'r_ben00001', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['08:00', '16:00']] }, feedToken: neuerFeedToken() })
|
||
return e
|
||
}
|
||
const TICKET_MIT_STAND = (version) =>
|
||
`Funktion: Terminplaner\nProjekt: Salon\nDomain: salon.example\nKunde: Salon\nsiteKey: abcdefghijklmnop\nKonfigurationsstand: Version ${version}, 03.10.2026\n\nSchritte:\n1. WOMS`
|
||
|
||
/**
|
||
* Funktionsdokument wie legeEinrichtung (Abo, Ticket), aber mit einer vollen Einstellung in termineConfig.
|
||
* einstellung: Objekt | false (kein termineConfig-Dokument) | string (roh gespeichert); ticketDetails ersetzt die Details eines vorhandenen Tickets.
|
||
*/
|
||
function legeAnleitung({ einstellung = d8Einstellung(), version = 1, ticketDetails, ...rest } = {}) {
|
||
const k = legeEinrichtung({ ...rest, einstellung: false })
|
||
if (einstellung !== false) {
|
||
aw.seed('termineConfig', {
|
||
$id: k.docId, customerId: k.customerId, projectId: k.projectId,
|
||
einstellung: typeof einstellung === 'string' ? einstellung : JSON.stringify(einstellung), version, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: 'test',
|
||
})
|
||
}
|
||
const ticket = ticketDoc(k.ticketId)
|
||
if (ticketDetails !== undefined && ticket) {
|
||
aw.seed('workorders', { $id: k.ticketId, woid: ticket.woid, status: ticket.status, topic: ticket.topic, details: ticketDetails })
|
||
}
|
||
return k
|
||
}
|
||
const anleitungPfad = (k, query = `projectId=${k.projectId}`) => `/api/team/funktionen/${k.customerId}/termine/anleitung${query ? `?${query}` : ''}`
|
||
const anleitung = (k, opts = STAFF, query) => rawGet(anleitungPfad(k, query), opts)
|
||
const warnungDrift = (ticketVersion, jetzt) => `Einstellungen seit der Bestellung geändert (Ticket Version ${ticketVersion}, jetzt ${jetzt}).`
|
||
|
||
/** Fuehrt fn aus und zaehlt die schreibenden Anfragen (alles ausser GET) an den Appwrite-Nachbau. */
|
||
async function zaehleSchreibzugriffe(fn) {
|
||
const echt = globalThis.fetch
|
||
const schreibend = []
|
||
globalThis.fetch = async (url, optionen) => {
|
||
const methode = optionen?.method ?? 'GET'
|
||
if (methode !== 'GET' && String(url).startsWith(aw.url)) schreibend.push(`${methode} ${String(url).replace(/\?.*/, '')}`)
|
||
return echt(url, optionen)
|
||
}
|
||
try {
|
||
return { ergebnis: await fn(), schreibend }
|
||
} finally {
|
||
globalThis.fetch = echt
|
||
}
|
||
}
|
||
const modusMerken = (t) => {
|
||
const vorher = { mode: config.termine.mode, pilotEmails: [...config.termine.pilotEmails] }
|
||
t.after(() => termineMit(vorher.mode, vorher.pilotEmails))
|
||
}
|
||
|
||
test('Anleitung: ohne JWT 401, mit Kundensitzung statt JWT 401/403, die Route gibt es im Kundenrouter nicht (404)', async () => {
|
||
const k = legeAnleitung()
|
||
const ohne = await anleitung(k, {})
|
||
assert.equal(ohne.status, 401)
|
||
const alsKunde = await anleitung(k, { cookie: customerCookie(k.customerId) })
|
||
assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`)
|
||
assert.ok(!JSON.stringify(alsKunde.body).includes('abschnitte'), 'keine Anleitung fuer den Kunden')
|
||
// kein Pfad unter /api/funktionen (Kundenrouter), auch nicht mit gueltiger Kundensitzung
|
||
const kundenpfad = await rawGet(`/api/funktionen/termine/anleitung?projectId=${k.projectId}`, { cookie: customerCookie(k.customerId) })
|
||
assert.equal(kundenpfad.status, 404)
|
||
assert.equal((await rawGet('/api/funktionen/termine/anleitung', { cookie: customerCookie(k.customerId) })).status, 404)
|
||
})
|
||
|
||
test('Anleitung: projectId fehlt oder ist leer oder doppelt => 400; fremdes oder unbekanntes Projekt => 403; ohne Dokument => 409 ohne Dokument anzulegen', async () => {
|
||
const k = legeAnleitung()
|
||
for (const query of ['', 'projectId=', 'projectId=a&projectId=b', 'projekt=x']) {
|
||
const res = await anleitung(k, STAFF, query)
|
||
assert.equal(res.status, 400, query)
|
||
assert.equal(typeof res.body.error, 'string', query)
|
||
}
|
||
// pt3 gehoert c2, nicht dem Kunden in der URL
|
||
assert.equal((await anleitung(k, STAFF, 'projectId=pt3')).status, 403)
|
||
assert.equal((await rawGet(`/api/team/funktionen/c1/termine/anleitung?projectId=pt3`, STAFF)).status, 403, 'Projekt von c2 fuer c1')
|
||
assert.equal((await anleitung(k, STAFF, 'projectId=gibtsnicht')).status, 403)
|
||
const docs = aw.docs('portalFeatures').size
|
||
aw.seed('websiteProjects', { $id: 'pt_d8_leer', customerId: 'c1', projectName: 'Ohne Dokument D8' })
|
||
const ohneDoc = await rawGet('/api/team/funktionen/c1/termine/anleitung?projectId=pt_d8_leer', STAFF)
|
||
assert.equal(ohneDoc.status, 409)
|
||
assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner')
|
||
assert.equal(ohneDoc.body.error, `Für dieses Projekt gibt es noch ${sf.FEATURE_LABELS.termine.keine}`)
|
||
assert.equal(aw.docs('portalFeatures').size, docs, 'kein Dokument entstanden')
|
||
// ein Dokument der News-Pinnwand ist kein Terminplaner
|
||
const news = legeEinrichtung({ key: 'news', einstellung: false })
|
||
assert.equal((await rawGet(`/api/team/funktionen/${news.customerId}/termine/anleitung?projectId=${news.projectId}`, STAFF)).status, 409)
|
||
})
|
||
|
||
test('Anleitung: 200 in jedem Zustand und bei Modus off, nur lesend, ohne Kundenansicht-Protokoll, mit state und ohne kurz', async (t) => {
|
||
modusMerken(t)
|
||
termineMit('off')
|
||
const faelle = {
|
||
off: { state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } },
|
||
ordered: { state: 'ordered', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: 'ordered' } },
|
||
setup: { state: 'setup' },
|
||
active: { state: 'active' },
|
||
disabled: { state: 'disabled', over: { disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z' } },
|
||
}
|
||
const protokoll = aw.docs('customerViewLog').size
|
||
for (const [state, optionen] of Object.entries(faelle)) {
|
||
const k = legeAnleitung(optionen)
|
||
const { ergebnis: res, schreibend } = await zaehleSchreibzugriffe(() => anleitung(k))
|
||
assert.equal(res.status, 200, `${state}: ${JSON.stringify(res.body)}`)
|
||
assert.equal(res.body.state, state)
|
||
assert.deepEqual(Object.keys(res.body).sort(), ANLEITUNG_KEYS, state)
|
||
assert.ok(!('kurz' in res.body), `${state}: kurz bleibt im Ticket`)
|
||
assert.deepEqual(schreibend, [], `${state}: die Route schreibt nichts`)
|
||
}
|
||
assert.equal(aw.docs('customerViewLog').size, protokoll, 'kein Eintrag im Protokoll der Kundenansicht')
|
||
})
|
||
|
||
test('Anleitung: die Antwort enthaelt keinen feedToken der Einstellung', async () => {
|
||
const einstellung = d8Einstellung()
|
||
const tokens = einstellung.ressourcen.map((r) => r.feedToken)
|
||
assert.equal(tokens.length, 2)
|
||
assert.ok(tokens.every((tk) => typeof tk === 'string' && tk.length >= 20), 'Ausgangslage: echte Token')
|
||
const k = legeAnleitung({ einstellung })
|
||
const res = await anleitung(k)
|
||
assert.equal(res.status, 200)
|
||
const json = JSON.stringify(res.body)
|
||
for (const tk of tokens) assert.ok(!json.includes(tk), 'feedToken in der Antwort')
|
||
assert.ok(!json.includes('feedToken'))
|
||
assert.ok(!('kurz' in res.body))
|
||
assert.ok(!json.includes('Konfigurationsstand:'), 'die Kurzfassung des Tickets (mit ihrer Standzeile) gehoert nicht in die Antwort')
|
||
})
|
||
|
||
test('Anleitung mit Einstellung: 13 Abschnitte wie der Generator, stand.version 1, Volltext, Warnungen als Liste', async () => {
|
||
const k = legeAnleitung()
|
||
const res = await anleitung(k)
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.deepEqual(res.body.abschnitte.map((a) => a.id), ANLEITUNG_IDS)
|
||
for (const a of res.body.abschnitte) assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id)
|
||
assert.deepEqual(res.body.abschnitte.filter((a) => a.kopierbar).map((a) => a.id), ['snippet', 'css', 'datenschutz_sie', 'datenschutz_du'])
|
||
assert.deepEqual(res.body.stand, { version: 1, updatedAt: '2026-10-03T08:15:00.000Z' })
|
||
assert.equal(typeof res.body.text, 'string')
|
||
assert.ok(res.body.text.includes(res.body.abschnitte[0].titel))
|
||
assert.ok(Array.isArray(res.body.warnungen) && res.body.warnungen.every((w) => typeof w === 'string'))
|
||
const snippet = res.body.abschnitte.find((a) => a.id === 'snippet').text
|
||
assert.ok(snippet.includes('data-webklar-termine="abcdefghijklmnop"'), 'siteKey des Dokuments im Einbau-Code')
|
||
// eine hoehere Version der Einstellung kommt an
|
||
const v4 = legeAnleitung({ version: 4 })
|
||
assert.equal((await anleitung(v4)).body.stand.version, 4)
|
||
})
|
||
|
||
test('Anleitung ohne gespeicherte Einstellung: nur der Einbau-Code und die Warnung, stand.version 0; unlesbare Einstellung ebenso mit der Version des Dokuments', async (t) => {
|
||
t.mock.method(console, 'error', () => {})
|
||
const k = legeAnleitung({ einstellung: false })
|
||
const res = await anleitung(k)
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.deepEqual(res.body.abschnitte.map((a) => a.id), ['snippet'])
|
||
assert.ok(res.body.warnungen.includes('Keine Einstellungen gespeichert'), res.body.warnungen.join(' | '))
|
||
assert.deepEqual(res.body.stand, { version: 0, updatedAt: null })
|
||
assert.ok(res.body.abschnitte[0].text.includes('abcdefghijklmnop'))
|
||
// kaputtes JSON im Dokument: wie "keine Einstellung", die Version bleibt sichtbar
|
||
const kaputt = legeAnleitung({ einstellung: '{kaputt', version: 4 })
|
||
const r2 = await anleitung(kaputt)
|
||
assert.equal(r2.status, 200, JSON.stringify(r2.body))
|
||
assert.deepEqual(r2.body.abschnitte.map((a) => a.id), ['snippet'])
|
||
assert.ok(r2.body.warnungen.includes('Keine Einstellungen gespeichert'))
|
||
assert.equal(r2.body.stand.version, 4)
|
||
})
|
||
|
||
test('Anleitung, Drift: Ticket auf Version 1, Einstellung jetzt Version 3 => Warnung am Ende von warnungen, ticketVersion und ticketWoid', async () => {
|
||
// eine andere Warnung muss davor stehen (ohne Entwurfs-Banner sonst keine): fehlende Impressum-URL
|
||
const einstellung = d8Einstellung()
|
||
einstellung.betrieb.impressumUrl = ''
|
||
const k = legeAnleitung({ einstellung, version: 3, ticketDetails: TICKET_MIT_STAND(1) })
|
||
const res = await anleitung(k)
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.equal(warnungDrift(1, 3), 'Einstellungen seit der Bestellung geändert (Ticket Version 1, jetzt 3).')
|
||
assert.equal(res.body.warnungen.at(-1), warnungDrift(1, 3))
|
||
assert.ok(res.body.warnungen.length > 1 && /Impressum/.test(res.body.warnungen[0]), `sie steht hinter den uebrigen Warnungen: ${res.body.warnungen.join(' | ')}`)
|
||
assert.equal(res.body.warnungen.filter((w) => w.startsWith('Einstellungen seit der Bestellung')).length, 1)
|
||
assert.equal(res.body.ticketVersion, 1)
|
||
assert.equal(res.body.ticketWoid, ticketDoc(k.ticketId).woid)
|
||
assert.deepEqual(res.body.stand.version, 3)
|
||
})
|
||
|
||
test('Anleitung, Drift: gleiche oder neuere Version im Ticket, Ticket ohne Standzeile, Ticket geloescht, Funktion ohne ticketId => keine Warnung und kein Fehler', async () => {
|
||
const faelle = {
|
||
'gleiche Version': { opts: { version: 3, ticketDetails: TICKET_MIT_STAND(3) }, ticketVersion: 3, hatTicket: true },
|
||
'Ticket neuer als die Einstellung': { opts: { version: 3, ticketDetails: TICKET_MIT_STAND(5) }, ticketVersion: 5, hatTicket: true },
|
||
'Ticket ohne Standzeile': { opts: { version: 3 }, ticketVersion: null, hatTicket: true },
|
||
'Ticket geloescht': { opts: { version: 3, ticket: 'geloescht' }, ticketVersion: null, hatTicket: false },
|
||
'ohne ticketId': { opts: { version: 3, ticket: null }, ticketVersion: null, hatTicket: false },
|
||
}
|
||
for (const [name, { opts, ticketVersion, hatTicket }] of Object.entries(faelle)) {
|
||
const k = legeAnleitung(opts)
|
||
const res = await anleitung(k)
|
||
assert.equal(res.status, 200, `${name}: ${JSON.stringify(res.body)}`)
|
||
assert.ok(!res.body.warnungen.some((w) => /seit der Bestellung/.test(w)), `${name}: ${res.body.warnungen.join(' | ')}`)
|
||
assert.equal(res.body.ticketVersion, ticketVersion, name)
|
||
assert.equal(res.body.ticketWoid, hatTicket ? ticketDoc(k.ticketId).woid : null, name)
|
||
}
|
||
// Ticket aus der Zeit ohne Einstellung (Version 0), inzwischen gespeichert (Version 1)
|
||
const nullStand = legeAnleitung({ version: 1, ticketDetails: 'Funktion: Terminplaner\nKonfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)' })
|
||
const r0 = await anleitung(nullStand)
|
||
assert.equal(r0.body.ticketVersion, 0)
|
||
assert.equal(r0.body.warnungen.at(-1), warnungDrift(0, 1))
|
||
})
|
||
|
||
test('Anleitung, Drift: ein nicht lesbares Ticket (5xx) ist ein Fehler, keine stille Luecke; ein Aufruf spaeter geht', async (t) => {
|
||
t.mock.method(console, 'error', () => {})
|
||
const k = legeAnleitung({ version: 3, ticketDetails: TICKET_MIT_STAND(1) })
|
||
aw.failNext({ collection: 'workorders', method: 'GET', status: 500, message: 'Nachbau: Ticket kaputt' })
|
||
const aus = await anleitung(k)
|
||
assert.ok(aus.status >= 500 && aus.status < 600, `Status ${aus.status}`)
|
||
assert.equal(typeof aus.body.error, 'string')
|
||
assert.equal((await anleitung(k)).status, 200)
|
||
})
|
||
|
||
test('Anleitung, hindernis: nur in setup - ohne Abo kein_abo, mit Abo und Einstellung null, ohne Einstellung keine_einstellung; sonst null und das Abo bleibt ungelesen', async () => {
|
||
const setupOhneAbo = legeAnleitung({ abo: null })
|
||
assert.equal((await anleitung(setupOhneAbo)).body.hindernis, 'kein_abo')
|
||
assert.equal((await anleitung(legeAnleitung({ abo: 'hosting' }))).body.hindernis, 'kein_abo', 'Hosting ist kein Wartungs-Abo')
|
||
const beendet = legeAnleitung({ abo: 'wartung' })
|
||
aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: beendet.customerId, status: 'canceled', endedAt: '2026-10-01T10:00:00.000Z' }))
|
||
assert.equal((await anleitung(beendet)).body.hindernis, 'kein_abo', 'beendetes Abo')
|
||
const frei = await anleitung(legeAnleitung())
|
||
assert.equal(frei.body.hindernis, null)
|
||
assert.equal(frei.body.state, 'setup')
|
||
assert.equal((await anleitung(legeAnleitung({ einstellung: false }))).body.hindernis, 'keine_einstellung')
|
||
// eine vorgemerkte Kuendigung und past_due laufen noch
|
||
const gekuendigt = legeAnleitung()
|
||
aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: gekuendigt.customerId, cancelAtPeriodEnd: true }))
|
||
assert.equal((await anleitung(gekuendigt)).body.hindernis, null)
|
||
|
||
// active, disabled, ordered und off: null, auch ohne Abo, und das Abo wird gar nicht erst gelesen
|
||
const vorher = aw.listCalls('subscriptions').length
|
||
for (const [state, optionen] of Object.entries({
|
||
active: { state: 'active', abo: null },
|
||
disabled: { state: 'disabled', abo: null, over: { disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z' } },
|
||
ordered: { state: 'ordered', abo: null, ticket: null, over: { unlockedByPurchase: false, purchaseStatus: 'ordered' } },
|
||
off: { state: 'off', abo: null, ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } },
|
||
})) {
|
||
const res = await anleitung(legeAnleitung(optionen))
|
||
assert.equal(res.status, 200, state)
|
||
assert.equal(res.body.hindernis, null, state)
|
||
}
|
||
assert.equal(aw.listCalls('subscriptions').length, vorher, 'ausserhalb von setup keine Abo-Abfrage')
|
||
})
|
||
|
||
test('Anleitung, hindernis: Abo nicht lesbar => 200 mit abo_unbekannt (geloggt), nie ein stilles null', async (t) => {
|
||
const log = t.mock.method(console, 'error', () => {})
|
||
const k = legeAnleitung()
|
||
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Nachbau: Abo kaputt' })
|
||
const res = await anleitung(k)
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.equal(res.body.hindernis, 'abo_unbekannt')
|
||
assert.ok(log.mock.calls.length >= 1, 'der Fehler steht im Log')
|
||
assert.equal((await anleitung(k)).body.hindernis, null, 'der naechste Aufruf liest wieder')
|
||
})
|
||
|
||
test('Anleitung, Abrechnung: der Rechnungs-Link erscheint, sobald invoiceUrl im Dokument steht (auch bei altem Ticket); Preis aus dem Register', async () => {
|
||
const k = legeAnleitung({ ticketDetails: TICKET_MIT_STAND(1) })
|
||
const abrechnung = async () => (await anleitung(k)).body.abschnitte.find((a) => a.id === 'abrechnung').text
|
||
const davor = await abrechnung()
|
||
assert.ok(davor.includes('Rechnung folgt im Portal'), davor)
|
||
assert.ok(davor.includes(config.features.termine.priceLabel), davor)
|
||
const ticketVorher = { ...ticketDoc(k.ticketId) }
|
||
// backfillInvoiceLinks traegt den Link spaeter nach: die Anleitung zeigt ihn sofort, das Ticket bleibt, wie es war
|
||
await updateDocument(config.collections.portalFeatures, k.docId, { invoiceUrl: 'https://invoice.stripe.test/in_d8' })
|
||
const danach = await abrechnung()
|
||
assert.ok(danach.includes('https://invoice.stripe.test/in_d8'), danach)
|
||
assert.ok(!danach.includes('folgt im Portal'), danach)
|
||
assert.deepEqual(ticketDoc(k.ticketId), ticketVorher, 'das Ticket wird nicht angefasst')
|
||
})
|
||
|
||
// ---- Teamansicht GET /:customerId: hindernis je setup-Dokument
|
||
|
||
test('Teamansicht: hindernis je Terminplaner in setup, null in jedem anderen Zustand und bei News; die Funktionsdokumente werden einmal gelesen', async (t) => {
|
||
const kunde = legeAnleitung() // Abo und Einstellung vorhanden
|
||
// zweites Projekt desselben Kunden in setup, ohne Einstellung
|
||
aw.seed('websiteProjects', { $id: 'cdp_zwei', customerId: kunde.customerId, projectName: 'Zweites Projekt' })
|
||
aw.seed('portalFeatures', { $id: 'f_cdp_zwei_termine', customerId: kunde.customerId, projectId: 'cdp_zwei', featureKey: 'termine', siteKey: 'bcdefghijklmnopq', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' })
|
||
// drittes Projekt: aktiv, viertes: News in setup
|
||
aw.seed('websiteProjects', { $id: 'cdp_drei', customerId: kunde.customerId, projectName: 'Drittes Projekt' })
|
||
aw.seed('portalFeatures', { $id: 'f_cdp_drei_termine', customerId: kunde.customerId, projectId: 'cdp_drei', featureKey: 'termine', siteKey: 'cdefghijklmnopqr', state: 'active', enabled: true })
|
||
aw.seed('portalFeatures', { $id: 'f_cdp_drei_news', customerId: kunde.customerId, projectId: 'cdp_drei', featureKey: 'news', siteKey: 'defghijklmnopqrs', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' })
|
||
|
||
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 abosVorher = aw.listCalls('subscriptions').length
|
||
const res = await get(`/api/team/funktionen/${kunde.customerId}`, STAFF)
|
||
globalThis.fetch = echt
|
||
|
||
assert.equal(abfragen.length, 1, abfragen.join(' | '))
|
||
const features = (projectId) => res.projects.find((p) => p.projectId === projectId).features
|
||
assert.equal(features(kunde.projectId).termine.hindernis, null, 'setup mit Abo und Einstellung')
|
||
assert.equal(features('cdp_zwei').termine.hindernis, 'keine_einstellung', 'setup ohne Einstellung')
|
||
assert.equal(features('cdp_drei').termine.hindernis, null, 'active')
|
||
assert.equal(features('cdp_drei').news.hindernis, null, 'News in setup hat keine Freischalt-Hindernisse')
|
||
assert.equal(features(kunde.projectId).news.hindernis, null, 'kein Dokument')
|
||
for (const p of res.projects) for (const view of Object.values(p.features)) assert.ok('hindernis' in view, `${p.projectId}: Feld hindernis fehlt`)
|
||
assert.equal(aw.listCalls('subscriptions').length - abosVorher, 1, 'zwei setup-Dokumente, ein Abo-Lauf (aboMemo je Anfrage)')
|
||
|
||
// ohne Abo: kein_abo, und die Ansicht der uebrigen Felder bleibt wie bisher
|
||
const ohneAbo = legeAnleitung({ abo: null })
|
||
const r2 = await get(`/api/team/funktionen/${ohneAbo.customerId}`, STAFF)
|
||
const view = r2.projects.find((p) => p.projectId === ohneAbo.projectId).features.termine
|
||
assert.equal(view.hindernis, 'kein_abo')
|
||
assert.equal(view.state, 'setup')
|
||
assert.equal(view.ticketMissing, false)
|
||
})
|