Abo-API: Nachweis-ID nach Appwrite-Regeln, Webhook bei dauerhaften Fehlern, Tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
7df0b72019
commit
887e1e54a0
10 files changed
+409
-107
No files matched your search
@@ -8,7 +8,7 @@
|
||||
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
|
||||
Empfänger: kenso@webklar.com, andrej@webklar.com
|
||||
Platzhalter: {{email}}, {{kunde_name}}, {{appwrite_id}}, {{stripe_abo_id}}, {{angebot_text}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angebot, angebot_fehler
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angebot, angebot_fehler, nachweis_fehlt
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
@@ -44,6 +44,10 @@ a{color:#111111;}
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Das Angebot konnte nicht automatisch angewendet werden. Bitte in Stripe prüfen und den Rabatt von Hand am Abo hinterlegen.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE angebot_fehler -->
|
||||
|
||||
<!-- WENN nachweis_fehlt -->
|
||||
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Nachweis nicht gespeichert</div><div class="mut" style="color:#4a4843;">Das Vertragsereignis „Abschluss“ (Nachweis der Zustimmung, Beginn der Widerrufsfrist) konnte nicht in Appwrite gespeichert werden. Bitte prüfen, ob die Collection contractEvents mit allen Attributen angelegt ist (scripts/setup-abo-collections.mjs), und den Nachweis zu diesem Abo nachtragen. Vertragsbestätigung und Angebot sind davon nicht betroffen.</div></td></tr></table></td></tr>
|
||||
<!-- ENDE nachweis_fehlt -->
|
||||
|
||||
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;"> </td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
+19
-6
@@ -5,7 +5,7 @@ import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js'
|
||||
import { listContractEvents, openWithdrawals, recordContractEvent } from '../services/contractEvents.js'
|
||||
import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent } from '../services/contractEvents.js'
|
||||
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
|
||||
import { getLedger } from '../services/hourLedger.js'
|
||||
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
|
||||
@@ -324,11 +324,12 @@ async function sendPortalCancellationConfirmation(sub, createdUnix) {
|
||||
* Nachweis des Neuabschlusses als Vertragsereignis. Der Vertrag kommt mit dem Abschluss bei Stripe
|
||||
* zustande, die Widerrufsfrist zaehlt ab dann (details.contractAt = startedAt, F04); die Zustimmung
|
||||
* (Fassung, Zeitpunkt, IP, Browser) kommt aus den Abo-Metadaten, die der Spiegel traegt. Feste
|
||||
* Dokument-ID je Abo: eine Webhook-Wiederholung legt kein zweites Ereignis an (F05).
|
||||
* Dokument-ID je Abo (eventIdFor, 36 Zeichen): eine Webhook-Wiederholung legt kein zweites
|
||||
* Ereignis an (F05).
|
||||
*/
|
||||
async function recordAbschluss(sub, session) {
|
||||
return recordContractEvent({
|
||||
id: `abschluss-${sub.$id}`,
|
||||
id: eventIdFor('abschluss', sub.$id),
|
||||
customerId: sub.customerId,
|
||||
subscriptionId: sub.$id,
|
||||
type: 'abschluss',
|
||||
@@ -357,8 +358,20 @@ async function handleStripeEvent(event) {
|
||||
case 'checkout.session.completed': {
|
||||
if (obj.mode !== 'subscription' || !obj.subscription) return
|
||||
const sub = await syncSubscription(obj.subscription)
|
||||
// Ein Fehler hier (etwa Collection fehlt) gibt 500 -> Stripe wiederholt, das Ereignis wird nachgeholt
|
||||
if (sub) await recordAbschluss(sub, obj)
|
||||
// Nachweis: voruebergehend nicht speicherbar (Appwrite nicht erreichbar, 5xx) -> 500, Stripe
|
||||
// wiederholt und der Nachweis kommt nach. Dauerhaft nicht speicherbar (4xx, etwa Collection
|
||||
// oder Attribut fehlt) -> Angebot, T02 und K01 laufen trotzdem, T02 sagt es dem Team; eine
|
||||
// Wiederholung wuerde nichts aendern, der Kunde soll seine Bestaetigung nicht warten lassen.
|
||||
let proofProblem = false
|
||||
if (sub) {
|
||||
try {
|
||||
await recordAbschluss(sub, obj)
|
||||
} catch (err) {
|
||||
if (!err.status || err.status >= 500) throw err
|
||||
console.error(`[abo] Nachweis des Abschlusses ${obj.subscription} nicht gespeichert (${err.status}):`, err.message)
|
||||
proofProblem = true
|
||||
}
|
||||
}
|
||||
const offerProblem = await activateOfferFromCheckout(obj).then(
|
||||
() => false,
|
||||
(e) => {
|
||||
@@ -375,7 +388,7 @@ async function handleStripeEvent(event) {
|
||||
stripe_abo_id: obj.subscription,
|
||||
angebot_text: obj.metadata?.offerText || '',
|
||||
},
|
||||
blocks: { angebot: Boolean(obj.metadata?.offerText), angebot_fehler: offerProblem },
|
||||
blocks: { angebot: Boolean(obj.metadata?.offerText), angebot_fehler: offerProblem, nachweis_fehlt: proofProblem },
|
||||
})
|
||||
await sendContractConfirmation(sub, obj.customer_details?.name).catch((e) =>
|
||||
console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { config } from '../config.js'
|
||||
import { createDocument, getDocument, ID, listDocuments, Query, updateDocument } from './appwriteAdmin.js'
|
||||
import { withdrawalDeadline } from './deadlines.js'
|
||||
@@ -36,12 +37,36 @@ function isWithdrawable(event) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Appwrite-Dokument-IDs: hoechstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich,
|
||||
// nicht mit einem Sonderzeichen beginnend (Appwrite 1.8, Validator UID)
|
||||
const DOCUMENT_ID_MAX = 36
|
||||
const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
|
||||
|
||||
export function isValidDocumentId(id) {
|
||||
return typeof id === 'string' && DOCUMENT_ID.test(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Feste Ereignis-ID (F05): "<typ>-" + sha256 des Schluessels als Hex, insgesamt genau 36 Zeichen
|
||||
* (bei abschluss 26 Hex-Zeichen). Gleicher Schluessel -> gleiche ID, ein Stripe-Webhook, der zweimal
|
||||
* ankommt, legt kein zweites Dokument an. key ist die Stripe-Abo-ID (abschluss: eines je Abo) oder
|
||||
* Abo-ID plus Rechnungs-/Zeitstempel, wenn es mehrere Ereignisse eines Typs je Abo gibt. Die Abo-ID
|
||||
* selbst darf nicht in die ID: "abschluss-sub_..." (sub_ + 24 Zeichen) waeren 38 Zeichen.
|
||||
*/
|
||||
export function eventIdFor(type, key) {
|
||||
if (!/^[a-z]+$/.test(String(type))) throw new Error(`eventIdFor: ungueltiger Typ "${type}"`)
|
||||
if (!key) throw new Error('eventIdFor: Schluessel fehlt')
|
||||
const hashLength = DOCUMENT_ID_MAX - type.length - 1
|
||||
return `${type}-${createHash('sha256').update(String(key)).digest('hex').slice(0, hashLength)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Ereignis anlegen (Bestellung/Aenderung). consent hat die Form { termsVersion, consentAt,
|
||||
* consentIp, consentUserAgent, earlyStartConsentAt? } oder ist null (Speicher-Minus, siehe oben).
|
||||
* details wird als JSON gespeichert. Mit id: erneutes Speichern derselben id ist kein Fehler
|
||||
* (Appwrite meldet 409), sondern liefert das schon vorhandene Dokument zurueck (z. B. wenn ein
|
||||
* Stripe-Webhook zweimal ankommt).
|
||||
* details wird als JSON gespeichert. Mit id (aus eventIdFor): erneutes Speichern derselben id ist
|
||||
* kein Fehler (Appwrite meldet 409), sondern liefert das schon vorhandene Dokument zurueck (z. B.
|
||||
* wenn ein Stripe-Webhook zweimal ankommt). Eine id, die Appwrite nicht annimmt, wird vorher mit
|
||||
* Status 400 abgelehnt statt erst als unklarer Appwrite-Fehler aufzufallen.
|
||||
*/
|
||||
export async function recordContractEvent({
|
||||
customerId,
|
||||
@@ -54,6 +79,13 @@ export async function recordContractEvent({
|
||||
dueAt = null,
|
||||
id = null,
|
||||
}) {
|
||||
if (id !== null && !isValidDocumentId(id)) {
|
||||
const err = new Error(
|
||||
`Ungueltige Ereignis-ID "${id}": Appwrite erlaubt hoechstens ${DOCUMENT_ID_MAX} Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, nicht mit Sonderzeichen beginnend (eventIdFor benutzen)`
|
||||
)
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
const documentId = id || ID.unique()
|
||||
const data = {
|
||||
customerId,
|
||||
|
||||
+194
-61
@@ -16,13 +16,17 @@ const aw = await startAppwriteMock({
|
||||
})
|
||||
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, seedFixtures, stripeSubscription } = await import('./support/fixtures.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 } = await import('../server/services/contractEvents.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()]))
|
||||
@@ -30,6 +34,7 @@ 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,
|
||||
@@ -38,6 +43,29 @@ const orderBody = (productKey, extra = {}) => ({
|
||||
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)
|
||||
}
|
||||
@@ -63,12 +91,13 @@ test('GET /api/abo: Katalog, Hosting-Abo mit Speicher, kein Stundenkonto', async
|
||||
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', { ...wartungDoc, $id: 'sub_h', customerId: 'c1', termsVersion: '2026-09-25-v3' })
|
||||
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')
|
||||
@@ -79,7 +108,7 @@ test('GET /api/abo: Wartung v3 → Link auf Fassung 3, Stundenkonto da', async (
|
||||
})
|
||||
|
||||
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' })
|
||||
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)
|
||||
@@ -93,18 +122,16 @@ test('Ohne Session 401, Admin-Session ist keine Kunden-Session', async () => {
|
||||
})
|
||||
|
||||
test('Checkout Hosting: Preis, Beschreibung, Hinweistext', async () => {
|
||||
const res = await post('/api/abo/checkout', orderBody('hosting'), { cookie: customerCookie('c3') })
|
||||
const { res, call } = await checkout(orderBody('hosting'))
|
||||
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.match(message(call), /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(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]'])
|
||||
@@ -116,43 +143,67 @@ test('Checkout Hosting: Preis, Beschreibung, Hinweistext', async () => {
|
||||
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') })
|
||||
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))
|
||||
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)
|
||||
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 () => {
|
||||
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)
|
||||
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', {
|
||||
$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: '',
|
||||
})
|
||||
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 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')
|
||||
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(call.body['metadata[offerId]'], 'o1')
|
||||
assert.equal(wartung.call.body['metadata[offerId]'], 'o1')
|
||||
} finally {
|
||||
await deleteDocument(config.collections.aboOffers, 'o1')
|
||||
}
|
||||
@@ -160,18 +211,40 @@ test('Checkout Hosting mit Angebot → 409; das Angebot gilt fuer die Wartung',
|
||||
|
||||
test('Checkout mit laufendem Abo → 409', async () => {
|
||||
aw.seed('subscriptions', hostingDoc)
|
||||
const res = await post('/api/abo/checkout', orderBody('wartung'), { cookie: customerCookie('c1') })
|
||||
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, $id: 'sub_h', cancelAtPeriodEnd: true, cancelAt: '2027-10-05T10:00:00.000Z' })
|
||||
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)
|
||||
@@ -241,7 +314,7 @@ test('Altbestand ohne Ereignis abschluss: Widerrufsfrist ab startedAt', async ()
|
||||
|
||||
// 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',
|
||||
id: eventIdFor('abschluss', 'sub_h'), customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'hosting',
|
||||
details: { contractAt: daysAgo(40) }, consent: { ...consent, consentAt: daysAgo(40) },
|
||||
})
|
||||
try {
|
||||
@@ -261,40 +334,59 @@ test('minTermEnd rechnet ab termStartAt, sonst ab trialEnd bzw. startedAt', asyn
|
||||
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' },
|
||||
},
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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, 'abschluss-sub_new')
|
||||
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, '2026-09-20T10:00:00.000Z')
|
||||
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')
|
||||
assert.equal(aw.docs('subscriptions').get(SUB_NEW).productKey, 'hosting')
|
||||
|
||||
const again = await postWebhook(event)
|
||||
assert.equal(again.status, 200)
|
||||
@@ -303,7 +395,48 @@ test('Webhook checkout.session.completed: Ereignis abschluss ab Vertragsschluss,
|
||||
// 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(() => {})
|
||||
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')
|
||||
}
|
||||
})
|
||||
@@ -18,6 +18,7 @@ const {
|
||||
listOpenStorageTasks,
|
||||
completeContractEvent,
|
||||
updateContractEvent,
|
||||
eventIdFor,
|
||||
} = await import('../server/services/contractEvents.js')
|
||||
|
||||
test('Ereignis speichern und lesen (details als JSON)', async () => {
|
||||
@@ -163,3 +164,34 @@ test('Speicher-Verringerung ohne Zustimmung (consent: null) speichert keine Zust
|
||||
assert.equal(doc.consentUserAgent ?? null, null)
|
||||
assert.equal(doc.termsVersion ?? null, null)
|
||||
})
|
||||
|
||||
// F05 (Fix 1): feste Ereignis-IDs muessen Appwrites Regeln fuer Dokument-IDs einhalten (hoechstens
|
||||
// 36 Zeichen) - 'abschluss-' + eine echte Stripe-Abo-ID (sub_ + 24) waeren 38. Deshalb
|
||||
// '<typ>-' + sha256 des Schluessels, auf 36 Zeichen gekuerzt; gleicher Schluessel -> gleiche ID.
|
||||
test('eventIdFor: 36 Zeichen nach Appwrite-Regeln, deterministisch', () => {
|
||||
const subId = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp'
|
||||
const id = eventIdFor('abschluss', subId)
|
||||
assert.equal(id.length, 36)
|
||||
assert.match(id, /^abschluss-[0-9a-f]{26}$/)
|
||||
assert.equal(id, eventIdFor('abschluss', subId))
|
||||
assert.notEqual(id, eventIdFor('abschluss', 'sub_1SD6vPRxAbCdEfGhIjKlMnOq'))
|
||||
// Laengerer Typ -> kuerzerer Hash, immer 36 Zeichen
|
||||
assert.equal(eventIdFor('kuendigung', subId).length, 36)
|
||||
assert.match(eventIdFor('kuendigung', subId), /^kuendigung-[0-9a-f]{25}$/)
|
||||
assert.throws(() => eventIdFor('abschluss', ''), /Schl/)
|
||||
assert.throws(() => eventIdFor('Abschluss!', subId), /Typ/)
|
||||
})
|
||||
|
||||
test('recordContractEvent lehnt IDs ab, die Appwrite nicht annimmt', async () => {
|
||||
const base = { customerId: 'c6', subscriptionId: 'sub_6', type: 'abschluss', productKey: 'hosting' }
|
||||
await assert.rejects(
|
||||
recordContractEvent({ ...base, id: 'abschluss-sub_1SD6vPRxAbCdEfGhIjKlMnOp' }),
|
||||
(e) => e.status === 400 && /36/.test(e.message)
|
||||
)
|
||||
await assert.rejects(recordContractEvent({ ...base, id: '-abschluss' }), (e) => e.status === 400)
|
||||
await assert.rejects(recordContractEvent({ ...base, id: 'abschluss/1' }), (e) => e.status === 400)
|
||||
assert.equal((await listContractEvents('c6')).length, 0)
|
||||
// Gueltige feste ID (aus eventIdFor) wird gespeichert
|
||||
const ok = await recordContractEvent({ ...base, id: eventIdFor('abschluss', 'sub_1SD6vPRxAbCdEfGhIjKlMnOp') })
|
||||
assert.equal(ok.$id.length, 36)
|
||||
})
|
||||
@@ -137,3 +137,15 @@ test('K09: Gutschrift wird mit dem Abo verrechnet, auch nach einem Wechsel', ()
|
||||
assert.match(mail.text, /Eine Auszahlung ist nicht möglich\. Wechseln Sie in ein anderes Abo, verrechnen wir die Gutschrift weiter; endet Ihr Abo ohne einen solchen Wechsel, entfällt ein noch nicht verrechneter Rest\./)
|
||||
assert.doesNotMatch(mail.html, /Wartungs-Abo|Vertragsende/)
|
||||
})
|
||||
|
||||
// Task 7 (Fix 1): konnte das Vertragsereignis "abschluss" dauerhaft nicht gespeichert werden (4xx bei
|
||||
// Appwrite), erfaehrt das Team das im T02-Hinweis statt in einem stillen Webhook-Fehler
|
||||
test('T02: Block nachweis_fehlt weist auf den fehlenden Nachweis hin', () => {
|
||||
const tpl = loadTemplate('T02')
|
||||
const values = sampleValues(tpl)
|
||||
const withBlock = renderMail('T02', values, { nachweis_fehlt: true })
|
||||
assert.deepEqual(withBlock.problems, [])
|
||||
assert.match(withBlock.text, /Nachweis nicht gespeichert/)
|
||||
assert.match(withBlock.text, /contractEvents/)
|
||||
assert.doesNotMatch(renderMail('T02', values, {}).text, /Nachweis nicht gespeichert/)
|
||||
})
|
||||
@@ -29,6 +29,27 @@ 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)
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
@@ -3,11 +3,26 @@ import http from 'node:http'
|
||||
/**
|
||||
* Appwrite-Nachbau für Tests: die REST-Pfade, die server/services/appwriteAdmin.js benutzt, im
|
||||
* Speicher. Mit schema = { collection: ['attr', ...] } lehnt er unbekannte Attribute ab wie
|
||||
* Appwrite ("Invalid document structure: Unknown attribute").
|
||||
* Appwrite ("Invalid document structure: Unknown attribute"). Dokument-IDs prüft er wie Appwrite
|
||||
* (höchstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, kein Sonderzeichen am
|
||||
* Anfang). failNext({ collection?, method?, status, message? }) lässt die nächste passende Anfrage
|
||||
* mit diesem Status scheitern (Fehlerpfade wie "Appwrite nicht erreichbar" oder "Collection fehlt").
|
||||
*/
|
||||
export async function startAppwriteMock({ schema = {} } = {}) {
|
||||
const collections = new Map()
|
||||
let sequence = 0
|
||||
let pendingFailure = null
|
||||
|
||||
// Wie Appwrite (Validator UID): 'unique()' ist erlaubt, sonst [A-Za-z0-9][A-Za-z0-9._-]{0,35}
|
||||
const validDocumentId = (id) => id === 'unique()' || (typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(id))
|
||||
const invalidDocumentId = [
|
||||
400,
|
||||
{
|
||||
message: 'Invalid `documentId` param: UID must contain at most 36 chars. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char',
|
||||
code: 400,
|
||||
type: 'general_argument_invalid',
|
||||
},
|
||||
]
|
||||
|
||||
const docs = (collection) => {
|
||||
if (!collections.has(collection)) collections.set(collection, new Map())
|
||||
@@ -83,9 +98,21 @@ export async function startAppwriteMock({ schema = {} } = {}) {
|
||||
const store = docs(collection)
|
||||
const notFound = [404, { message: 'Document with the requested ID could not be found.', code: 404, type: 'document_not_found' }]
|
||||
|
||||
// Eingeplanter Fehler (failNext): genau eine passende Anfrage scheitert
|
||||
if (
|
||||
pendingFailure &&
|
||||
(!pendingFailure.collection || pendingFailure.collection === collection) &&
|
||||
(!pendingFailure.method || pendingFailure.method === req.method)
|
||||
) {
|
||||
const { status, message } = pendingFailure
|
||||
pendingFailure = null
|
||||
return [status, { message: message || `Nachbau: eingeplanter Fehler ${status}`, code: status, type: 'general_server_error' }]
|
||||
}
|
||||
|
||||
if (!id && req.method === 'GET') return list(collection, url.searchParams.getAll('queries[]'))
|
||||
if (!id && req.method === 'POST') {
|
||||
const { documentId, data = {} } = await readBody(req)
|
||||
if (!validDocumentId(documentId)) return invalidDocumentId
|
||||
if (store.has(documentId)) {
|
||||
return [409, { message: 'Document with the requested ID already exists.', code: 409, type: 'document_already_exists' }]
|
||||
}
|
||||
@@ -128,6 +155,10 @@ export async function startAppwriteMock({ schema = {} } = {}) {
|
||||
url: `http://127.0.0.1:${server.address().port}/v1`,
|
||||
docs: (collection) => new Map([...docs(collection)].map(([id, doc]) => [id, publicDoc(doc)])),
|
||||
seed,
|
||||
/** Naechste passende Dokument-Anfrage scheitert mit status (einmalig). */
|
||||
failNext: ({ collection = null, method = null, status = 500, message = '' }) => {
|
||||
pendingFailure = { collection, method, status, message }
|
||||
},
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
}
|
||||
}
|
||||
+41
-20
@@ -1,11 +1,12 @@
|
||||
// testApp.js zuerst: es prueft beim Laden, dass useTestEnv() gelaufen ist, bevor config.js die Umgebung liest
|
||||
// testApp.js zuerst: es prueft beim Laden, dass useTestEnv() vor config.js gelaufen ist
|
||||
import { sessionCookie } from './testApp.js'
|
||||
import { config } from '../../server/config.js'
|
||||
|
||||
/**
|
||||
* Feste Testdaten für die Routen-Tests (Aufgaben 7–13): Kunden c1–c3, Abo-Dokumente, Zustimmung,
|
||||
* Feste Testdaten für die Routen-Tests (Aufgaben 7–13): Kunden c1–c4, Abo-Dokumente, Zustimmung,
|
||||
* Session-Cookies. seedFixtures(aw) legt Kunden und Abos in den Appwrite-Nachbau; c3 hat kein Abo
|
||||
* (Neuabschluss-Tests). Erst nach useTestEnv() dynamisch importieren (siehe testApp.js).
|
||||
* (Neuabschluss-Tests), c4 ist ein Testkonto (ABO_TEST_EMAILS=test@example.invalid im Test setzen).
|
||||
* Erst nach useTestEnv() dynamisch importieren (siehe testApp.js).
|
||||
*/
|
||||
|
||||
const DAY_MS = 86400000
|
||||
@@ -15,18 +16,20 @@ export const customers = {
|
||||
c1: { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true },
|
||||
c2: { $id: 'c2', appwriteUserId: 'u2', email: 'k2@example.invalid', name: 'Kunde Zwei', status: 'active', portalAccessEnabled: true },
|
||||
c3: { $id: 'c3', appwriteUserId: 'u3', email: 'k3@example.invalid', name: 'Kunde Drei', status: 'active', portalAccessEnabled: true },
|
||||
c4: { $id: 'c4', appwriteUserId: 'u4', email: 'test@example.invalid', name: 'Testkonto', status: 'active', portalAccessEnabled: true },
|
||||
}
|
||||
|
||||
/** Abo-Dokument mit id als Dokument-ID und Stripe-Abo-ID (in Produktion immer gleich). */
|
||||
export const subscriptionDoc = (id, overrides = {}) => ({ ...overrides, $id: id, stripeSubscriptionId: id })
|
||||
|
||||
// Abo seit 40 Tagen (Widerrufsfrist des Abschlusses vorbei), naechste Abbuchung in 20 Tagen
|
||||
const startedAt = daysFromNow(-40)
|
||||
|
||||
/** Hosting-Abo sub_h von c1: aktiv, Fassung 4, ohne Zusatzspeicher. */
|
||||
export const hostingDoc = {
|
||||
$id: 'sub_h',
|
||||
export const hostingDoc = subscriptionDoc('sub_h', {
|
||||
customerId: 'c1',
|
||||
email: 'k@example.invalid',
|
||||
stripeCustomerId: 'cus_c1',
|
||||
stripeSubscriptionId: 'sub_h',
|
||||
status: 'active',
|
||||
productKey: 'hosting',
|
||||
planKey: config.products.hosting.lookupKey,
|
||||
@@ -49,27 +52,23 @@ export const hostingDoc = {
|
||||
storageUnits: 0,
|
||||
storagePaidUnits: 0,
|
||||
storagePaidPeriodEnd: null,
|
||||
}
|
||||
})
|
||||
|
||||
/** Wartungs-Abo sub_w von c1 (im Test meist als sub_h eingespielt, damit c1 nur ein Abo hat). */
|
||||
export const wartungDoc = {
|
||||
/** Wartungs-Abo sub_w von c1 (im Test meist per subscriptionDoc('sub_h', { ...wartungDoc }) eingespielt). */
|
||||
export const wartungDoc = subscriptionDoc('sub_w', {
|
||||
...hostingDoc,
|
||||
$id: 'sub_w',
|
||||
stripeSubscriptionId: 'sub_w',
|
||||
productKey: 'wartung',
|
||||
planKey: config.products.wartung.lookupKey,
|
||||
priceAmount: config.products.wartung.amountCents,
|
||||
}
|
||||
})
|
||||
|
||||
/** Zweites Hosting-Abo sub_h2 von c2. */
|
||||
export const hosting2Doc = {
|
||||
export const hosting2Doc = subscriptionDoc('sub_h2', {
|
||||
...hostingDoc,
|
||||
$id: 'sub_h2',
|
||||
customerId: 'c2',
|
||||
email: 'k2@example.invalid',
|
||||
stripeCustomerId: 'cus_c2',
|
||||
stripeSubscriptionId: 'sub_h2',
|
||||
}
|
||||
})
|
||||
|
||||
const consentAt = new Date().toISOString()
|
||||
|
||||
@@ -90,7 +89,21 @@ export const customerCookie = (customerId = 'c1') =>
|
||||
export const adminCookie = () =>
|
||||
sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso', email: 'kenso@webklar.com' })
|
||||
|
||||
/** Kunden c1–c3 und die Abos sub_h (c1) und sub_h2 (c2) in den Appwrite-Nachbau legen. */
|
||||
/** Admin im Modus "Als Kunde ansehen" (portalAdmin.js /view-as): Bestellen und Aendern sind gesperrt. */
|
||||
export const viewAsCookie = (customerId = 'c1') =>
|
||||
sessionCookie({
|
||||
role: 'admin',
|
||||
viewAs: true,
|
||||
isAdmin: true,
|
||||
appwriteUserId: 'u_admin',
|
||||
adminName: 'Kenso',
|
||||
adminEmail: 'kenso@webklar.com',
|
||||
customerId,
|
||||
name: customers[customerId]?.name || '',
|
||||
email: customers[customerId]?.email || '',
|
||||
})
|
||||
|
||||
/** Kunden c1–c4 und die Abos sub_h (c1) und sub_h2 (c2) in den Appwrite-Nachbau legen. */
|
||||
export function seedFixtures(aw) {
|
||||
for (const customer of Object.values(customers)) aw.seed('customers', customer)
|
||||
aw.seed('subscriptions', hostingDoc)
|
||||
@@ -101,10 +114,18 @@ const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000)
|
||||
|
||||
/**
|
||||
* Stripe-Abo in der Form der SDK-API-Version für den Stripe-Nachbau: Abo-Posten je Produkt,
|
||||
* dazu ein Zusatzspeicher-Posten, wenn storageUnits > 0. metadata ergaenzt/ueberschreibt die
|
||||
* Zustimmungsdaten, rest alles Weitere (status, cancel_at, ...).
|
||||
* dazu ein Zusatzspeicher-Posten, wenn storageUnits > 0. Die Standard-ID ist 28 Zeichen lang wie
|
||||
* bei Stripe (sub_ + 24); fuer den Spiegel von sub_h explizit id: 'sub_h' uebergeben. metadata
|
||||
* ergaenzt/ueberschreibt die Zustimmungsdaten, rest alles Weitere (status, cancel_at, ...).
|
||||
*/
|
||||
export function stripeSubscription({ id = 'sub_h', customerId = 'c1', productKey = 'hosting', storageUnits = 0, metadata = {}, ...rest } = {}) {
|
||||
export function stripeSubscription({
|
||||
id = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp',
|
||||
customerId = 'c1',
|
||||
productKey = 'hosting',
|
||||
storageUnits = 0,
|
||||
metadata = {},
|
||||
...rest
|
||||
} = {}) {
|
||||
const product = config.products[productKey]
|
||||
const items = [
|
||||
{
|
||||
|
||||
+18
-15
@@ -1,44 +1,47 @@
|
||||
import http from 'node:http'
|
||||
import express from 'express'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import signature from 'cookie-signature'
|
||||
import Stripe from 'stripe'
|
||||
import { config } from '../../server/config.js'
|
||||
import { sessionMiddleware } from '../../server/middleware/session.js'
|
||||
|
||||
/**
|
||||
* Express-App für Routen-Tests: wie server/index.js mit cookieParser(SESSION_SECRET) und
|
||||
* express.json samt rawBody (Stripe-Webhook), aber nur mit den Routern, die der Test übergibt:
|
||||
* Express-App für Routen-Tests: wie server/index.js mit sessionMiddleware() (cookieParser mit
|
||||
* SESSION_SECRET) und express.json samt rawBody (Stripe-Webhook), aber nur mit den Routern, die der
|
||||
* Test übergibt:
|
||||
* const app = await startTestApp({ '/api/abo': aboRoutes, '/api/portal-admin': portalAdminRoutes })
|
||||
*
|
||||
* Reihenfolge im Test: Nachbauten starten, useTestEnv(), dann dieses Modul und die Server-Module
|
||||
* dynamisch importieren (assertTestEnv() weigert sich sonst, weil config.js die Umgebung beim
|
||||
* ersten Import liest). Die Anfrage-Helfer sprechen die zuletzt gestartete App an.
|
||||
* dynamisch importieren. config.js liest die Umgebung beim ersten Import; zeigt sie nicht auf die
|
||||
* Nachbauten, weigert sich dieses Modul beim Laden. Die Anfrage-Helfer sprechen die zuletzt
|
||||
* gestartete App an.
|
||||
*/
|
||||
|
||||
function assertTestEnv() {
|
||||
for (const name of ['APPWRITE_ENDPOINT', 'STRIPE_API_BASE']) {
|
||||
for (const [name, value] of [
|
||||
['APPWRITE_ENDPOINT', config.appwrite.endpoint],
|
||||
['STRIPE_API_BASE', config.stripe.apiBase],
|
||||
]) {
|
||||
let host = null
|
||||
try {
|
||||
host = new URL(process.env[name] || '').hostname
|
||||
host = new URL(value || '').hostname
|
||||
} catch {
|
||||
host = null
|
||||
}
|
||||
if (host !== '127.0.0.1') {
|
||||
throw new Error(`testApp: ${name} zeigt nicht auf 127.0.0.1 – useTestEnv() vor dem Import aufrufen`)
|
||||
throw new Error(`testApp: ${name} zeigt nicht auf 127.0.0.1 – useTestEnv() muss vor dem ersten Import von config.js laufen`)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTestEnv()
|
||||
|
||||
const cookieName = () => process.env.SESSION_COOKIE_NAME || 'webklar_portal_session'
|
||||
const sessionSecret = () => process.env.SESSION_SECRET || ''
|
||||
|
||||
/**
|
||||
* Signierter Session-Cookie wie setPortalSession (cookie-parser: Praefix "s:" + cookie-signature).
|
||||
* Kunde: { customerId, appwriteUserId }. Admin: { role: 'admin', appwriteUserId, name, email }.
|
||||
*/
|
||||
export function sessionCookie(session) {
|
||||
const value = `s:${signature.sign(JSON.stringify(session), sessionSecret())}`
|
||||
return `${cookieName()}=${encodeURIComponent(value)}`
|
||||
const value = `s:${signature.sign(JSON.stringify(session), config.sessionSecret)}`
|
||||
return `${config.cookieName}=${encodeURIComponent(value)}`
|
||||
}
|
||||
|
||||
let baseUrl = null
|
||||
@@ -47,7 +50,7 @@ let baseUrl = null
|
||||
export async function startTestApp(routers = {}) {
|
||||
const app = express()
|
||||
app.set('trust proxy', 1)
|
||||
app.use(cookieParser(sessionSecret()))
|
||||
app.use(sessionMiddleware())
|
||||
app.use(
|
||||
express.json({
|
||||
limit: '2mb',
|
||||
@@ -110,7 +113,7 @@ export const post = (path, body, opts = {}) => request('POST', path, { ...opts,
|
||||
const webhookSigner = new Stripe('sk_test_fake')
|
||||
|
||||
/** Stripe-Ereignis signiert an den Webhook schicken (F25) -> { status, body }. */
|
||||
export function postWebhook(event, { path = '/api/abo/webhook', secret = process.env.STRIPE_WEBHOOK_SECRET || 'whsec_test' } = {}) {
|
||||
export function postWebhook(event, { path = '/api/abo/webhook', secret = config.stripe.webhookSecret || 'whsec_test' } = {}) {
|
||||
const payload = JSON.stringify(event)
|
||||
const stripeSignature = webhookSigner.webhooks.generateTestHeaderString({ payload, secret })
|
||||
return request('POST', path, { body: payload, headers: { 'stripe-signature': stripeSignature } })
|
||||
|
||||
Reference in new issue
Block a user