fragebogen.js ersetzt den Stub aus Block B: bestellpruefung (Kriterien (a) bis (h), je Punkt ein Eintrag, nach Schrittfolge sortiert, Abo-Punkt nur bei mitAbo !== false), SCHRITT_TITEL, gesundheitsWarnungen (Stichworte nur am Wortanfang, hoechstens 10 Meldungen) und bestellungAnsicht fuer die Bestellseite. einstellungen.js exportiert sichtbareLeistungen und MELDUNG_PFLICHT. Testhelfer bestellbarerStand und entwurfDoc; der Stub-Test entfaellt, die zwei B-Tests mit dem Stub laufen gegen das echte Gate. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
680 lines
33 KiB
JavaScript
680 lines
33 KiB
JavaScript
import { test, after, beforeEach } 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'
|
||
|
||
// Zahlungseingang in jedem Zustand (Task B10b, Review Focus #1): nie Geld ohne Spur, nie doppelt.
|
||
// Immer gilt nach einem Fall: Tickets + Team-Mails >= 1 und Tickets <= 1 (spur()). Bestellt wird ueber die echte Route
|
||
// POST /api/funktionen/termine/checkout, bezahlt ueber den signierten Webhook; websiteProjects, workorders und customers
|
||
// legt das Setup-Skript nicht an: ohne Schema im Nachbau.
|
||
const aw = await startAppwriteMock({
|
||
schema: {
|
||
portalFeatures: schemaKeys('portalFeatures'),
|
||
subscriptions: schemaKeys('subscriptions'),
|
||
featureConsents: schemaKeys('featureConsents'),
|
||
termineConfig: schemaKeys('termineConfig'),
|
||
},
|
||
})
|
||
const st = await startFakeStripe()
|
||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||
const { config } = await import('../server/config.js')
|
||
const { startTestApp, post, postWebhook } = await import('./support/testApp.js')
|
||
const { customerCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
|
||
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
|
||
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||
const sf = await import('../server/services/siteFeatures.js')
|
||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||
const { mailSink } = await import('./support/mailSink.js')
|
||
|
||
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/abo': aboRoutes })
|
||
|
||
// Mail-Senke, deren Versand sich gezielt scheitern lassen laesst: versandFehler(mail) => true wirft wie ein SMTP-Fehler
|
||
const sink = mailSink()
|
||
const mails = {
|
||
sent: sink.sent,
|
||
versandFehler: null,
|
||
async sendMail(mail) {
|
||
if (mails.versandFehler?.(mail)) throw new Error('SMTP nicht erreichbar')
|
||
return sink.sendMail(mail)
|
||
},
|
||
}
|
||
setMailTransportForTests(mails)
|
||
|
||
const T = config.features.termine
|
||
const STANDARD_TERMINE_MODUS = { mode: config.termine.mode, pilotEmails: config.termine.pilotEmails }
|
||
after(() => {
|
||
setMailTransportForTests(null)
|
||
config.termine.mode = STANDARD_TERMINE_MODUS.mode
|
||
config.termine.pilotEmails = STANDARD_TERMINE_MODUS.pilotEmails
|
||
delete T.bestellpruefung
|
||
return Promise.all([app.close(), aw.close(), st.close()])
|
||
})
|
||
|
||
// seedFixtures gibt c1 nur ein Hosting-Abo (sub_h): Termine-Faelle ueberschreiben es mit einem Wartungs-Abo
|
||
const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch }))
|
||
|
||
beforeEach(async () => {
|
||
for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'workorders']) {
|
||
for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
|
||
}
|
||
seedFixtures(aw)
|
||
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Salon' })
|
||
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Studio' })
|
||
wartung()
|
||
st.checkoutSessions.clear()
|
||
st.invoices.clear()
|
||
mails.sent.length = 0
|
||
mails.versandFehler = null
|
||
config.termine.mode = 'pilot'
|
||
config.termine.pilotEmails = ['*']
|
||
// Die Bestellpruefung (Fragebogen-Gate, termine/fragebogen.js) ist hier nicht Gegenstand: jedes Projekt ist bestellbar
|
||
T.bestellpruefung = async () => []
|
||
})
|
||
|
||
const c1 = customerCookie('c1')
|
||
const SUBJECT = {
|
||
K15: 'Deine Bestellung: Terminplaner',
|
||
T16: 'Neue Funktion bestellt: Terminplaner',
|
||
T16_NEWS: 'Neue Funktion bestellt: News-Pinnwand',
|
||
T19: '[Abo] Erstattung fällig: Terminplaner-Einrichtung',
|
||
}
|
||
const KEIN_ABO = /Kein laufendes Wartungs-Abo/
|
||
const BEREITS_DA = /Bezahlt, aber schon vorhanden/
|
||
|
||
const tickets = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).startsWith('Terminplaner einbauen'))
|
||
const mailsMit = (subject) => mails.sent.filter((m) => m.subject === subject)
|
||
const teamMails = () => mails.sent.filter((m) => m.to.some((t) => config.chatNotify.emails.includes(t.address)))
|
||
const doc = (projectId = 'p1', key = 'termine') => sf.getFeatureDoc('c1', projectId, key)
|
||
const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } })
|
||
const webhook = (type, session) => postWebhook(event(type, session))
|
||
const STUMM = (t) => t.mock.method(console, 'error', () => {})
|
||
const geloggt = (stumm) => stumm.mock.calls.map((c) => c.arguments.join(' ')).join('\n')
|
||
|
||
/** Hilfspruefung nach jedem Fall: Geld nie ohne Spur (Ticket oder Team-Mail) und nie mehr als ein Ticket. */
|
||
function spur() {
|
||
assert.ok(tickets().length + teamMails().length >= 1, 'Geld ohne Spur: weder Ticket noch Team-Mail')
|
||
assert.ok(tickets().length <= 1, `hoechstens ein Ticket, gefunden ${tickets().length}`)
|
||
}
|
||
|
||
/** Echter Bestellweg: Checkout-Route legt die Stripe-Sitzung an und das Dokument auf 'ordered'. */
|
||
async function bestelle(projectId = 'p1') {
|
||
const haken = T.consent.items.filter((item) => item.haken).map((item) => item.id)
|
||
const res = await post('/api/funktionen/termine/checkout', { projectId, zustimmung: { version: T.consent.version, haken } }, { cookie: c1 })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const sessionId = [...st.checkoutSessions.values()].at(-1).id
|
||
assert.equal((await doc(projectId)).state, 'ordered')
|
||
assert.equal((await doc(projectId)).stripeSessionId, sessionId)
|
||
return sessionId
|
||
}
|
||
|
||
/** Bezahlte Sitzung im Ereignis; die Rechnung liegt im Stripe-Nachbau. */
|
||
function bezahlt(sessionId, { projectId = 'p1', featureKey = 'termine', invoice = `in_${sessionId}`, ...extra } = {}) {
|
||
st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` })
|
||
return {
|
||
id: sessionId,
|
||
object: 'checkout.session',
|
||
mode: 'payment',
|
||
payment_status: 'paid',
|
||
invoice,
|
||
metadata: { customerId: 'c1', projectId, featureKey },
|
||
...extra,
|
||
}
|
||
}
|
||
const rechnungUrl = (sessionId) => `https://invoice.stripe.test/in_${sessionId}`
|
||
|
||
/** Die Systemzeile der verspaeteten Zahlung steht vorn in adminNote. */
|
||
const SYSTEMZEILE = (sessionId) => new RegExp(`^\\d{4}-\\d\\d-\\d\\dT[\\d:.]+Z System: Zahlung eingegangen \\(Sitzung ${sessionId}\\), nichts eingerichtet`)
|
||
|
||
// ------------------------------------------------------------------------------------ (1) ordered + Abo laeuft ---
|
||
test('(1) ordered + Abo laeuft: setup, ein Ticket, K15 und T16 je einmal, T16 ohne kein_abo', async () => {
|
||
const sessionId = await bestelle()
|
||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||
assert.equal(res.status, 200)
|
||
|
||
const d = await doc()
|
||
assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['setup', 'paid', true, ''])
|
||
assert.equal(d.invoiceUrl, rechnungUrl(sessionId))
|
||
assert.equal(tickets().length, 1)
|
||
assert.match(tickets()[0].topic, /^Terminplaner einbauen – Salon/)
|
||
assert.equal(aw.docs('workorders').get(d.ticketId).topic, tickets()[0].topic)
|
||
|
||
const [k15] = mailsMit(SUBJECT.K15)
|
||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||
assert.equal(k15.to[0].address, 'k@example.invalid')
|
||
const t16 = mailsMit(SUBJECT.T16)
|
||
assert.equal(t16.length, 1)
|
||
assert.doesNotMatch(t16[0].text, KEIN_ABO)
|
||
assert.doesNotMatch(t16[0].text, BEREITS_DA)
|
||
assert.match(t16[0].text, new RegExp(String(aw.docs('workorders').get(d.ticketId).woid)))
|
||
spur()
|
||
|
||
// Stripe-Wiederholung: nichts doppelt
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||
assert.equal(tickets().length, 1)
|
||
assert.equal(mailsMit(SUBJECT.K15).length + mailsMit(SUBJECT.T16).length, 2)
|
||
})
|
||
|
||
test('(1) Abo vorgemerkt gekuendigt, laeuft aber noch: kein kein_abo-Block', async () => {
|
||
const sessionId = await bestelle()
|
||
wartung({ cancelAtPeriodEnd: true })
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||
assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, KEIN_ABO)
|
||
spur()
|
||
})
|
||
|
||
// ------------------------------------------------------------------------------------ (2) ordered + Abo beendet ---
|
||
test('(2) ordered + Abo beendet (canceled): setup, Ticket, K15, T16 mit kein_abo', async () => {
|
||
const sessionId = await bestelle()
|
||
wartung({ status: 'canceled' })
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||
|
||
assert.equal((await doc()).state, 'setup')
|
||
assert.equal(tickets().length, 1)
|
||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||
const t16 = mailsMit(SUBJECT.T16)
|
||
assert.equal(t16.length, 1)
|
||
assert.match(t16[0].text, KEIN_ABO)
|
||
assert.match(t16[0].text, new RegExp(String(aw.docs('workorders').get((await doc()).ticketId).woid)))
|
||
spur()
|
||
// Wiederholung: stumm
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||
})
|
||
|
||
test('(2) News braucht kein Abo: nie kein_abo', async () => {
|
||
await sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_news_a', consent: {} })
|
||
wartung({ status: 'canceled' })
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt('cs_news_a', { featureKey: 'news' }))).status, 200)
|
||
assert.equal(mailsMit(SUBJECT.T16_NEWS).length, 1)
|
||
assert.doesNotMatch(mailsMit(SUBJECT.T16_NEWS)[0].text, KEIN_ABO)
|
||
})
|
||
|
||
// ------------------------------------------------------------------------------------ (3) laeuft scheitert ---
|
||
test('(3) Abo nicht lesbar: Webhook 200, Ticket, K15, T16 ohne kein_abo, Fehler im Log', async (t) => {
|
||
const sessionId = await bestelle()
|
||
const stumm = STUMM(t)
|
||
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })
|
||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||
assert.equal(res.status, 200)
|
||
|
||
assert.equal((await doc()).state, 'setup')
|
||
assert.equal(tickets().length, 1)
|
||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||
assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, KEIN_ABO)
|
||
assert.match(geloggt(stumm), /Wartungs-Abo/)
|
||
spur()
|
||
})
|
||
|
||
// ------------------------------------------------------------------------------------ (4) Staff hat aktiviert ---
|
||
test('(4) Staff aktiviert die ordered-Bestellung, dann Zahlung: active bleibt, kein Ticket, keine K15, T16 bereits_aktiv genau einmal', async (t) => {
|
||
const sessionId = await bestelle()
|
||
await sf.adminActivate('c1', 'p1', 'termine', { by: 'Andrej' })
|
||
const stumm = STUMM(t)
|
||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||
assert.equal(res.status, 200)
|
||
|
||
const d = await doc()
|
||
assert.deepEqual([d.state, d.enabled, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['active', true, 'paid', true, 'gemeldet'])
|
||
assert.equal(d.invoiceUrl, rechnungUrl(sessionId))
|
||
assert.equal(d.stripeInvoiceId, `in_${sessionId}`)
|
||
assert.match(d.adminNote, SYSTEMZEILE(sessionId))
|
||
assert.match(d.adminNote, /Freigeschaltet von Andrej/, 'die aeltere Notiz bleibt dahinter')
|
||
assert.equal(tickets().length, 0)
|
||
assert.equal(mailsMit(SUBJECT.K15).length, 0)
|
||
const t16 = mailsMit(SUBJECT.T16)
|
||
assert.equal(t16.length, 1)
|
||
assert.match(t16[0].text, BEREITS_DA)
|
||
assert.match(t16[0].text, /schon freigeschaltet/)
|
||
assert.match(t16[0].text, new RegExp(sessionId))
|
||
assert.doesNotMatch(t16[0].text, KEIN_ABO)
|
||
assert.match(geloggt(stumm), new RegExp(sessionId), 'console.error nennt die Sitzungs-ID')
|
||
assert.equal(mails.sent.length, 1, 'sonst keine Mail')
|
||
spur()
|
||
|
||
// zweite Zustellung desselben Ereignisses und async_payment_succeeded nach completed: stumm
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
|
||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||
assert.equal(mails.sent.length, 1)
|
||
assert.equal(tickets().length, 0)
|
||
assert.equal((await doc()).adminNote, d.adminNote, 'nichts doppelt verbucht')
|
||
})
|
||
|
||
test('(4) zwei parallele Zustellungen: genau ein T16', async (t) => {
|
||
const sessionId = await bestelle()
|
||
await sf.adminActivate('c1', 'p1', 'termine', { by: 'Andrej' })
|
||
STUMM(t)
|
||
const ergebnis = await Promise.all([
|
||
webhook('checkout.session.completed', bezahlt(sessionId)),
|
||
webhook('checkout.session.completed', bezahlt(sessionId)),
|
||
])
|
||
assert.deepEqual(ergebnis.map((r) => r.status), [200, 200])
|
||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||
assert.equal(mails.sent.length, 1)
|
||
assert.equal((await doc()).zahlungMeldung, 'gemeldet')
|
||
assert.equal(tickets().length, 0)
|
||
spur()
|
||
})
|
||
|
||
test('(4) normal bezahlte Bestellung (inzwischen aktiv): ein Folgeereignis derselben Sitzung ist kein Fall und bleibt stumm', async () => {
|
||
const sessionId = await bestelle()
|
||
await webhook('checkout.session.completed', bezahlt(sessionId))
|
||
const vorher = mails.sent.length
|
||
// Folgeereignis zu einer normal bezahlten Bestellung (Zustand inzwischen active): kein Fall
|
||
await sf.activate((await doc()).$id, { by: 'Andrej' })
|
||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
|
||
assert.equal(mails.sent.length, vorher)
|
||
assert.equal(tickets().length, 1)
|
||
})
|
||
|
||
// ------------------------------------------------------------------------------------ (5) Team hat abgeschaltet ---
|
||
test('(5) Team schaltet die ordered-Bestellung ab, dann Zahlung: disabled bleibt, T16 abgeschaltet einmal, Wiederholung stumm', async (t) => {
|
||
const sessionId = await bestelle()
|
||
const abgeschaltet = await sf.disable((await doc()).$id, { by: 'Andrej' })
|
||
STUMM(t)
|
||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||
assert.equal(res.status, 200)
|
||
|
||
const d = await doc()
|
||
assert.deepEqual([d.state, d.enabled, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['disabled', false, 'paid', true, 'gemeldet'])
|
||
assert.equal(d.disabledAt, abgeschaltet.disabledAt, 'disabledAt (Beginn der Loeschfrist) unveraendert')
|
||
assert.equal(d.disabledReason, 'team')
|
||
assert.match(d.adminNote, SYSTEMZEILE(sessionId))
|
||
assert.equal(tickets().length, 0)
|
||
assert.equal(mailsMit(SUBJECT.K15).length, 0)
|
||
assert.equal(mailsMit(SUBJECT.T19).length, 0, 'kein T19: der Grund ist team, nicht das Abo')
|
||
const t16 = mailsMit(SUBJECT.T16)
|
||
assert.equal(t16.length, 1)
|
||
assert.match(t16[0].text, BEREITS_DA)
|
||
assert.match(t16[0].text, /schon abgeschaltet/)
|
||
spur()
|
||
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
|
||
assert.equal(mails.sent.length, 1)
|
||
})
|
||
|
||
// ------------------------------------------------------------------------------------ (6) Abo-Ende vor der Einrichtung ---
|
||
test('(6) disabled abo_ende_vor_einrichtung + Zahlung: T19 einmal mit zahlung_nach_ende, kein T16, kein Ticket, keine K15, Wiederholung stumm', async (t) => {
|
||
const sessionId = await bestelle()
|
||
await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung', adminNote: 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung' })
|
||
STUMM(t)
|
||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||
assert.equal(res.status, 200)
|
||
|
||
const d = await doc()
|
||
assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['disabled', 'paid', true, 'gemeldet'])
|
||
assert.equal(tickets().length, 0)
|
||
assert.equal(mailsMit(SUBJECT.K15).length, 0)
|
||
assert.equal(mailsMit(SUBJECT.T16).length, 0)
|
||
const t19 = mailsMit(SUBJECT.T19)
|
||
assert.equal(t19.length, 1)
|
||
assert.equal(mails.sent.length, 1)
|
||
assert.match(t19[0].text, /Zahlung nach dem Abo-Ende/)
|
||
assert.doesNotMatch(t19[0].text, /Ticket noch offen/)
|
||
assert.match(t19[0].text, /149 €/)
|
||
assert.match(t19[0].text, new RegExp(sessionId))
|
||
assert.match(t19[0].text, /Kunde Eins/)
|
||
assert.match(t19[0].text, /Salon/)
|
||
assert.ok(t19[0].html.includes(rechnungUrl(sessionId)), 'Knopf zur Rechnung')
|
||
assert.ok(t19[0].to.every((x) => config.chatNotify.emails.includes(x.address)), 'T19 geht an das Team')
|
||
spur()
|
||
|
||
// Wiederholung, async_payment_succeeded nach completed und paralleles Paar: stumm
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
|
||
const parallel = await Promise.all([
|
||
webhook('checkout.session.completed', bezahlt(sessionId)),
|
||
webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId)),
|
||
])
|
||
assert.deepEqual(parallel.map((r) => r.status), [200, 200])
|
||
assert.equal(mails.sent.length, 1)
|
||
})
|
||
|
||
test('(6) parallel von Anfang an: zwei Zustellungen, ein T19', async (t) => {
|
||
const sessionId = await bestelle()
|
||
await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung' })
|
||
STUMM(t)
|
||
const ergebnis = await Promise.all([
|
||
webhook('checkout.session.completed', bezahlt(sessionId)),
|
||
webhook('checkout.session.completed', bezahlt(sessionId)),
|
||
webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId)),
|
||
])
|
||
assert.deepEqual(ergebnis.map((r) => r.status), [200, 200, 200])
|
||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||
assert.equal(mails.sent.length, 1)
|
||
assert.equal(tickets().length, 0)
|
||
spur()
|
||
})
|
||
|
||
// ------------------------------------------------------------------------------------ (7) Sitzung abgelaufen ---
|
||
test('(7) Stripe-Sitzung laeuft ab, die Zahlung kommt doch: Uebernahme, setup, ein Ticket, K15 und T16 je einmal', async () => {
|
||
const sessionId = await bestelle()
|
||
const expired = { id: sessionId, object: 'checkout.session', mode: 'payment', metadata: { customerId: 'c1', projectId: 'p1', featureKey: 'termine' } }
|
||
assert.equal((await webhook('checkout.session.expired', expired)).status, 200)
|
||
assert.equal((await doc()).state, 'off')
|
||
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||
const d = await doc()
|
||
assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.stripeSessionId], ['setup', 'paid', true, sessionId])
|
||
assert.equal(tickets().length, 1)
|
||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||
assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, KEIN_ABO)
|
||
spur()
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||
assert.equal(tickets().length, 1)
|
||
assert.equal(mails.sent.length, 2)
|
||
})
|
||
|
||
// ------------------------------------------------------------------------------------ (8) Neubestellung nach Ablauf ---
|
||
test('(8) Neubestellung nach Ablauf: die alte Sitzung zahlt (Uebernahme, ein Ticket), die neue danach ungeklaert, kein zweites Ticket', async (t) => {
|
||
const alt = await bestelle()
|
||
const expired = { id: alt, object: 'checkout.session', mode: 'payment', metadata: { customerId: 'c1', projectId: 'p1', featureKey: 'termine' } }
|
||
st.checkoutSessions.get(alt).status = 'expired'
|
||
await webhook('checkout.session.expired', expired)
|
||
const neu = await bestelle()
|
||
assert.notEqual(neu, alt)
|
||
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(alt))).status, 200)
|
||
const d = await doc()
|
||
assert.deepEqual([d.state, d.stripeSessionId], ['setup', alt], 'Uebernahme in die laufende Bestellung')
|
||
assert.equal(tickets().length, 1)
|
||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||
spur()
|
||
|
||
const stumm = STUMM(t)
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(neu))).status, 200)
|
||
assert.equal(tickets().length, 1, 'kein zweites Ticket')
|
||
const t16 = mailsMit(SUBJECT.T16)
|
||
assert.equal(t16.length, 2)
|
||
assert.match(t16[1].text, /Bezahlt, aber nicht zuzuordnen/)
|
||
assert.match(t16[1].text, new RegExp(neu))
|
||
assert.match(geloggt(stumm), new RegExp(neu))
|
||
assert.equal((await doc()).stripeSessionId, alt)
|
||
spur()
|
||
})
|
||
|
||
// ------------------------------------------------------------------------------------ (9) SEPA ---
|
||
test('(9) SEPA: completed ohne paid tut nichts, async_payment_succeeded wie (1)', async () => {
|
||
const sessionId = await bestelle()
|
||
const unbezahlt = bezahlt(sessionId, { payment_status: 'unpaid' })
|
||
assert.equal((await webhook('checkout.session.completed', unbezahlt)).status, 200)
|
||
assert.equal((await doc()).state, 'ordered')
|
||
assert.equal(tickets().length, 0)
|
||
assert.equal(mails.sent.length, 0)
|
||
|
||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
|
||
assert.equal((await doc()).state, 'setup')
|
||
assert.equal(tickets().length, 1)
|
||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||
assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, KEIN_ABO)
|
||
spur()
|
||
})
|
||
|
||
test('(9) SEPA fehlgeschlagen: T16 zahlung_fehlgeschlagen mit Terminplaner, Bestellung zurueck auf off', async () => {
|
||
const sessionId = await bestelle('p2')
|
||
const res = await webhook('checkout.session.async_payment_failed', bezahlt(sessionId, { projectId: 'p2', payment_status: 'unpaid' }))
|
||
assert.equal(res.status, 200)
|
||
assert.equal((await doc('p2')).state, 'off')
|
||
const t16 = mailsMit(SUBJECT.T16)
|
||
assert.equal(t16.length, 1)
|
||
assert.match(t16[0].text, /Zahlung fehlgeschlagen/)
|
||
assert.match(t16[0].text, /Terminplaner/)
|
||
assert.equal(tickets().length, 0)
|
||
})
|
||
|
||
// ------------------------------------------------------------------------------------ (10) Versandfehler ---
|
||
test('(10) T16 bereits_aktiv scheitert beim Versand: Webhook 500, Zahlung gebucht, Marker offen; Wiederholung sendet genau eine Mail', async (t) => {
|
||
const sessionId = await bestelle()
|
||
await sf.adminActivate('c1', 'p1', 'termine', { by: 'Andrej' })
|
||
STUMM(t)
|
||
mails.versandFehler = (m) => m.subject === SUBJECT.T16
|
||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||
assert.equal(res.status, 500)
|
||
|
||
const d = await doc()
|
||
assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['active', 'paid', true, 'bereits_aktiv'])
|
||
assert.match(d.adminNote, SYSTEMZEILE(sessionId))
|
||
assert.equal(mails.sent.length, 0)
|
||
|
||
mails.versandFehler = null
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||
assert.match(mailsMit(SUBJECT.T16)[0].text, BEREITS_DA)
|
||
assert.equal((await doc()).zahlungMeldung, 'gemeldet')
|
||
assert.equal((await doc()).adminNote, d.adminNote, 'die Wiederholung bucht nichts doppelt')
|
||
// weitere Wiederholungen stumm
|
||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
|
||
assert.equal(mails.sent.length, 1)
|
||
spur()
|
||
})
|
||
|
||
test('(10) T19 scheitert beim Versand: Webhook 500, Marker offen; Wiederholung sendet genau ein T19', async (t) => {
|
||
const sessionId = await bestelle()
|
||
await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung' })
|
||
STUMM(t)
|
||
mails.versandFehler = (m) => m.subject === SUBJECT.T19
|
||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||
assert.equal(res.status, 500)
|
||
|
||
const d = await doc()
|
||
assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['disabled', 'paid', true, 'abo_ende'])
|
||
assert.match(d.adminNote, SYSTEMZEILE(sessionId))
|
||
assert.equal(mails.sent.length, 0)
|
||
|
||
mails.versandFehler = null
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||
assert.equal((await doc()).zahlungMeldung, 'gemeldet')
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
|
||
assert.equal(mails.sent.length, 1)
|
||
spur()
|
||
})
|
||
|
||
test('(10) K15 und T16 beim normalen created scheitern weiterhin gefangen: Webhook 200, Ticket da', async (t) => {
|
||
const sessionId = await bestelle()
|
||
STUMM(t)
|
||
mails.versandFehler = () => true
|
||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||
assert.equal(res.status, 200)
|
||
assert.equal((await doc()).state, 'setup')
|
||
assert.equal(tickets().length, 1)
|
||
assert.equal(mails.sent.length, 0)
|
||
// der Wiederholungsversuch sieht created:false und schickt nichts mehr (wie bisher)
|
||
mails.versandFehler = null
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||
assert.equal(mails.sent.length, 0)
|
||
assert.equal(tickets().length, 1)
|
||
})
|
||
|
||
// ------------------------------------------------------------------------------------ (11) News ---
|
||
test('(11) News + Staff-Aktivierung + Zahlung: T16 bereits_aktiv (die bisher stille Luecke)', async (t) => {
|
||
await sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_news_b', consent: {} })
|
||
await sf.adminActivate('c1', 'p1', 'news', { by: 'Andrej' })
|
||
const stumm = STUMM(t)
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt('cs_news_b', { featureKey: 'news' }))).status, 200)
|
||
|
||
const d = await doc('p1', 'news')
|
||
assert.deepEqual([d.state, d.purchaseStatus, d.zahlungMeldung], ['active', 'paid', 'gemeldet'])
|
||
assert.equal(aw.docs('workorders').size, 0, 'kein Ticket')
|
||
assert.equal(mails.sent.length, 1)
|
||
const t16 = mailsMit(SUBJECT.T16_NEWS)
|
||
assert.equal(t16.length, 1)
|
||
assert.match(t16[0].text, BEREITS_DA)
|
||
assert.match(t16[0].text, /schon freigeschaltet/)
|
||
assert.match(geloggt(stumm), /cs_news_b/)
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt('cs_news_b', { featureKey: 'news' }))).status, 200)
|
||
assert.equal(mails.sent.length, 1)
|
||
})
|
||
|
||
test('(11) Zahlung auf ein Dokument in Einrichtung (setup mit Ticket, Zahlung noch nicht gebucht): T16 bereits_aktiv mit "in Einrichtung"', async (t) => {
|
||
aw.seed('portalFeatures', {
|
||
$id: 'f_p1_termine',
|
||
customerId: 'c1',
|
||
projectId: 'p1',
|
||
featureKey: 'termine',
|
||
state: 'setup',
|
||
enabled: false,
|
||
siteKey: 'abcdefghijklmnop',
|
||
stripeSessionId: 'cs_alt_setup',
|
||
ticketId: 'wo_alt',
|
||
unlockedByPurchase: false,
|
||
purchaseStatus: 'ordered',
|
||
})
|
||
STUMM(t)
|
||
assert.equal((await webhook('checkout.session.completed', bezahlt('cs_alt_setup'))).status, 200)
|
||
const t16 = mailsMit(SUBJECT.T16)
|
||
assert.equal(t16.length, 1)
|
||
assert.match(t16[0].text, /schon in Einrichtung/)
|
||
assert.equal((await doc()).state, 'setup')
|
||
assert.equal(mailsMit(SUBJECT.K15).length, 0)
|
||
})
|
||
|
||
// ------------------------------------------------------------------------------------ (12) meldeErstattung direkt ---
|
||
test('(12) meldeErstattung: bezahltes setup-Dokument => ein T19 mit ticket_offen, Marker gemeldet, zweiter Aufruf schonGemeldet', async () => {
|
||
const sessionId = await bestelle()
|
||
await webhook('checkout.session.completed', bezahlt(sessionId))
|
||
const vorher = mails.sent.length
|
||
const d = await doc()
|
||
const woid = aw.docs('workorders').get(d.ticketId).woid
|
||
|
||
const erst = await sf.meldeErstattung(d)
|
||
assert.deepEqual(erst, { sent: true })
|
||
const t19 = mailsMit(SUBJECT.T19)
|
||
assert.equal(t19.length, 1)
|
||
assert.equal(mails.sent.length, vorher + 1)
|
||
assert.match(t19[0].text, /Ticket noch offen/)
|
||
assert.match(t19[0].text, new RegExp(String(woid)))
|
||
assert.doesNotMatch(t19[0].text, /Zahlung nach dem Abo-Ende/, 'die Zahlung kam vor dem Abo-Ende')
|
||
assert.match(t19[0].text, /149 €/)
|
||
assert.match(t19[0].text, new RegExp(sessionId))
|
||
assert.ok(t19[0].html.includes(rechnungUrl(sessionId)))
|
||
const gemeldet = await doc()
|
||
assert.equal(gemeldet.zahlungMeldung, 'gemeldet')
|
||
assert.equal(gemeldet.purchaseStatus, 'paid')
|
||
|
||
assert.deepEqual(await sf.meldeErstattung(d), { sent: false, schonGemeldet: true })
|
||
assert.deepEqual(await sf.meldeErstattung(await doc()), { sent: false, schonGemeldet: true })
|
||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||
})
|
||
|
||
test('(12) meldeErstattung parallel: nie zwei Mails', async () => {
|
||
const sessionId = await bestelle()
|
||
await webhook('checkout.session.completed', bezahlt(sessionId))
|
||
const d = await doc()
|
||
const ergebnis = await Promise.all([sf.meldeErstattung(d), sf.meldeErstattung(d), sf.meldeErstattung(d)])
|
||
assert.equal(ergebnis.filter((r) => r.sent).length, 1)
|
||
assert.equal(ergebnis.filter((r) => r.schonGemeldet).length, 2)
|
||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||
})
|
||
|
||
test('(12) offener Fall abo_ende + Aufruf des Abgleichs + Webhook-Wiederholung: insgesamt genau ein T19 (zahlung_nach_ende)', async (t) => {
|
||
const sessionId = await bestelle()
|
||
await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung' })
|
||
// Die erste Zustellung bucht den Fall nur (hier direkt ueber markPaid), die Meldung fehlt noch
|
||
const gebucht = await sf.markPaid({ sessionId, invoiceId: `in_${sessionId}`, invoiceUrl: rechnungUrl(sessionId) })
|
||
assert.equal(gebucht.fall, 'abo_ende')
|
||
assert.equal(mails.sent.length, 0)
|
||
|
||
// Der Abgleich (nachAboEnde: false) meldet zuerst: der offene Fall abo_ende macht daraus die Zahlung nach dem Abo-Ende
|
||
assert.deepEqual(await sf.meldeErstattung(gebucht.doc, { nachAboEnde: false }), { sent: true })
|
||
const t19 = mailsMit(SUBJECT.T19)
|
||
assert.equal(t19.length, 1)
|
||
assert.match(t19[0].text, /Zahlung nach dem Abo-Ende/)
|
||
assert.doesNotMatch(t19[0].text, /Ticket noch offen/, 'kein Ticket')
|
||
assert.equal((await doc()).zahlungMeldung, 'gemeldet')
|
||
|
||
// die Wiederholung des Webhooks danach bleibt stumm, auch parallel zu einem weiteren Aufruf
|
||
STUMM(t)
|
||
const parallel = await Promise.all([
|
||
webhook('checkout.session.completed', bezahlt(sessionId)),
|
||
sf.meldeErstattung(await doc()),
|
||
])
|
||
assert.equal(parallel[0].status, 200)
|
||
assert.deepEqual(parallel[1], { sent: false, schonGemeldet: true })
|
||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||
assert.equal(mails.sent.length, 1)
|
||
assert.equal(tickets().length, 0)
|
||
spur()
|
||
})
|
||
|
||
test('(12) Abgleich und Webhook gleichzeitig auf den offenen Fall: ein T19', async (t) => {
|
||
const sessionId = await bestelle()
|
||
await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung' })
|
||
const gebucht = await sf.markPaid({ sessionId, invoiceId: `in_${sessionId}`, invoiceUrl: rechnungUrl(sessionId) })
|
||
STUMM(t)
|
||
const [abgleich, hook] = await Promise.all([sf.meldeErstattung(gebucht.doc), webhook('checkout.session.completed', bezahlt(sessionId))])
|
||
assert.equal(hook.status, 200)
|
||
assert.ok(abgleich.sent || abgleich.schonGemeldet)
|
||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||
assert.match(mailsMit(SUBJECT.T19)[0].text, /Zahlung nach dem Abo-Ende/)
|
||
assert.equal(mails.sent.length, 1)
|
||
})
|
||
|
||
test('(12) meldeErstattung: Mail scheitert => { sent: false }, kein Marker, kein Wurf; spaeter sendet der naechste Aufruf', async (t) => {
|
||
const sessionId = await bestelle()
|
||
await webhook('checkout.session.completed', bezahlt(sessionId))
|
||
const d = await doc()
|
||
STUMM(t)
|
||
mails.versandFehler = () => true
|
||
const ergebnis = await sf.meldeErstattung(d)
|
||
assert.deepEqual(ergebnis, { sent: false })
|
||
assert.equal((await doc()).zahlungMeldung, '', 'kein Marker')
|
||
assert.equal(mailsMit(SUBJECT.T19).length, 0)
|
||
|
||
mails.versandFehler = null
|
||
assert.deepEqual(await sf.meldeErstattung(d), { sent: true })
|
||
assert.equal((await doc()).zahlungMeldung, 'gemeldet')
|
||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||
})
|
||
|
||
test('(12) meldeErstattung: nachAboEnde setzt den Block auch ohne offenen Fall, Sitzung und Kunde sind uebersteuerbar', async () => {
|
||
const sessionId = await bestelle()
|
||
await webhook('checkout.session.completed', bezahlt(sessionId))
|
||
const d = await doc()
|
||
const ergebnis = await sf.meldeErstattung(d, { nachAboEnde: true, sitzung: 'cs_andere', customer: { name: 'Frau Muster', email: 'muster@example.invalid' } })
|
||
assert.deepEqual(ergebnis, { sent: true })
|
||
const [t19] = mailsMit(SUBJECT.T19)
|
||
assert.match(t19.text, /Zahlung nach dem Abo-Ende/)
|
||
assert.match(t19.text, /cs_andere/)
|
||
assert.match(t19.text, /Frau Muster/)
|
||
assert.match(t19.text, /muster@example\.invalid/)
|
||
})
|
||
|
||
test('(12) meldeErstattung wirft nie: unbekanntes Dokument => { sent: false }', async (t) => {
|
||
STUMM(t)
|
||
assert.deepEqual(await sf.meldeErstattung({ $id: 'gibt_es_nicht', customerId: 'c1', projectId: 'p1', featureKey: 'termine' }), { sent: false })
|
||
})
|
||
|
||
test('meldeZahlungFall direkt: einmal je Dokument, Zustand im Text, kein Wurf bei Versandfehler', async (t) => {
|
||
const sessionId = await bestelle()
|
||
await sf.adminActivate('c1', 'p1', 'termine', { by: 'Andrej' })
|
||
const { doc: gebucht, fall } = await sf.markPaid({ sessionId, invoiceId: `in_${sessionId}`, invoiceUrl: rechnungUrl(sessionId) })
|
||
assert.equal(fall, 'bereits_aktiv')
|
||
STUMM(t)
|
||
mails.versandFehler = () => true
|
||
assert.deepEqual(await sf.meldeZahlungFall(gebucht, fall), { sent: false })
|
||
assert.equal((await doc()).zahlungMeldung, 'bereits_aktiv')
|
||
mails.versandFehler = null
|
||
assert.deepEqual(await sf.meldeZahlungFall(gebucht, fall), { sent: true })
|
||
assert.deepEqual(await sf.meldeZahlungFall(gebucht, fall), { sent: false, schonGemeldet: true })
|
||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||
assert.match(mailsMit(SUBJECT.T16)[0].text, /schon freigeschaltet/)
|
||
})
|
||
|
||
test('customerOf und projectName sind exportiert', async () => {
|
||
assert.equal((await sf.customerOf('c1')).name, 'Kunde Eins')
|
||
assert.equal(await sf.customerOf('gibt_es_nicht'), null)
|
||
assert.equal(await sf.projectName('p1'), 'Salon')
|
||
assert.equal(await sf.projectName('gibt_es_nicht'), '')
|
||
})
|