Files
Webklar-Kundenbereich/test/support.test.js
T
knsoandClaude Opus 5.5 b45108c1de Funktionen: Stripe-Sitzungs-ID bis 128 Zeichen, keine Rohmeldungen beim Bestellen
Live-Fehler: cs_live-Sitzungs-IDs (~66 Zeichen) passten nicht in das Feld
portalFeatures.stripeSessionId (64), die Bestellung brach nach dem Anlegen
der Stripe-Sitzung ab und der Kunde sah die Appwrite-Meldung.
- Setup-Skript: Feld 128 Zeichen, bestehendes Feld wird per ensureStringSize vergroessert
- Fehler von Appwrite oder unerwartete Fehler: Log + verstaendlicher Hinweis

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:58:44 +00:00

314 lines
19 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'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird
const aw = await startAppwriteMock({ schema: { subscriptions: ['customerId', 'status'] } })
const st = await startFakeStripe()
after(() => Promise.all([aw.close(), st.close()]))
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { createDocument, getDocument, listDocuments, Query } = await import('../server/services/appwriteAdmin.js')
const { getStripe } = await import('../server/services/stripeBilling.js')
test('Appwrite-Nachbau: anlegen, lesen, filtern', async () => {
await createDocument('subscriptions', { customerId: 'c1', status: 'active' }, 'sub_a')
await createDocument('subscriptions', { customerId: 'c2', status: 'active' }, 'sub_c')
assert.equal((await getDocument('subscriptions', 'sub_a')).status, 'active')
const list = await listDocuments('subscriptions', [Query.equal('customerId', 'c1'), Query.limit(5)])
assert.deepEqual(list.map((d) => d.$id), ['sub_a'])
})
test('Appwrite-Nachbau: doppelte ID 409, fehlendes Dokument 404', async () => {
await assert.rejects(createDocument('subscriptions', { customerId: 'c1' }, 'sub_a'), (e) => e.status === 409)
await assert.rejects(getDocument('subscriptions', 'gibt_es_nicht'), (e) => e.status === 404)
})
test('Appwrite-Nachbau: Schema wie Appwrite', async () => {
await assert.rejects(createDocument('subscriptions', { unbekannt: 1 }, 'sub_b'), (e) => e.status === 400)
})
// Appwrite 1.8: Dokument-IDs hoechstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich,
// nicht mit einem Sonderzeichen beginnend - sonst 400 (Task 7, Fix 1)
test('Appwrite-Nachbau: Dokument-ID nach Appwrite-Regeln', async () => {
const tooLong = 'abschluss-sub_1SD6vPRxAbCdEfGhIjKlMnOp' // 38 Zeichen
await assert.rejects(createDocument('subscriptions', { customerId: 'c1' }, tooLong), (e) => e.status === 400 && /documentId/.test(e.message))
await assert.rejects(createDocument('subscriptions', { customerId: 'c1' }, '-abc'), (e) => e.status === 400)
await assert.rejects(createDocument('subscriptions', { customerId: 'c1' }, 'abc/def'), (e) => e.status === 400)
const ok = await createDocument('subscriptions', { customerId: 'c1' }, `abschluss-${'f'.repeat(26)}`)
assert.equal(ok.$id.length, 36)
})
test('Appwrite-Nachbau: failNext liefert einmal den gewuenschten Fehler', async () => {
aw.failNext({ collection: 'subscriptions', method: 'POST', status: 500 })
await assert.rejects(createDocument('subscriptions', { customerId: 'c9' }, 'sub_fail'), (e) => e.status === 500)
assert.equal((await createDocument('subscriptions', { customerId: 'c9' }, 'sub_fail')).$id, 'sub_fail')
// Nur die passende Anfrage scheitert; andere Collections/Methoden laufen durch
aw.failNext({ collection: 'hourCredits', method: 'POST', status: 404 })
assert.equal((await getDocument('subscriptions', 'sub_fail')).$id, 'sub_fail')
await assert.rejects(createDocument('hourCredits', { customerId: 'c9' }, 'hc_1'), (e) => e.status === 404)
// after: so viele passende Anfragen gehen vorher noch durch (Task 8: erst die zweite PATCH scheitert).
// adminFetch bildet jeden 5xx-Status auf status 500 ab.
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 503, after: 1 })
assert.equal((await getDocument('subscriptions', 'sub_fail')).$id, 'sub_fail')
await assert.rejects(getDocument('subscriptions', 'sub_fail'), (e) => e.status === 500 && /503/.test(e.message))
assert.equal((await getDocument('subscriptions', 'sub_fail')).$id, 'sub_fail')
})
test('Stripe-Nachbau: getStripe spricht mit 127.0.0.1', async () => {
st.subscriptions.set('sub_1', { id: 'sub_1', object: 'subscription', items: { object: 'list', data: [] } })
assert.equal((await getStripe().subscriptions.retrieve('sub_1')).id, 'sub_1')
assert.equal(st.calls.at(-1).path, '/v1/subscriptions/sub_1')
})
test('Stripe-Nachbau: unbekannte ID 404', async () => {
await assert.rejects(getStripe().prices.retrieve('price_weg'), (e) => e.statusCode === 404)
})
test('useTestEnv lehnt fremde Hosts ab', () => {
assert.throws(() => useTestEnv({ appwriteUrl: 'https://ticket.webklar.com/v1', stripeUrl: st.url }))
assert.equal(new URL(process.env.APPWRITE_ENDPOINT).hostname, '127.0.0.1')
})
// F17 (Task 8): POST /v1/subscriptions/:id mit Positionen (anlegen per Preis, aendern per ID), Metadaten
// (leerer Wert loescht wie bei Stripe) und einmaligem Zusatz nextSubscriptionUpdate fuer die Antwort
test('Stripe-Nachbau: Abo-Update mit Positionen, Metadaten, einmaliger Zusatz', async () => {
const stripe = getStripe()
st.prices.set('price_a', { id: 'price_a', object: 'price', active: true, lookup_key: 'lookup_a', unit_amount: 100 })
st.subscriptions.set('sub_u', {
id: 'sub_u',
object: 'subscription',
status: 'active',
metadata: { keep: '1', drop: 'x' },
items: { object: 'list', data: [{ id: 'si_1', object: 'subscription_item', quantity: 1, price: { id: 'price_0', object: 'price' } }] },
})
const added = await stripe.subscriptions.update('sub_u', {
items: [{ price: 'price_a', quantity: 2 }],
metadata: { drop: '', neu: 'y' },
proration_behavior: 'none',
})
assert.equal(added.items.data.length, 2)
const item = added.items.data[1]
assert.equal(item.quantity, 2)
assert.equal(item.price.id, 'price_a')
assert.equal(item.price.lookup_key, 'lookup_a')
assert.match(item.id, /^si_/)
assert.deepEqual(added.metadata, { keep: '1', neu: 'y' })
assert.equal(added.status, 'active')
assert.equal(st.calls.at(-1).body['items[0][price]'], 'price_a')
const changed = await stripe.subscriptions.update('sub_u', { items: [{ id: item.id, quantity: 0 }] })
assert.equal(changed.items.data[1].quantity, 0)
assert.equal((await stripe.subscriptions.retrieve('sub_u')).items.data[1].quantity, 0, 'gespeichert')
// Wie Stripe: derselbe Preis nicht zweimal, unbekannter Preis oder Posten 400/404, unbekanntes Abo 404
await assert.rejects(stripe.subscriptions.update('sub_u', { items: [{ price: 'price_a', quantity: 1 }] }), (e) => e.statusCode === 400)
await assert.rejects(stripe.subscriptions.update('sub_u', { items: [{ price: 'price_gibt_es_nicht', quantity: 1 }] }), (e) => e.statusCode === 400)
await assert.rejects(stripe.subscriptions.update('sub_u', { items: [{ id: 'si_gibt_es_nicht', quantity: 1 }] }), (e) => e.statusCode === 400)
await assert.rejects(stripe.subscriptions.update('sub_gibt_es_nicht', { metadata: { a: 'b' } }), (e) => e.statusCode === 404)
// Kuendigungsfelder wie cancelAtPeriodEnd/cancelSubscriptionAt sie setzen
const cancelled = await stripe.subscriptions.update('sub_u', { cancel_at_period_end: true, metadata: { cancelSource: 'test' } })
assert.equal(cancelled.cancel_at_period_end, true)
assert.equal(cancelled.metadata.cancelSource, 'test')
const dated = await stripe.subscriptions.update('sub_u', { cancel_at: 1800000000, proration_behavior: 'none' })
assert.equal(dated.cancel_at, 1800000000)
// nextSubscriptionUpdate: einmalig in die naechste Antwort gemischt, nicht gespeichert
st.nextSubscriptionUpdate = { latest_invoice: { status: 'paid', hosted_invoice_url: 'https://pay.example/inv' } }
const once = await stripe.subscriptions.update('sub_u', { metadata: { a: 'b' } })
assert.equal(once.latest_invoice.hosted_invoice_url, 'https://pay.example/inv')
assert.equal(once.metadata.a, 'b')
assert.equal((await stripe.subscriptions.retrieve('sub_u')).latest_invoice, undefined)
assert.equal((await stripe.subscriptions.update('sub_u', { metadata: { a: 'c' } })).latest_invoice, undefined)
assert.equal(st.nextSubscriptionUpdate, null)
})
// Task 9: mit pending_update im einmaligen Zusatz verhaelt sich der Nachbau wie Stripe bei einer ausstehenden
// Aktualisierung (Zahlung nicht erfolgt): das Abo bleibt unveraendert, die Aenderung wartet, bis ein Test sie
// mit applyPendingUpdate uebernimmt (= Rechnung bezahlt)
test('Stripe-Nachbau: ausstehende Aktualisierung bleibt zurückgehalten, bis sie übernommen wird', async () => {
const stripe = getStripe()
st.prices.set('price_b', { id: 'price_b', object: 'price', active: true, lookup_key: 'lookup_b', unit_amount: 500 })
st.subscriptions.set('sub_p', {
id: 'sub_p',
object: 'subscription',
status: 'active',
metadata: { productKey: 'hosting' },
items: { object: 'list', data: [{ id: 'si_p', object: 'subscription_item', quantity: 1, price: { id: 'price_0', object: 'price' } }] },
})
st.nextSubscriptionUpdate = { pending_update: { expires_at: 1 }, latest_invoice: { status: 'open', hosted_invoice_url: 'https://pay.example/inv' } }
const pending = await stripe.subscriptions.update('sub_p', {
items: [{ id: 'si_p', price: 'price_b' }],
metadata: { productKey: 'wartung' },
payment_behavior: 'pending_if_incomplete',
})
assert.deepEqual(pending.pending_update, { expires_at: 1 })
assert.equal(pending.latest_invoice.hosted_invoice_url, 'https://pay.example/inv')
// Wie Stripe: Antwort und gespeichertes Abo zeigen den alten Stand
assert.equal(pending.metadata.productKey, 'hosting')
assert.equal(pending.items.data[0].price.id, 'price_0')
// Wie Stripe (Fix 1): das Abo traegt pending_update, bis die Aenderung uebernommen oder verworfen wird
const stored = await stripe.subscriptions.retrieve('sub_p')
assert.equal(stored.metadata.productKey, 'hosting')
assert.deepEqual(stored.pending_update, { expires_at: 1 })
// Bezahlt: die zurueckgehaltene Aenderung wird uebernommen
const applied = st.applyPendingUpdate('sub_p')
assert.equal(applied.metadata.productKey, 'wartung')
const after = await stripe.subscriptions.retrieve('sub_p')
assert.equal(after.items.data[0].price.id, 'price_b')
assert.equal(after.pending_update, null)
assert.throws(() => st.applyPendingUpdate('sub_p'), /keine ausstehende/)
// Verfallen: Stripe verwirft die Aenderung, das Abo bleibt beim alten Stand
st.nextSubscriptionUpdate = { pending_update: { expires_at: 2 } }
await stripe.subscriptions.update('sub_p', { metadata: { productKey: 'neu' }, payment_behavior: 'pending_if_incomplete' })
st.expirePendingUpdate('sub_p')
const expired = await stripe.subscriptions.retrieve('sub_p')
assert.equal(expired.metadata.productKey, 'wartung')
assert.equal(expired.pending_update, null)
assert.throws(() => st.applyPendingUpdate('sub_p'), /keine ausstehende/)
})
// Wie Stripe (docs.stripe.com/billing/subscriptions/pending-updates, Stand 28.09.2026): mit
// pending_if_incomplete nur die unterstuetzten Parameter; Metadaten hoechstens 40 Zeichen je Schluessel und
// 500 je Wert
test('Stripe-Nachbau: pending_if_incomplete nur mit unterstützten Parametern, Metadaten mit Stripe-Grenzen', async () => {
const stripe = getStripe()
st.subscriptions.set('sub_q', {
id: 'sub_q',
object: 'subscription',
status: 'active',
metadata: {},
items: { object: 'list', data: [{ id: 'si_q', object: 'subscription_item', quantity: 1, price: { id: 'price_0', object: 'price' } }] },
})
await assert.rejects(
stripe.subscriptions.update('sub_q', { payment_behavior: 'pending_if_incomplete', description: 'nicht erlaubt' }),
(e) => e.statusCode === 400 && /description/.test(e.message)
)
await assert.rejects(
stripe.subscriptions.update('sub_q', { payment_behavior: 'pending_if_incomplete', items: [{ id: 'si_q', metadata: { x: '1' } }] }),
(e) => e.statusCode === 400 && /metadata/.test(e.message)
)
const ok = await stripe.subscriptions.update('sub_q', {
payment_behavior: 'pending_if_incomplete',
items: [{ id: 'si_q', quantity: 2 }],
billing_cycle_anchor: 'now',
proration_behavior: 'create_prorations',
proration_date: 1790000000,
metadata: { a: 'b' },
discounts: [{ coupon: 'X' }],
expand: ['latest_invoice'],
})
assert.equal(ok.items.data[0].quantity, 2)
await assert.rejects(stripe.subscriptions.update('sub_q', { metadata: { a: 'x'.repeat(501) } }), (e) => e.statusCode === 400)
await assert.rejects(stripe.subscriptions.update('sub_q', { metadata: { ['k'.repeat(41)]: '1' } }), (e) => e.statusCode === 400)
assert.equal((await stripe.subscriptions.update('sub_q', { metadata: { a: 'x'.repeat(500) } })).metadata.a.length, 500)
})
// F25: Mails lassen sich in Tests pruefen (nodemailer jsonTransport statt SMTP); null schaltet zurueck
test('Mail-Senke: Kunden- und Team-Mails landen in der Senke, null schaltet zurück', async () => {
const { mailSink } = await import('./support/mailSink.js')
const { sendAdminNotice, sendCustomerMail, setMailTransportForTests } = await import('../server/services/mailer.js')
const sink = mailSink()
setMailTransportForTests(sink)
try {
await sendCustomerMail('k@example.invalid', {
template: 'K10',
values: { name: 'K', antrag_datum: '01.10.2026', antrag_nr: 'A1', begruendung: 'Nachweis fehlt' },
attachments: [{ filename: 'a.pdf', content: Buffer.from('PDF'), contentType: 'application/pdf' }],
})
assert.deepEqual(await sendAdminNotice({ template: 'T06', values: { email: 'k@example.invalid', produkt: 'Hosting-Abo', stripe_abo_id: 'sub_1' } }), { sent: true })
assert.equal(sink.sent.length, 2)
const [customerMail, notice] = sink.sent
assert.deepEqual(customerMail.to.map((t) => t.address), ['k@example.invalid'])
assert.equal(customerMail.from.name, 'WEBklar')
assert.match(customerMail.text, /Nachweis fehlt/)
assert.deepEqual(customerMail.attachments.map((a) => a.filename), ['a.pdf'])
assert.equal(notice.from.name, 'WEBklar Kundenportal')
assert.match(notice.subject, /^\[Abo\]/)
} finally {
setMailTransportForTests(null)
}
await assert.rejects(sendCustomerMail('k@example.invalid', { template: 'K10', values: {} }), /SMTP nicht konfiguriert/)
})
// F17: GET /v1/invoices/:id bettet wie Stripe hoechstens 10 Zeilen ein, GET /v1/invoices/:id/lines blaettert
test('Stripe-Nachbau: Rechnung bettet höchstens 10 Zeilen ein, /lines blättert', async () => {
const stripe = getStripe()
st.invoices.set('in_p', { id: 'in_p', object: 'invoice', lines: { object: 'list', data: Array.from({ length: 12 }, (_, i) => ({ amount: i })) } })
const invoice = await stripe.invoices.retrieve('in_p')
assert.equal(invoice.lines.data.length, 10)
assert.equal(invoice.lines.has_more, true)
assert.ok(invoice.lines.data.every((l) => l.id && l.object === 'line_item'))
const page = await stripe.invoices.listLineItems('in_p', { limit: 5 })
assert.equal(page.data.length, 5)
assert.equal(page.has_more, true)
const all = await stripe.invoices.listLineItems('in_p', { limit: 5 }).autoPagingToArray({ limit: 100 })
assert.deepEqual(all.map((l) => l.amount), [...Array(12).keys()])
const small = await stripe.invoices.retrieve('in_p') // unveraendert seedbar: die Quelle behaelt alle Zeilen
assert.equal(small.lines.data.length, 10)
st.invoices.set('in_q', { id: 'in_q', object: 'invoice', lines: { object: 'list', data: [{ amount: 1 }] } })
assert.equal((await stripe.invoices.retrieve('in_q')).lines.has_more, false)
await assert.rejects(stripe.invoices.listLineItems('in_nix'), (e) => e.statusCode === 404)
})
// F17: POST /v1/invoices/create_preview liefert die vorab hinterlegte Vorschau (Objekt oder Funktion des Aufrufs)
test('Stripe-Nachbau: Rechnungsvorschau aus dem Seed', async () => {
const stripe = getStripe()
const params = { subscription: 'sub_u', subscription_details: { items: [{ id: 'si_1', quantity: 2 }], proration_behavior: 'create_prorations', proration_date: 1 } }
const empty = await stripe.invoices.createPreview(params)
assert.deepEqual(empty.lines.data, [])
st.invoicePreview = { object: 'invoice', lines: { object: 'list', data: [{ amount: 42 }], has_more: false } }
assert.equal((await stripe.invoices.createPreview(params)).lines.data[0].amount, 42)
assert.equal(st.calls.at(-1).body['subscription_details[items][0][quantity]'], '2')
assert.equal(st.calls.at(-1).body['subscription_details[proration_behavior]'], 'create_prorations')
st.invoicePreview = (body) => ({ object: 'invoice', lines: { data: [{ amount: Number(body.subscription_details.items[0].quantity) * 100 }] } })
assert.equal((await stripe.invoices.createPreview(params)).lines.data[0].amount, 200)
await assert.rejects(stripe.invoices.createPreview({ subscription: 'sub_nix' }), (e) => e.statusCode === 404)
st.invoicePreview = null
})
// F41d / Task 8 (Fix 1): Fehler im Stripe-Nachbau einplanen - einmalig, nur fuer den passenden Aufruf
// (method, path als String oder RegExp); after passende Aufrufe gehen vorher durch. Der gescheiterte Aufruf
// steht trotzdem in calls; 4xx wiederholt stripe-node nicht (5xx schon, maxNetworkRetries).
test('Stripe-Nachbau: failNext lässt den nächsten passenden Aufruf scheitern', async () => {
const stripe = getStripe()
st.subscriptions.set('sub_f', { id: 'sub_f', object: 'subscription', status: 'active', metadata: {}, items: { object: 'list', data: [] } })
st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_f', status: 400, message: 'Nachbau: abgelehnt' })
await assert.rejects(stripe.subscriptions.update('sub_f', { metadata: { a: 'b' } }), (e) => e.statusCode === 400 && /abgelehnt/.test(e.message))
assert.equal(st.calls.at(-1).body['metadata[a]'], 'b', 'gescheiterter Aufruf steht in calls')
assert.equal((await stripe.subscriptions.retrieve('sub_f')).metadata.a, undefined, 'gescheiterter Aufruf aendert nichts')
assert.equal((await stripe.subscriptions.update('sub_f', { metadata: { a: 'b' } })).metadata.a, 'b', 'einmalig')
// Nur der passende Pfad (RegExp) und erst nach `after` passenden Aufrufen
st.failNext({ method: 'POST', path: /\/v1\/subscriptions\/sub_f$/, status: 402, after: 1 })
assert.equal((await stripe.subscriptions.retrieve('sub_f')).id, 'sub_f')
assert.equal((await stripe.subscriptions.update('sub_f', { metadata: { a: 'c' } })).metadata.a, 'c')
await assert.rejects(stripe.subscriptions.update('sub_f', { metadata: { a: 'd' } }), (e) => e.statusCode === 402)
assert.equal((await stripe.subscriptions.retrieve('sub_f')).metadata.a, 'c')
})
test('schema_funktionen_news', async () => {
const { schemaKeys } = await import('./support/schema.js')
const features = schemaKeys('portalFeatures')
for (const k of ['customerId', 'projectId', 'featureKey', 'enabled', 'unlockedByPurchase', 'purchaseStatus', 'state', 'siteKey', 'ticketId', 'stripeSessionId', 'stripeInvoiceId', 'invoiceUrl', 'orderedAt', 'activatedAt', 'disabledAt', 'consentText', 'consentAt', 'consentIp', 'consentUserAgent', 'adminNote']) {
assert.ok(features.includes(k), `portalFeatures.${k}`)
}
const news = schemaKeys('newsPosts')
for (const k of ['customerId', 'projectId', 'type', 'title', 'text', 'imageId', 'paper', 'duration', 'visibleFrom', 'visibleUntil', 'takenDownAt', 'createdAt', 'updatedAt']) {
assert.ok(news.includes(k), `newsPosts.${k}`)
}
})
// Stripe-Checkout-IDs im Live-Modus sind ~66 Zeichen lang (cs_live_ + 58); 64 war zu knapp
test('schema_stripe_session_id_lang_genug', async () => {
const { readFileSync } = await import('node:fs')
const src = readFileSync(new URL('../scripts/setup-abo-collections.mjs', import.meta.url), 'utf8')
const block = src.slice(src.indexOf("'portalFeatures',"))
const m = block.match(/key: 'stripeSessionId', size: (\d+)/)
assert.ok(m, 'stripeSessionId im portalFeatures-Block')
assert.ok(Number(m[1]) >= 128, `stripeSessionId size ${m[1]} < 128`)
// bestehendes Feld in Produktion wird per Groessen-Update angehoben
assert.match(src, /ensureStringSize\('portalFeatures', 'stripeSessionId', 128\)/)
})