310 lines
16 KiB
JavaScript
310 lines
16 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'
|
|
|
|
// 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 })
|
|
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, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
|
|
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
|
const { recordContractEvent, listContractEvents } = await import('../server/services/contractEvents.js')
|
|
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
|
const { withdrawalDeadline } = await import('../server/services/deadlines.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 orderBody = (productKey, extra = {}) => ({
|
|
productKey,
|
|
acceptTerms: true,
|
|
startChoice: 'sofort',
|
|
termsVersion: config.abo.termsVersion,
|
|
offerId: '',
|
|
...extra,
|
|
})
|
|
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.termsVersion, config.abo.termsVersion)
|
|
assert.ok(r.lateStartAt)
|
|
})
|
|
|
|
test('GET /api/abo: Wartung v3 → Link auf Fassung 3, Stundenkonto da', async () => {
|
|
aw.seed('subscriptions', { ...wartungDoc, $id: 'sub_h', customerId: 'c1', 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', { ...wartungDoc, $id: 'sub_h', 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 = await post('/api/abo/checkout', orderBody('hosting'), { cookie: customerCookie('c3') })
|
|
assert.equal(res.status, 200, JSON.stringify(res.body))
|
|
assert.match(res.body.url, /^https:\/\//)
|
|
const call = st.calls.find((c) => c.path === '/v1/checkout/sessions')
|
|
assert.equal(call.body['subscription_data[description]'], 'WEBklar Hosting-Abo')
|
|
assert.match(call.body['custom_text[submit][message]'], /15,00 € monatlich/)
|
|
assert.equal(call.body['subscription_data[metadata][productKey]'], 'hosting')
|
|
assert.match(call.body['custom_text[submit][message]'], /Mindestlaufzeit 12 Monate, danach monatlich kündbar/)
|
|
assert.match(call.body['custom_text[submit][message]'], /§ 19 UStG/)
|
|
const hostingPrice = [...st.prices.values()].find((p) => p.lookup_key === 'webklar_hosting_monatlich_1500')
|
|
assert.equal(call.body['line_items[0][price]'], hostingPrice.id)
|
|
assert.equal(call.body['discounts[0][coupon]'], 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 Wartung: 50,00 €, Beschreibung, Produkt in den Metadaten', async () => {
|
|
const before = st.calls.length
|
|
const res = await post('/api/abo/checkout', orderBody('wartung'), { cookie: customerCookie('c3') })
|
|
assert.equal(res.status, 200, JSON.stringify(res.body))
|
|
const call = st.calls.slice(before).find((c) => c.path === '/v1/checkout/sessions')
|
|
assert.equal(call.body['subscription_data[description]'], 'WEBklar Wartungs-Abo')
|
|
assert.match(call.body['custom_text[submit][message]'], /50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar/)
|
|
assert.equal(call.body['subscription_data[metadata][productKey]'], 'wartung')
|
|
const wartungPrice = [...st.prices.values()].find((p) => p.lookup_key === 'webklar_wartung_monatlich_5000')
|
|
assert.equal(call.body['line_items[0][price]'], wartungPrice.id)
|
|
})
|
|
|
|
test('Checkout: unbekanntes Produkt → 400', async () => {
|
|
assert.equal((await post('/api/abo/checkout', orderBody('gold'), { cookie: customerCookie('c3') })).status, 400)
|
|
assert.equal((await post('/api/abo/checkout', { ...orderBody('hosting'), productKey: undefined }, { cookie: customerCookie('c3') })).status, 400)
|
|
})
|
|
|
|
test('Checkout Hosting mit Angebot → 409; das Angebot gilt fuer die Wartung', async () => {
|
|
aw.seed('aboOffers', {
|
|
$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: '',
|
|
})
|
|
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 post('/api/abo/checkout', orderBody('hosting', { offerId: 'o1' }), { cookie: customerCookie('c3') })
|
|
assert.equal(hosting.status, 409)
|
|
assert.match(hosting.body.error, /anderes Abo/)
|
|
const before = st.calls.length
|
|
const wartung = await post('/api/abo/checkout', orderBody('wartung', { offerId: 'o1' }), { cookie: customerCookie('c3') })
|
|
assert.equal(wartung.status, 200, JSON.stringify(wartung.body))
|
|
const call = st.calls.slice(before).find((c) => c.path === '/v1/checkout/sessions')
|
|
assert.equal(call.body['discounts[0][coupon]'], 'WEBKLAR-ANGEBOT-O1')
|
|
assert.equal(call.body['subscription_data[metadata][offerId]'], 'o1')
|
|
// Der Webhook liest das Angebot von der Sitzung (activateOfferFromCheckout)
|
|
assert.equal(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 = await post('/api/abo/checkout', orderBody('wartung'), { cookie: customerCookie('c1') })
|
|
assert.equal(res.status, 409)
|
|
assert.match(res.body.error, /bereits/)
|
|
})
|
|
|
|
test('Gekündigtes Abo: canSwitch und canChangeStorage false', async () => {
|
|
aw.seed('subscriptions', { ...hostingDoc, $id: 'sub_h', 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('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: '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)
|
|
})
|
|
|
|
// 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. Zuletzt, weil danach c3 ein Abo hat.
|
|
test('Webhook checkout.session.completed: Ereignis abschluss ab Vertragsschluss, kein Duplikat', async () => {
|
|
const stripeSub = stripeSubscription({ id: 'sub_new', customerId: 'c3', productKey: 'hosting', metadata: { termsAcceptedAt: '2026-09-20T10:00:00.000Z' } })
|
|
st.subscriptions.set('sub_new', stripeSub)
|
|
const event = {
|
|
id: 'evt_1',
|
|
object: 'event',
|
|
type: 'checkout.session.completed',
|
|
created: Math.floor(Date.now() / 1000),
|
|
data: {
|
|
object: {
|
|
id: 'cs_done', object: 'checkout.session', mode: 'subscription', subscription: 'sub_new', customer: 'cus_c3',
|
|
customer_details: { email: 'k3@example.invalid', name: 'Kunde Drei' },
|
|
metadata: { webklarCustomerId: 'c3', productKey: 'hosting' },
|
|
},
|
|
},
|
|
}
|
|
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, 'abschluss-sub_new')
|
|
assert.equal(e.type, 'abschluss')
|
|
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, '2026-09-20T10:00:00.000Z')
|
|
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 clearEvents('c3')
|
|
await deleteDocument(config.collections.subscriptions, 'sub_new').catch(() => {})
|
|
}
|
|
})
|