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\)/) })