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