Funktionen: Freischalten nach Abnahme und Ticket nachlegen (Team)

Neue Team-Aktionen abnahme (Freischalten ohne Ticket-Schliessung, K16
einmal, unter feature-activate:<id> mit Haken freischaltHindernis) und
ticket (Ticket nachlegen: Zahlung bei Stripe pruefen und Webhook-Weg,
geloeschtes Ticket ersetzen, vorhandenes unberuehrt). Ticketanlage aus
markPaid in legeTicketAn herausgeloest.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 01:08:03 +00:00
1 parent 8fe14c2fe6
commit f830698dc3
3 files changed
+699 -12

No files matched your search

+494 -1
View File
@@ -21,9 +21,17 @@ 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 })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
// 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' })
@@ -546,3 +554,488 @@ test('adminActivate (Dienst): bezahlter Terminplaner ohne Abo => Fehler 409, bei
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')
})