Files
Webklar-Kundenbereich/test/aboRoutes.test.js
T

443 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
const aw = await startAppwriteMock({
schema: {
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
aboOffers: schemaKeys('aboOffers'),
hourCredits: schemaKeys('hourCredits'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
// Testkonto (Testrabatt im Checkout): Kunde c4 der Fixtures
process.env.ABO_TEST_EMAILS = 'test@example.invalid'
const { config } = await import('../server/config.js')
const { startTestApp, sessionCookie, get, rawGet, post, postWebhook } = await import('./support/testApp.js')
const { hostingDoc, wartungDoc, consent, customerCookie, adminCookie, viewAsCookie, seedFixtures, subscriptionDoc, stripeSubscription } =
await import('./support/fixtures.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { recordContractEvent, listContractEvents, eventIdFor } = await import('../server/services/contractEvents.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
const { offerText } = await import('../server/services/offers.js')
const app = await startTestApp({ '/api/abo': aboRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
seedFixtures(aw)
const DAY_MS = 86400000
const daysAgo = (days) => new Date(Date.now() - days * DAY_MS).toISOString()
const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000)
const orderBody = (productKey, extra = {}) => ({
productKey,
acceptTerms: true,
startChoice: 'sofort',
termsVersion: config.abo.termsVersion,
offerId: '',
...extra,
})
/** Bestellung abschicken; dazu der Checkout-Aufruf beim Stripe-Nachbau (nur Aufrufe ab jetzt, sonst null). */
async function checkout(body, { cookie = customerCookie('c3') } = {}) {
const before = st.calls.length
const res = await post('/api/abo/checkout', body, { cookie })
const call = st.calls.slice(before).find((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions') || null
return { res, call }
}
const message = (call) => call.body['custom_text[submit][message]']
const priceIdFor = (lookupKey) => [...st.prices.values()].find((p) => p.lookup_key === lookupKey)?.id
// Hinweistext ueber dem Bezahlknopf, wortgleich (Wartung wie vor Plan 2, plus § 19 UStG)
const INTRO_SOFORT =
'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande.'
const INTRO_FRIST =
'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab.'
const TAIL = 'Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.'
const OFFER_O1 = {
$id: 'o1', customerId: 'c3', status: 'offen', scope: 'neu', months: 2, monthlyPriceCents: 2500, regularPriceCents: 5000,
validUntil: '2099-01-01T00:00:00.000Z', stripeCouponId: 'WEBKLAR-ANGEBOT-O1', note: '',
}
async function clearEvents(customerId) {
for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id)
}
test('GET /api/abo: Katalog, Hosting-Abo mit Speicher, kein Stundenkonto', async () => {
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 })
const r = await get('/api/abo', { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) })
assert.deepEqual(r.products.map((p) => [p.key, p.amountCents]), [['hosting', 1500], ['wartung', 5000]])
assert.equal(r.subscription.productKey, 'hosting'); assert.equal(r.subscription.storage.units, 2)
assert.equal(r.subscription.canSwitch, true); assert.equal(r.ledger, null)
assert.equal(r.subscription.termsUrl, '/abo-bedingungen.html')
// F13: PDF der zugestimmten Fassung; Katalogdaten fuer Speicher-Widget und Bestellseiten
assert.equal(r.subscription.termsPdfUrl, '/WEBklar-Abo-Vertragsunterlagen.pdf')
assert.equal(r.subscription.termsVersion, config.abo.termsVersion)
assert.deepEqual(r.mailStorage, { includedGb: 5, gbPerUnit: 5, unitPriceCents: 100, maxUnits: 10 })
// Angebote je Produkt (Hosting bis Aufgabe 11 immer null); das alte Feld offer entfaellt (F15)
assert.deepEqual(r.offers, { hosting: null, wartung: null })
assert.equal('offer' in r, false)
assert.deepEqual(r.subscription.storage, { units: 2, pendingSetup: false, pendingReduction: null })
assert.equal(r.subscription.canChangeStorage, true)
assert.equal(r.subscription.switchPending, null)
assert.deepEqual(r.subscription.withdrawals, [])
assert.equal(r.subscription.withdrawalOpen, false)
assert.equal(r.subscription.withdrawalUntil, null)
assert.equal(r.available, true)
assert.equal(r.testAccount, false)
assert.equal(r.termsVersion, config.abo.termsVersion)
assert.ok(r.lateStartAt)
})
test('GET /api/abo: Wartung v3 → Link auf Fassung 3, Stundenkonto da', async () => {
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, termsVersion: '2026-09-25-v3' }))
const r = await get('/api/abo', { cookie: customerCookie() })
assert.equal(r.subscription.termsUrl, '/vertragsunterlagen/v3/abo-bedingungen.html'); assert.ok(r.ledger)
assert.equal(r.subscription.termsPdfUrl, '/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
assert.equal(r.subscription.productKey, 'wartung')
// Wechseln kann nur ein Hosting-Abo; Zusatzspeicher gibt es in beiden Abos
assert.equal(r.subscription.canSwitch, false)
assert.equal(r.subscription.canChangeStorage, true)
})
test('GET /api/abo: Altbestand ohne productKey gilt als Wartungs-Abo', async () => {
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: undefined, termsVersion: '2026-09-25-v3' }))
const r = await get('/api/abo', { cookie: customerCookie() })
assert.equal(r.subscription.productKey, 'wartung')
assert.ok(r.ledger)
assert.equal(r.subscription.canSwitch, false)
})
test('Ohne Session 401, Admin-Session ist keine Kunden-Session', async () => {
assert.equal((await rawGet('/api/abo')).status, 401)
assert.equal((await rawGet('/api/abo', { cookie: adminCookie() })).status, 401)
await assert.rejects(get('/api/abo'), (e) => e.status === 401)
})
test('Checkout Hosting: Preis, Beschreibung, Hinweistext', async () => {
const { res, call } = await checkout(orderBody('hosting'))
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.match(res.body.url, /^https:\/\//)
assert.equal(call.body['subscription_data[description]'], 'WEBklar Hosting-Abo')
assert.match(message(call), /15,00 € monatlich/)
assert.equal(call.body['subscription_data[metadata][productKey]'], 'hosting')
assert.equal(message(call), `${INTRO_SOFORT} 15,00 € monatlich, ${TAIL}`)
assert.equal(call.body['line_items[0][price]'], priceIdFor('webklar_hosting_monatlich_1500'))
assert.equal(call.body['discounts[0][coupon]'], undefined)
assert.equal(call.body['subscription_data[trial_end]'], undefined)
// Nachweis der Zustimmung nur am Abo (F41c): an der Checkout-Sitzung bleiben Zuordnung und Produkt
assert.equal(call.body['subscription_data[metadata][termsAcceptedAt]'] !== undefined, true)
assert.ok(call.body['subscription_data[metadata][consentIp]'])
assert.equal(call.body['subscription_data[metadata][termsVersion]'], config.abo.termsVersion)
for (const key of ['termsVersion', 'consentTextVersion', 'termsAcceptedAt', 'startChoice', 'earlyStartConsentAt', 'consentIp', 'consentUserAgent', 'email']) {
assert.equal(call.body[`metadata[${key}]`], undefined, `metadata[${key}] darf nicht an der Sitzung stehen`)
}
assert.equal(call.body['metadata[webklarCustomerId]'], 'c3')
assert.equal(call.body['metadata[productKey]'], 'hosting')
})
test('Checkout Hosting nach der Widerrufsfrist: Probezeit bis zum Leistungsbeginn, Hinweistext', async () => {
const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') })
const { res, call } = await checkout(orderBody('hosting', { startChoice: 'nach_frist', lateStartAt }))
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(call.body['subscription_data[trial_end]'], String(unix(lateStartAt)))
assert.equal(message(call), `${INTRO_FRIST} Danach 15,00 € monatlich, ${TAIL}`)
// Veralteter Leistungsbeginn (Datumswechsel seit dem Laden der Seite) -> 409, kein Checkout
const stale = await checkout(orderBody('hosting', { startChoice: 'nach_frist', lateStartAt: '2026-01-02T05:00:00.000Z' }))
assert.equal(stale.res.status, 409)
assert.equal(stale.call, null)
})
test('Checkout Wartung: 50,00 €, Beschreibung, Hinweistexte wortgleich', async () => {
const sofort = await checkout(orderBody('wartung'))
assert.equal(sofort.res.status, 200, JSON.stringify(sofort.res.body))
assert.equal(sofort.call.body['subscription_data[description]'], 'WEBklar Wartungs-Abo')
assert.equal(sofort.call.body['subscription_data[metadata][productKey]'], 'wartung')
assert.equal(sofort.call.body['line_items[0][price]'], priceIdFor('webklar_wartung_monatlich_5000'))
assert.equal(message(sofort.call), `${INTRO_SOFORT} 50,00 € monatlich, ${TAIL}`)
const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') })
const frist = await checkout(orderBody('wartung', { startChoice: 'nach_frist', lateStartAt }))
assert.equal(frist.res.status, 200, JSON.stringify(frist.res.body))
assert.equal(message(frist.call), `${INTRO_FRIST} Danach 50,00 € monatlich, ${TAIL}`)
assert.equal(frist.call.body['subscription_data[trial_end]'], String(unix(lateStartAt)))
})
test('Checkout Testkonto: Testrabatt des bestellten Produkts', async () => {
const r = await get('/api/abo', { cookie: customerCookie('c4') })
assert.equal(r.testAccount, true)
const hosting = await checkout(orderBody('hosting'), { cookie: customerCookie('c4') })
assert.equal(hosting.res.status, 200, JSON.stringify(hosting.res.body))
assert.equal(hosting.call.body['discounts[0][coupon]'], 'WEBKLAR-TEST-050-HOSTING')
const wartung = await checkout(orderBody('wartung'), { cookie: customerCookie('c4') })
assert.equal(wartung.res.status, 200, JSON.stringify(wartung.res.body))
assert.equal(wartung.call.body['discounts[0][coupon]'], 'WEBKLAR-TEST-050')
})
test('Checkout: unbekanntes Produkt → 400', async () => {
const gold = await checkout(orderBody('gold'))
assert.equal(gold.res.status, 400)
assert.equal(gold.call, null)
assert.equal((await checkout({ ...orderBody('hosting'), productKey: undefined })).res.status, 400)
})
test('Checkout Hosting mit Angebot → 409; das Angebot gilt fuer die Wartung', async () => {
aw.seed('aboOffers', OFFER_O1)
try {
const r = await get('/api/abo', { cookie: customerCookie('c3') })
assert.equal(r.offers.hosting, null)
assert.equal(r.offers.wartung.id, 'o1')
const hosting = await checkout(orderBody('hosting', { offerId: 'o1' }))
assert.equal(hosting.res.status, 409)
assert.match(hosting.res.body.error, /anderes Abo/)
assert.equal(hosting.call, null)
const wartung = await checkout(orderBody('wartung', { offerId: 'o1' }))
assert.equal(wartung.res.status, 200, JSON.stringify(wartung.res.body))
assert.equal(wartung.call.body['discounts[0][coupon]'], 'WEBKLAR-ANGEBOT-O1')
assert.equal(wartung.call.body['subscription_data[metadata][offerId]'], 'o1')
assert.equal(message(wartung.call), `${INTRO_SOFORT} Preis wie vereinbart: ${offerText(OFFER_O1)}. ${TAIL}`)
// Der Webhook liest das Angebot von der Sitzung (activateOfferFromCheckout)
assert.equal(wartung.call.body['metadata[offerId]'], 'o1')
} finally {
await deleteDocument(config.collections.aboOffers, 'o1')
}
})
test('Checkout mit laufendem Abo → 409', async () => {
aw.seed('subscriptions', hostingDoc)
const { res, call } = await checkout(orderBody('wartung'), { cookie: customerCookie('c1') })
assert.equal(res.status, 409)
assert.match(res.body.error, /bereits/)
assert.equal(call, null)
})
test('Checkout im Modus „Als Kunde ansehen“ → 403', async () => {
const { res, call } = await checkout(orderBody('hosting'), { cookie: viewAsCookie('c3') })
assert.equal(res.status, 403)
assert.equal(call, null)
})
test('Gekündigtes Abo: canSwitch und canChangeStorage false', async () => {
aw.seed('subscriptions', { ...hostingDoc, cancelAtPeriodEnd: true, cancelAt: '2027-10-05T10:00:00.000Z' })
const r = await get('/api/abo', { cookie: customerCookie() })
assert.equal(r.subscription.canSwitch, false); assert.equal(r.subscription.canChangeStorage, false)
assert.equal(r.subscription.cancelAt, '2027-10-05T10:00:00.000Z')
})
test('Kein Wechsel und kein Speicher-Plus bei trialing, past_due oder Kündigung nur per cancelAt', async () => {
for (const patch of [
{ status: 'trialing', trialEnd: '2026-11-01T05:00:00.000Z', startChoice: 'nach_frist' },
{ status: 'past_due' },
{ cancelAtPeriodEnd: false, cancelAt: '2027-10-05T10:00:00.000Z' },
]) {
aw.seed('subscriptions', { ...hostingDoc, ...patch })
const r = await get('/api/abo', { cookie: customerCookie() })
assert.equal(r.subscription.canSwitch, false, JSON.stringify(patch))
assert.equal(r.subscription.canChangeStorage, false, JSON.stringify(patch))
assert.equal(r.subscription.active, true, JSON.stringify(patch))
}
aw.seed('subscriptions', hostingDoc)
})
test('Widerrufsfristen aus den Ereignissen', async () => {
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 })
const consentAt = daysAgo(2)
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
details: { fromUnits: 0, toUnits: 2 }, consent: { ...consent, consentAt }, status: 'offen', dueAt: consentAt,
})
try {
const r = await get('/api/abo', { cookie: customerCookie() })
assert.deepEqual(r.subscription.withdrawals.map((w) => w.type), ['speicher'])
assert.equal(r.subscription.withdrawalOpen, true)
assert.equal(r.subscription.withdrawalUntil, withdrawalDeadline(consentAt).endsAt.toISOString())
assert.equal(r.subscription.withdrawals[0].until, r.subscription.withdrawalUntil)
assert.ok(r.subscription.withdrawals[0].eventId)
// Offenes Plus: konsoleH noch nicht umgestellt
assert.deepEqual(r.subscription.storage, { units: 2, pendingSetup: true, pendingReduction: null })
} finally {
await clearEvents('c1')
}
})
test('Geplante Verringerung: ab Periodenende, ohne Widerrufsfrist', async () => {
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 })
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
details: { fromUnits: 2, toUnits: 1 }, consent: null, status: 'offen', dueAt: hostingDoc.currentPeriodEnd,
})
try {
const r = await get('/api/abo', { cookie: customerCookie() })
assert.deepEqual(r.subscription.storage, { units: 2, pendingSetup: false, pendingReduction: { units: 1, effectiveAt: hostingDoc.currentPeriodEnd } })
assert.deepEqual(r.subscription.withdrawals, [])
assert.equal(r.subscription.withdrawalOpen, false)
assert.equal(r.subscription.canChangeStorage, true)
} finally {
await clearEvents('c1')
}
})
// F12: solange ein Wechsel auf die Zahlung wartet, gibt es weder Wechsel noch Speicheraenderung,
// und das Portal kann den Kunden zur Zahlungsseite zurueckfuehren
test('Wechsel wartet auf die Zahlung: canSwitch und canChangeStorage false, switchPending mit Zahlungslink', async () => {
aw.seed('subscriptions', hostingDoc)
const ev = await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', paymentUrl: 'https://pay.example/inv' }, consent, status: 'offen',
})
try {
const r = await get('/api/abo', { cookie: customerCookie() })
assert.equal(r.subscription.canSwitch, false)
assert.equal(r.subscription.canChangeStorage, false)
assert.deepEqual(r.subscription.switchPending, { since: consent.consentAt, paymentUrl: 'https://pay.example/inv' })
assert.equal(ev.status, 'offen')
} finally {
await clearEvents('c1')
}
})
// F14: Abos von vor Plan 2 haben kein Ereignis abschluss -> Frist ab startedAt wie bisher
test('Altbestand ohne Ereignis abschluss: Widerrufsfrist ab startedAt', async () => {
const startedAt = daysAgo(2)
aw.seed('subscriptions', { ...hostingDoc, startedAt, termStartAt: startedAt })
const r = await get('/api/abo', { cookie: customerCookie() })
const until = withdrawalDeadline(startedAt).endsAt.toISOString()
assert.deepEqual(r.subscription.withdrawals, [{ type: 'abschluss', eventId: null, until }])
assert.equal(r.subscription.withdrawalOpen, true)
assert.equal(r.subscription.withdrawalUntil, until)
// Mit Ereignis zaehlt allein das Ereignis (hier: Vertragsschluss vor 40 Tagen, Frist vorbei)
await recordContractEvent({
id: eventIdFor('abschluss', 'sub_h'), customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'hosting',
details: { contractAt: daysAgo(40) }, consent: { ...consent, consentAt: daysAgo(40) },
})
try {
const again = await get('/api/abo', { cookie: customerCookie() })
assert.deepEqual(again.subscription.withdrawals, [])
assert.equal(again.subscription.withdrawalOpen, false)
} finally {
await clearEvents('c1')
}
})
test('minTermEnd rechnet ab termStartAt, sonst ab trialEnd bzw. startedAt', async () => {
aw.seed('subscriptions', { ...hostingDoc, termStartAt: '2026-10-05T10:00:00.000Z' })
assert.equal((await get('/api/abo', { cookie: customerCookie() })).subscription.minTermEnd, '2027-10-05T10:00:00.000Z')
aw.seed('subscriptions', { ...hostingDoc, termStartAt: null, trialEnd: '2026-11-01T05:00:00.000Z', status: 'trialing' })
assert.equal((await get('/api/abo', { cookie: customerCookie() })).subscription.minTermEnd, '2027-11-01T05:00:00.000Z')
aw.seed('subscriptions', hostingDoc)
})
// ---------------------------------------------------------------------------
// Webhook checkout.session.completed (zuletzt: gibt c3 voruebergehend ein Abo)
// ---------------------------------------------------------------------------
// Abo-IDs 28 Zeichen wie bei Stripe (sub_ + 24): 'abschluss-' + ID waeren 38 Zeichen, Appwrite erlaubt 36
const SUB_NEW = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp'
const checkoutCompleted = (subscriptionId, metadata = {}) => ({
id: `evt_${subscriptionId}`,
object: 'event',
type: 'checkout.session.completed',
created: Math.floor(Date.now() / 1000),
data: {
object: {
id: `cs_${subscriptionId}`, object: 'checkout.session', mode: 'subscription', subscription: subscriptionId, customer: 'cus_c3',
customer_details: { email: 'k3@example.invalid', name: 'Kunde Drei' },
metadata: { webklarCustomerId: 'c3', productKey: 'hosting', ...metadata },
},
},
})
async function cleanupWebhook(subscriptionId, offerId = null) {
await clearEvents('c3')
await deleteDocument(config.collections.subscriptions, subscriptionId).catch(() => {})
if (offerId) await deleteDocument(config.collections.aboOffers, offerId).catch(() => {})
}
// F04/F05/F25: der Abschluss wird als Ereignis nachgewiesen, die Frist zaehlt ab dem Vertragsschluss bei
// Stripe (startedAt), eine Webhook-Wiederholung legt kein zweites Ereignis an
test('Webhook checkout.session.completed: Ereignis abschluss ab Vertragsschluss, kein Duplikat', async () => {
assert.equal(SUB_NEW.length, 28)
const stripeSub = stripeSubscription({ id: SUB_NEW, customerId: 'c3', productKey: 'hosting' })
// Zustimmung im Portal eine Minute vor dem Abschluss bei Stripe
const termsAcceptedAt = new Date((stripeSub.start_date - 60) * 1000).toISOString()
stripeSub.metadata.termsAcceptedAt = termsAcceptedAt
st.subscriptions.set(SUB_NEW, stripeSub)
const event = checkoutCompleted(SUB_NEW)
try {
const first = await postWebhook(event)
assert.equal(first.status, 200, JSON.stringify(first.body))
const events = await listContractEvents('c3')
assert.equal(events.length, 1)
const [e] = events
assert.equal(e.$id, eventIdFor('abschluss', SUB_NEW))
assert.equal(e.$id.length, 36)
assert.equal(e.type, 'abschluss')
assert.equal(e.subscriptionId, SUB_NEW)
assert.equal(e.productKey, 'hosting')
assert.equal(e.status, 'erledigt')
assert.equal(e.details.contractAt, new Date(stripeSub.start_date * 1000).toISOString())
assert.equal(e.termsVersion, config.abo.termsVersion)
assert.equal(e.consentAt, termsAcceptedAt)
assert.equal(e.consentIp, '127.0.0.1')
assert.equal(e.consentUserAgent, 'test')
assert.equal(aw.docs('subscriptions').get(SUB_NEW).productKey, 'hosting')
const again = await postWebhook(event)
assert.equal(again.status, 200)
assert.equal((await listContractEvents('c3')).length, 1)
// Falsche Signatur: 400, nichts verarbeitet
assert.equal((await postWebhook(event, { secret: 'whsec_falsch' })).status, 400)
} finally {
await cleanupWebhook(SUB_NEW)
}
})
// Fix 1, Important: ein dauerhafter Fehler beim Nachweis (4xx, z. B. Collection oder Attribut fehlt) darf
// Angebot, T02 und K01 nicht blockieren; ein voruebergehender (5xx, Netz) gibt 500, Stripe wiederholt
test('Webhook: Nachweis dauerhaft nicht speicherbar (400) → 200, Angebot trotzdem aktiviert', async () => {
const subId = 'sub_1SD6w0QxAbCdEfGhIjKlMnOq'
st.subscriptions.set(subId, stripeSubscription({ id: subId, customerId: 'c3', productKey: 'wartung', metadata: { offerId: 'o2' } }))
aw.seed('aboOffers', { ...OFFER_O1, $id: 'o2', stripeCouponId: 'WEBKLAR-ANGEBOT-O2' })
aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 400 })
try {
const res = await postWebhook(checkoutCompleted(subId, { productKey: 'wartung', offerId: 'o2' }))
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal((await listContractEvents('c3')).length, 0)
assert.equal(aw.docs('aboOffers').get('o2').status, 'aktiv')
assert.equal(aw.docs('aboOffers').get('o2').stripeSubscriptionId, subId)
assert.equal(aw.docs('subscriptions').get(subId).productKey, 'wartung')
} finally {
await cleanupWebhook(subId, 'o2')
}
})
test('Webhook: Nachweis voruebergehend nicht speicherbar (500) → 500, Wiederholung holt alles nach', async () => {
const subId = 'sub_1SD6x1RxAbCdEfGhIjKlMnOr'
st.subscriptions.set(subId, stripeSubscription({ id: subId, customerId: 'c3', productKey: 'wartung', metadata: { offerId: 'o3' } }))
aw.seed('aboOffers', { ...OFFER_O1, $id: 'o3', stripeCouponId: 'WEBKLAR-ANGEBOT-O3' })
aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 })
const event = checkoutCompleted(subId, { productKey: 'wartung', offerId: 'o3' })
try {
const failed = await postWebhook(event)
assert.equal(failed.status, 500)
assert.equal((await listContractEvents('c3')).length, 0)
assert.equal(aw.docs('aboOffers').get('o3').status, 'offen')
// Stripe wiederholt: Nachweis, Angebot und Hinweise kommen nach
const retry = await postWebhook(event)
assert.equal(retry.status, 200, JSON.stringify(retry.body))
const events = await listContractEvents('c3')
assert.equal(events.length, 1)
assert.equal(events[0].$id, eventIdFor('abschluss', subId))
assert.equal(aw.docs('aboOffers').get('o3').status, 'aktiv')
} finally {
await cleanupWebhook(subId, 'o3')
}
})