Registereintrag config.features.shop (Bestelltexte mit Fassung config.shop.fassung, Rechtsunterlagen-Getter, Mailtexte, Ticket), STANDARD_HAKEN.shop (Schalter, Bestellpruefung ueber shop/anfrage.js, Unterlagen, Belehrung), legalDocs-Satz shop, shopTicketDetails sowie Rumpf von anfrageOffenePunkte. Tests angepasst und um Shop-Faelle erweitert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1692 lines
99 KiB
JavaScript
1692 lines
99 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'), featureConsents: schemaKeys('featureConsents') },
|
||
})
|
||
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', 'shop'])
|
||
// 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)
|
||
})
|
||
|
||
// ------------------------------------------------------------- Staff-Aktivierung mit Zustimmung in Textform (TP2 F13a) ---
|
||
const { zustimmungFuerSitzung, leseZustimmung } = await import('../server/services/featureConsents.js')
|
||
const { setLegalDirForTests } = await import('../server/services/legalDocs.js')
|
||
|
||
const berlinTagF13 = (ms = Date.now()) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(ms)
|
||
const tagMitVersatzF13 = (tag, tage) => new Date(Date.parse(`${tag}T12:00:00Z`) + tage * 86400000).toISOString().slice(0, 10)
|
||
const ttmmjjjjF13 = (tag) => tag.split('-').reverse().join('.')
|
||
const consentsF13 = () => [...aw.docs('featureConsents').values()]
|
||
const consentsDesF13 = (projectId) => consentsF13().filter((c) => c.projectId === projectId)
|
||
let f13Nr = 0
|
||
/** Kunde mit eigenem Projekt (ohne Funktionsdokument); E-Mail frei waehlbar, das Wartungs-Abo laeuft. */
|
||
function legeKundeF13({ email, abo = true } = {}) {
|
||
f13Nr += 1
|
||
const customerId = `c_f13_${f13Nr}`
|
||
const projectId = `p_f13_${f13Nr}`
|
||
aw.seed('customers', { $id: customerId, email: email ?? `f13-${f13Nr}@example.invalid`, name: `Kunde F13 ${f13Nr}`, status: 'active', portalAccessEnabled: true })
|
||
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Projekt F13 ${f13Nr}` })
|
||
if (abo) aw.seed('subscriptions', subscriptionDoc(`sub_f13_${f13Nr}`, { ...wartungDoc, customerId }))
|
||
return { customerId, projectId, email: aw.docs('customers').get(customerId).email }
|
||
}
|
||
const aktivierenF13 = (k, body = {}, opts = STAFF) => team(k.customerId, 'termine', { projectId: k.projectId, action: 'activate', ...body }, opts)
|
||
const feldF13 = (k, over = {}) => ({ textformDate: berlinTagF13(), textformChannel: 'chat', ...over })
|
||
const k16AnF13 = (email) => mails.sent.filter((m) => m.subject === 'Dein Terminplaner ist bereit' && m.to.some((t) => t.address === email))
|
||
|
||
test('textform_pflicht_modus_on: ohne Angaben 400, mit gültigen Angaben 200, Nachweis mit Datum, Weg, Fassung, Mitarbeiter und leerer Sitzung, K16 mit LB und AVV als PDF', async (t) => {
|
||
modusMerken(t)
|
||
termineMit('on', ['pilot@example.invalid'])
|
||
const k = legeKundeF13()
|
||
const v = config.termine.avvVersion
|
||
const mailsVorher = mails.sent.length
|
||
|
||
const ohne = await aktivierenF13(k)
|
||
assert.equal(ohne.status, 400)
|
||
assert.match(ohne.body.error, /Datum der Zustimmung/)
|
||
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null, 'nichts angelegt')
|
||
assert.equal(consentsDesF13(k.projectId).length, 0)
|
||
assert.equal(mails.sent.length, mailsVorher)
|
||
// nur Datum ohne Weg
|
||
const nurDatum = await aktivierenF13(k, { textformDate: berlinTagF13() })
|
||
assert.equal(nurDatum.status, 400)
|
||
assert.match(nurDatum.body.error, /Weg der Zustimmung/)
|
||
|
||
const tag = berlinTagF13()
|
||
const ok = await aktivierenF13(k, { textformDate: tag, textformChannel: 'email' })
|
||
assert.equal(ok.status, 200, JSON.stringify(ok.body))
|
||
assert.equal(featuresOf(ok, k.projectId).termine.state, 'active')
|
||
const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'termine')
|
||
assert.deepEqual([doc.state, doc.enabled, doc.unlockedByPurchase], ['active', true, false], 'ohne Bestellung freigeschaltet')
|
||
|
||
const [nachweis] = consentsDesF13(k.projectId)
|
||
assert.equal(consentsDesF13(k.projectId).length, 1)
|
||
assert.deepEqual([nachweis.customerId, nachweis.projectId, nachweis.featureKey], [k.customerId, k.projectId, 'termine'])
|
||
assert.equal(nachweis.stripeSessionId, '', 'keine Stripe-Sitzung')
|
||
assert.equal(nachweis.ip, '')
|
||
assert.equal(nachweis.userAgent, '')
|
||
assert.equal(nachweis.konto, 'Mitarbeiter: Andrej')
|
||
assert.ok(Math.abs(Date.parse(nachweis.acceptedAt) - Date.now()) < 10000, 'Zeitpunkt der Erfassung')
|
||
const gelesen = leseZustimmung(nachweis)
|
||
assert.deepEqual(gelesen.fassungen, { leistungsbeschreibung: v, avv: v })
|
||
assert.deepEqual(gelesen.texte, [{
|
||
id: 'textform',
|
||
text: `Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung ${v}) am ${ttmmjjjjF13(tag)} per E-Mail in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.`,
|
||
}])
|
||
assert.match(nachweis.textHash, /^[0-9a-f]{64}$/)
|
||
// der Weg Chat heißt „Chat im Kundenportal“
|
||
const chat = legeKundeF13()
|
||
assert.equal((await aktivierenF13(chat, { textformDate: tag, textformChannel: 'chat' })).status, 200)
|
||
assert.ok(leseZustimmung(consentsDesF13(chat.projectId)[0]).texte[0].text.includes(`am ${ttmmjjjjF13(tag)} per Chat im Kundenportal in Textform`))
|
||
|
||
// K16 an den Kunden mit genau den beiden PDFs (Leistungsbeschreibung, AVV), keine Widerrufsbelehrung
|
||
const k16 = k16AnF13(k.email)
|
||
assert.equal(k16.length, 1, mails.sent.map((m) => m.subject).join(' / '))
|
||
assert.deepEqual(k16[0].attachments.map((a) => a.filename), [`Leistungsbeschreibung-Terminplaner-${v}.pdf`, `AVV-Terminplaner-${v}.pdf`])
|
||
for (const a of k16[0].attachments) {
|
||
assert.equal(a.contentType, 'application/pdf')
|
||
assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF')
|
||
}
|
||
assert.equal(mails.sent.length, mailsVorher + 2, 'je eine K16 für den Kunden und den Chat-Kunden, sonst nichts')
|
||
})
|
||
|
||
const K16_NICHT_GESENDET = 'K16 mit Unterlagen NICHT gesendet – von Hand nachsenden'
|
||
|
||
test('textform A2: scheitert der Versand von K16 mit den Unterlagen, bleibt die Funktion freigeschaltet, adminNote und warnung:true sagen es; Erfolg und Kunde ohne Adresse', async (t) => {
|
||
modusMerken(t)
|
||
termineMit('on', [])
|
||
t.mock.method(console, 'error', () => {})
|
||
|
||
// Erfolg: keine Warnung, keine Notiz
|
||
const gut = legeKundeF13()
|
||
const ok = await aktivierenF13(gut, feldF13(gut))
|
||
assert.equal(ok.status, 200, JSON.stringify(ok.body))
|
||
assert.equal(ok.body.warnung, undefined, 'ohne Fehler kein Feld warnung')
|
||
assert.doesNotMatch((await sf.getFeatureDoc(gut.customerId, gut.projectId, 'termine')).adminNote, /NICHT gesendet/)
|
||
|
||
// Versandfehler: Freischaltung und Nachweis stehen, die Warnung kommt zurueck
|
||
const k = legeKundeF13()
|
||
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } })
|
||
t.after(() => setMailTransportForTests(mails))
|
||
const res = await aktivierenF13(k, feldF13(k))
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.equal(res.body.warnung, true)
|
||
const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'termine')
|
||
assert.equal(doc.state, 'active', 'die Freischaltung bleibt')
|
||
assert.equal(consentsDesF13(k.projectId).length, 1, 'der Nachweis bleibt')
|
||
assert.ok(doc.adminNote.includes(K16_NICHT_GESENDET), doc.adminNote)
|
||
assert.match(doc.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z System: K16 mit Unterlagen NICHT gesendet – von Hand nachsenden \| .*Andrej: activate/)
|
||
assert.equal(featuresOf(res, k.projectId).termine.adminNote, doc.adminNote, 'das Ticketsystem liest die Notiz mit der Antwort')
|
||
|
||
// Kunde ohne E-Mail-Adresse: K16 kann nicht raus, auch das ist eine Warnung
|
||
setMailTransportForTests(mails)
|
||
const leer = legeKundeF13({ email: '' })
|
||
aw.docs('customers').get(leer.customerId).email = ''
|
||
const ohneAdresse = await aktivierenF13(leer, feldF13(leer))
|
||
assert.equal(ohneAdresse.status, 200, JSON.stringify(ohneAdresse.body))
|
||
assert.equal(ohneAdresse.body.warnung, true)
|
||
assert.ok((await sf.getFeatureDoc(leer.customerId, leer.projectId, 'termine')).adminNote.includes(K16_NICHT_GESENDET))
|
||
})
|
||
|
||
test('textform_entfaellt: Modus off und pilot, Modus on mit Pilotadresse, News und Abschalten brauchen keine Angaben und schreiben keinen Nachweis', async (t) => {
|
||
modusMerken(t)
|
||
const mailsVorher = mails.sent.length
|
||
for (const [mode, pilot] of [['off', []], ['pilot', ['*']], ['pilot', []]]) {
|
||
termineMit(mode, pilot)
|
||
const k = legeKundeF13()
|
||
const res = await aktivierenF13(k)
|
||
assert.equal(res.status, 200, `${mode}: ${JSON.stringify(res.body)}`)
|
||
assert.equal(featuresOf(res, k.projectId).termine.state, 'active')
|
||
assert.equal(consentsDesF13(k.projectId).length, 0, mode)
|
||
}
|
||
// Modus on, aber die Adresse steht in der Pilotliste
|
||
const pilotKunde = legeKundeF13({ email: 'Pilot-F13@example.invalid' })
|
||
termineMit('on', ['pilot-f13@example.invalid'])
|
||
assert.equal((await aktivierenF13(pilotKunde)).status, 200)
|
||
assert.equal(consentsDesF13(pilotKunde.projectId).length, 0)
|
||
// Modus on, andere Adresse: Pflicht; News und Abschalten bleiben frei
|
||
const k = legeKundeF13()
|
||
assert.equal((await aktivierenF13(k)).status, 400)
|
||
const news = await team(k.customerId, 'news', { projectId: k.projectId, action: 'activate' })
|
||
assert.equal(news.status, 200)
|
||
assert.equal(featuresOf(news, k.projectId).news.state, 'active')
|
||
assert.equal((await team(k.customerId, 'news', { projectId: k.projectId, action: 'disable' })).status, 200)
|
||
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null)
|
||
const aktiv = legeKundeF13()
|
||
termineMit('off')
|
||
assert.equal((await aktivierenF13(aktiv)).status, 200)
|
||
termineMit('on', [])
|
||
const aus = await team(aktiv.customerId, 'termine', { projectId: aktiv.projectId, action: 'disable' })
|
||
assert.equal(aus.status, 200, 'Abschalten braucht keine Angaben')
|
||
assert.equal(featuresOf(aus, aktiv.projectId).termine.state, 'disabled')
|
||
assert.equal(consentsF13().filter((c) => [k.projectId, aktiv.projectId, pilotKunde.projectId].includes(c.projectId)).length, 0)
|
||
assert.equal(k16AnF13('k@example.invalid').length, 0)
|
||
assert.equal(mails.sent.slice(mailsVorher).filter((m) => m.subject === 'Dein Terminplaner ist bereit').length, 0, 'ohne Textform keine K16 (wie bisher)')
|
||
})
|
||
|
||
test('textform_entfaellt: ein bezahlter oder schon aktiver Terminplaner braucht keine neue Zustimmung in Textform (der Nachweis liegt schon vor)', async (t) => {
|
||
modusMerken(t)
|
||
termineMit('on', [])
|
||
// bezahlt (Zustimmung beim Checkout), Zustand setup, Wartungs-Abo läuft
|
||
const bezahlt = legeKundeF13()
|
||
aw.seed('portalFeatures', { $id: `f_${bezahlt.projectId}_termine`, customerId: bezahlt.customerId, projectId: bezahlt.projectId, featureKey: 'termine', siteKey: 'qrstuvwxyzabcdef', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' })
|
||
const res = await aktivierenF13(bezahlt)
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
assert.equal(featuresOf(res, bezahlt.projectId).termine.state, 'active')
|
||
assert.equal(consentsDesF13(bezahlt.projectId).length, 0)
|
||
// schon aktiv (Doppelklick im WOMS): kein zweiter Nachweis, keine zweite Mail
|
||
const k = legeKundeF13()
|
||
termineMit('off')
|
||
assert.equal((await aktivierenF13(k)).status, 200)
|
||
termineMit('on', [])
|
||
const vorher = mails.sent.length
|
||
assert.equal((await aktivierenF13(k)).status, 200)
|
||
assert.equal(consentsDesF13(k.projectId).length, 0)
|
||
assert.equal(mails.sent.length, vorher)
|
||
})
|
||
|
||
test('textform_datum: Datum vor der Fassung, in der Zukunft, ungültig oder fehlend und ungültiger Weg ergeben 400 ohne Nachweis, ohne Dokument, ohne Mail', async (t) => {
|
||
modusMerken(t)
|
||
termineMit('on', [])
|
||
const k = legeKundeF13()
|
||
const v = config.termine.avvVersion
|
||
const heute = berlinTagF13()
|
||
const mailsVorher = mails.sent.length
|
||
const faelle = [
|
||
[{ textformDate: tagMitVersatzF13(v, -1), textformChannel: 'chat' }, /liegt vor der Fassung/],
|
||
[{ textformDate: tagMitVersatzF13(heute, 1), textformChannel: 'chat' }, /in der Zukunft/],
|
||
[{ textformDate: '2026-02-30', textformChannel: 'chat' }, /fehlt oder ist ungültig/],
|
||
[{ textformDate: '05.10.2026', textformChannel: 'chat' }, /fehlt oder ist ungültig/],
|
||
[{ textformDate: 20261005, textformChannel: 'chat' }, /fehlt oder ist ungültig/],
|
||
[{ textformDate: heute }, /Weg der Zustimmung/],
|
||
[{ textformDate: heute, textformChannel: 'telefon' }, /Weg der Zustimmung/],
|
||
[{ textformDate: heute, textformChannel: '' }, /Weg der Zustimmung/],
|
||
[{ textformDate: heute, textformChannel: ['chat'] }, /Weg der Zustimmung/],
|
||
]
|
||
for (const [body, meldung] of faelle) {
|
||
const res = await aktivierenF13(k, body)
|
||
assert.equal(res.status, 400, JSON.stringify(body))
|
||
assert.match(res.body.error, meldung, JSON.stringify(body))
|
||
}
|
||
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null, 'nichts angelegt')
|
||
assert.equal(consentsDesF13(k.projectId).length, 0)
|
||
assert.equal(mails.sent.length, mailsVorher)
|
||
// genau am Tag der Fassung und heute ist erlaubt
|
||
assert.equal((await aktivierenF13(k, { textformDate: v, textformChannel: 'email' })).status, 200)
|
||
assert.equal(consentsDesF13(k.projectId).length, 1)
|
||
})
|
||
|
||
test('textform_pflicht_modus_on: fremdes Projekt, Kundensitzung und nicht lesbare Rechtsunterlagen ändern nichts (die Berechtigung des Team-Wegs bleibt)', async (t) => {
|
||
modusMerken(t)
|
||
termineMit('on', [])
|
||
const k = legeKundeF13()
|
||
const fremd = legeKundeF13()
|
||
// Projekt eines anderen Kunden: 403 vor jeder Prüfung der Zustimmung
|
||
const res = await team(k.customerId, 'termine', { projectId: fremd.projectId, action: 'activate', ...feldF13(k) })
|
||
assert.equal(res.status, 403)
|
||
const alsKunde = await aktivierenF13(k, feldF13(k), { cookie: customerCookie(k.customerId) })
|
||
assert.ok(alsKunde.status === 401 || alsKunde.status === 403)
|
||
assert.equal(consentsF13().filter((c) => [k.projectId, fremd.projectId].includes(c.projectId)).length, 0)
|
||
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null)
|
||
// PDFs nicht lesbar: nichts wird freigeschaltet, nichts gespeichert, keine Mail
|
||
const leer = await (await import('node:fs/promises')).mkdtemp((await import('node:path')).join((await import('node:os')).tmpdir(), 'f13-leer-'))
|
||
setLegalDirForTests(leer)
|
||
t.after(() => setLegalDirForTests(null))
|
||
const mailsVorher = mails.sent.length
|
||
const fehler = await aktivierenF13(k, feldF13(k))
|
||
assert.ok(fehler.status >= 500, `Status ${fehler.status}`)
|
||
assert.match(fehler.body.error, /Rechtsunterlagen/)
|
||
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null)
|
||
assert.equal(consentsDesF13(k.projectId).length, 0)
|
||
assert.equal(mails.sent.length, mailsVorher)
|
||
setLegalDirForTests(null)
|
||
assert.equal((await aktivierenF13(k, feldF13(k))).status, 200)
|
||
})
|
||
|
||
test('textform_pflicht_modus_on: eine leere oder keine PDF-Datei als Unterlage ergibt 503 vor jeder Änderung (R121), danach mit gültigen Dateien 200', async (t) => {
|
||
modusMerken(t)
|
||
termineMit('on', [])
|
||
const fs = await import('node:fs')
|
||
const os = await import('node:os')
|
||
const path = await import('node:path')
|
||
const { legalPdfFiles } = await import('../server/services/legalDocs.js')
|
||
const echt = legalPdfFiles('termine', config.termine.avvVersion)
|
||
const kopien = fs.mkdtempSync(path.join(os.tmpdir(), 'f13-pdf-'))
|
||
fs.mkdirSync(path.join(kopien, 'rechtliches'))
|
||
const kopiere = () => {
|
||
for (const f of Object.values(echt)) fs.copyFileSync(f.path, path.join(kopien, 'rechtliches', path.basename(f.path)))
|
||
}
|
||
t.after(() => {
|
||
setLegalDirForTests(null)
|
||
fs.rmSync(kopien, { recursive: true, force: true })
|
||
})
|
||
setLegalDirForTests(kopien)
|
||
const k = legeKundeF13()
|
||
const mailsVorher = mails.sent.length
|
||
for (const [doc, inhalt] of [['lb', ''], ['avv', '<html>kein PDF</html>'], ['lb', '%PDF']]) {
|
||
kopiere()
|
||
fs.writeFileSync(path.join(kopien, 'rechtliches', path.basename(echt[doc].path)), inhalt)
|
||
const res = await aktivierenF13(k, feldF13(k))
|
||
assert.equal(res.status, 503, `${doc}: ${JSON.stringify(inhalt)}`)
|
||
assert.match(res.body.error, /Rechtsunterlagen/)
|
||
assert.equal(await sf.getFeatureDoc(k.customerId, k.projectId, 'termine'), null, 'nichts angelegt')
|
||
assert.equal(consentsDesF13(k.projectId).length, 0, 'kein Nachweis')
|
||
assert.equal(mails.sent.length, mailsVorher)
|
||
}
|
||
kopiere()
|
||
const ok = await aktivierenF13(k, feldF13(k))
|
||
assert.equal(ok.status, 200, JSON.stringify(ok.body))
|
||
assert.equal(consentsDesF13(k.projectId).length, 1)
|
||
assert.equal(k16AnF13(k.email).length, 1)
|
||
})
|
||
|
||
test('get_admin_termine_feld: GET /api/team/funktionen/:kunde liefert termine { textformNoetig, avvVersion, links } je Modus', async (t) => {
|
||
modusMerken(t)
|
||
const k = legeKundeF13({ email: 'get-f13@example.invalid' })
|
||
const v = config.termine.avvVersion
|
||
const feldVon = async () => (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).termine
|
||
termineMit('off')
|
||
assert.equal((await feldVon()).textformNoetig, false)
|
||
termineMit('pilot', ['*'])
|
||
assert.equal((await feldVon()).textformNoetig, false)
|
||
termineMit('on', ['get-f13@example.invalid'])
|
||
assert.equal((await feldVon()).textformNoetig, false, 'Pilotadresse')
|
||
termineMit('on', [])
|
||
const f = await feldVon()
|
||
assert.deepEqual(Object.keys(f).sort(), ['avvVersion', 'links', 'textformNoetig'])
|
||
assert.equal(f.textformNoetig, true)
|
||
assert.equal(f.avvVersion, v)
|
||
assert.deepEqual(Object.keys(f.links).sort(), ['avv', 'lb'])
|
||
// bisherige Antwort bleibt: projects
|
||
const ganz = await get(`/api/team/funktionen/${k.customerId}`, STAFF)
|
||
assert.ok(Array.isArray(ganz.projects))
|
||
// unbekannter Kunde: Modus on => Textform nötig (ohne Adresse), keine Ausnahme
|
||
termineMit('on', ['*'])
|
||
assert.equal((await get('/api/team/funktionen/c_gibtsnicht', STAFF)).termine.textformNoetig, true)
|
||
})
|
||
|
||
test('chat_vorlage_versioniert: die Adressen für die Chat-Vorlage verweisen nur auf Seiten mit Fassung (-<Fassung>.html), absolut, folgen der aktuellen Fassung', async (t) => {
|
||
modusMerken(t)
|
||
termineMit('on', [])
|
||
const k = legeKundeF13()
|
||
const v = config.termine.avvVersion
|
||
const { links } = (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).termine
|
||
assert.equal(links.lb, `${config.publicBaseUrl}/rechtliches/leistungsbeschreibung-terminplaner-${v}.html`)
|
||
assert.equal(links.avv, `${config.publicBaseUrl}/rechtliches/avv-terminplaner-${v}.html`)
|
||
for (const adresse of Object.values(links)) {
|
||
assert.ok(adresse.endsWith(`-${v}.html`), adresse)
|
||
assert.match(adresse, /^https:\/\//)
|
||
}
|
||
// eine neue Fassung ändert die Adressen mit
|
||
const vorher = config.termine.avvVersion
|
||
t.after(() => { config.termine.avvVersion = vorher })
|
||
config.termine.avvVersion = '2099-01-02'
|
||
const neu = (await get(`/api/team/funktionen/${k.customerId}`, STAFF)).termine
|
||
assert.equal(neu.avvVersion, '2099-01-02')
|
||
for (const adresse of Object.values(neu.links)) assert.ok(adresse.endsWith('-2099-01-02.html'), adresse)
|
||
})
|
||
|
||
test('zustimmungFuerSitzung(""): der Nachweis der Staff-Aktivierung (leere Sitzung) wird über keine Sitzung gefunden', async (t) => {
|
||
modusMerken(t)
|
||
termineMit('on', [])
|
||
const k = legeKundeF13()
|
||
assert.equal((await aktivierenF13(k, feldF13(k))).status, 200)
|
||
assert.equal(consentsDesF13(k.projectId).length, 1)
|
||
assert.equal(consentsDesF13(k.projectId)[0].stripeSessionId, '')
|
||
for (const leer of ['', ' ', undefined, null]) assert.equal(await zustimmungFuerSitzung(leer), null, JSON.stringify(leer))
|
||
})
|