import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' import { DateTime } from 'luxon' import Organization from '#models/organization' import { __setStripeForTests, } from '#services/stripe' import { __setTrialRecapEnqueueForTests } from '#services/stripe_billing' import { createTestUser } from '../helpers/auth.js' import { body, type ApiOk } from '../helpers/response.js' import { fakeSubscription } from '../helpers/stripe_mock.js' import type Stripe from 'stripe' /** * Tests E2E HTTP du tunnel billing trial 14 j. * * Couverture : * - POST /api/v1/billing/start-trial → 200 + URL Stripe (mocked) * - POST /api/v1/billing/start-trial → 409 si trial déjà consommé * - GET /api/v1/billing/subscription → reflète inTrial / trialEndsAt * - POST /api/v1/billing/webhook → checkout.completed → org devient * trialing avec trial_ends_at posé * - POST /api/v1/billing/webhook → customer.subscription.updated * (trialing → active) → org passe en active * - POST /api/v1/billing/webhook → trial_will_end → recap enqueué * - POST /api/v1/billing/webhook → invoice.payment_failed → past_due * - POST /api/v1/billing/webhook → subscription.deleted → free * - Idempotence : 2× le même event = même état final * * Stratégie de mocking : le SDK Stripe est injecté via __setStripeForTests, * y compris `webhooks.constructEvent` qui retourne le payload parsé * directement (on bypass la vérif signature — bien testée par Stripe * eux-mêmes, on teste ici le routing applicatif). * * Les jobs BullMQ sont stubés via __setTrialRecapEnqueueForTests pour * que les tests passent sans Redis. */ const WEBHOOK_PATH = '/api/v1/billing/webhook' const TRIAL_PATH = '/api/v1/billing/start-trial' const SUB_PATH = '/api/v1/billing/subscription' /** * Mock minimal du SDK Stripe pour les routes billing. On override toutes * les méthodes appelées par les handlers du chantier trial. */ type StripeMockOpts = { /** Stripe.subscriptions.retrieve(id) → renvoyer ce subscription. */ subscriptionFixture?: Stripe.Subscription /** Forcer customers.create à renvoyer cet id (default: cus_test_). */ customerId?: string /** URL retournée par checkout.sessions.create. */ checkoutUrl?: string /** * Le handler webhook appelle stripe.webhooks.constructEvent(raw, sig, secret). * On retourne directement event en bypassant la vérif signature pour * concentrer les tests sur le routing applicatif. */ passThroughWebhook?: boolean } function installFullStripeMock(opts: StripeMockOpts = {}) { const customerId = opts.customerId ?? `cus_${Math.random().toString(36).slice(2, 10)}` const checkoutUrl = opts.checkoutUrl ?? 'https://checkout.stripe.test/cs_e2e' const mock = { customers: { create: async (params: Record) => ({ id: customerId, email: params.email, metadata: params.metadata, }), }, prices: { list: async () => ({ data: [{ id: 'price_pro_monthly_test' } as unknown as Stripe.Price], }), }, checkout: { sessions: { create: async () => ({ id: 'cs_test', url: checkoutUrl }), }, }, billingPortal: { sessions: { create: async () => ({ url: 'https://billing.stripe.test/portal' }), }, }, subscriptions: { retrieve: async () => opts.subscriptionFixture ?? fakeSubscription({}), }, webhooks: { constructEvent: opts.passThroughWebhook ? (raw: string | Buffer) => JSON.parse(raw.toString()) : () => { throw new Error('webhooks.constructEvent: signature check bypassed in test') }, }, } as unknown as Stripe __setStripeForTests(mock) return { customerId, checkoutUrl } } function uninstallStripeMock() { __setStripeForTests(null) } // --------------------------------------------------------------------------- // E2E : POST /billing/start-trial // --------------------------------------------------------------------------- test.group('Billing E2E — POST /start-trial', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) group.each.teardown(() => uninstallStripeMock()) test('200 → renvoie l\'URL Stripe Checkout (et persist Stripe customerId)', async ({ client, assert, }) => { const { bearer, org } = await createTestUser() const { customerId, checkoutUrl } = installFullStripeMock() const response = await client.post(TRIAL_PATH).headers(bearer).json({}) response.assertStatus(200) const payload = body>(response) assert.equal(payload.data.url, checkoutUrl) // Le customer Stripe a été persisté côté org. await org.refresh() assert.equal(org.stripeCustomerId, customerId) }) test('200 → réutilise le customer existant (idempotence ensureStripeCustomer)', async ({ client, assert, }) => { const { bearer, org } = await createTestUser() org.stripeCustomerId = 'cus_already_there' await org.save() let customersCreateCalls = 0 const mock = { customers: { create: async () => { customersCreateCalls += 1 return { id: 'cus_should_not_be_called' } }, }, prices: { list: async () => ({ data: [{ id: 'price_test' }] }) }, checkout: { sessions: { create: async () => ({ id: 'cs_x', url: 'https://test/cs' }) }, }, } as unknown as Stripe __setStripeForTests(mock) const response = await client.post(TRIAL_PATH).headers(bearer).json({}) response.assertStatus(200) await org.refresh() assert.equal(org.stripeCustomerId, 'cus_already_there') assert.equal(customersCreateCalls, 0, 'customers.create ne doit pas être appelé') }) test('409 → trial déjà consommé (trialEndsAt déjà posé)', async ({ client }) => { const { bearer, org } = await createTestUser() org.trialEndsAt = DateTime.utc().minus({ days: 5 }) org.stripeCustomerId = 'cus_consumed' await org.save() installFullStripeMock({ customerId: 'cus_consumed' }) const response = await client.post(TRIAL_PATH).headers(bearer).json({}) response.assertStatus(409) }) test('409 → trial déjà consommé (stripeSubscriptionId déjà posé)', async ({ client }) => { const { bearer, org } = await createTestUser() org.stripeSubscriptionId = 'sub_existing' org.stripeCustomerId = 'cus_existing' await org.save() installFullStripeMock({ customerId: 'cus_existing' }) const response = await client.post(TRIAL_PATH).headers(bearer).json({}) response.assertStatus(409) }) test('401 → sans Bearer token', async ({ client }) => { installFullStripeMock() const response = await client.post(TRIAL_PATH).json({}) response.assertStatus(401) }) }) // --------------------------------------------------------------------------- // E2E : GET /billing/subscription // --------------------------------------------------------------------------- test.group('Billing E2E — GET /subscription (trial state)', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) test('inTrial=true quand status=trialing + trial_ends_at futur', async ({ client, assert, }) => { const { bearer, org } = await createTestUser() org.subscriptionStatus = 'trialing' org.trialEndsAt = DateTime.utc().plus({ days: 10 }) await org.save() const response = await client.get(SUB_PATH).headers(bearer) response.assertStatus(200) const state = body>(response) assert.isTrue(state.data.inTrial) assert.isNotNull(state.data.trialEndsAt) }) test('inTrial=false quand trial_ends_at passé (garde-fou)', async ({ client, assert }) => { const { bearer, org } = await createTestUser() org.subscriptionStatus = 'trialing' org.trialEndsAt = DateTime.utc().minus({ minutes: 1 }) await org.save() const response = await client.get(SUB_PATH).headers(bearer) response.assertStatus(200) const state = body>(response) assert.isFalse(state.data.inTrial) }) }) // --------------------------------------------------------------------------- // E2E : POST /billing/webhook // --------------------------------------------------------------------------- test.group('Billing E2E — POST /webhook (dispatcher applicatif)', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) group.each.teardown(() => { uninstallStripeMock() __setTrialRecapEnqueueForTests(async () => {}) }) test('checkout.session.completed → org en trialing + trial_ends_at', async ({ client, assert, }) => { const { org } = await createTestUser() const trialEndEpoch = Math.floor(Date.now() / 1000) + 14 * 24 * 3600 installFullStripeMock({ passThroughWebhook: true, subscriptionFixture: fakeSubscription({ id: 'sub_e2e_checkout', customerId: 'cus_e2e_checkout', status: 'trialing', lookupKey: 'rubis_pro_monthly', trialEnd: trialEndEpoch, organizationId: org.id, }), }) const event = { id: 'evt_checkout_1', type: 'checkout.session.completed', data: { object: { id: 'cs_e2e', subscription: 'sub_e2e_checkout', metadata: { organization_id: org.id }, }, }, } const response = await client .post(WEBHOOK_PATH) .header('stripe-signature', 'dummy') .json(event) response.assertStatus(200) await org.refresh() assert.equal(org.plan, 'pro') assert.equal(org.subscriptionStatus, 'trialing') assert.equal(org.trialEndsAt?.toUnixInteger(), trialEndEpoch) assert.equal(org.stripeSubscriptionId, 'sub_e2e_checkout') }) test('customer.subscription.updated (trial → active) → org devient active', async ({ client, assert, }) => { const { org } = await createTestUser() org.plan = 'pro' org.subscriptionStatus = 'trialing' org.stripeCustomerId = 'cus_e2e_upd' org.stripeSubscriptionId = 'sub_e2e_upd' org.trialEndsAt = DateTime.utc().minus({ hours: 1 }) await org.save() installFullStripeMock({ passThroughWebhook: true }) const event = { id: 'evt_upd_1', type: 'customer.subscription.updated', data: { object: fakeSubscription({ id: 'sub_e2e_upd', customerId: 'cus_e2e_upd', status: 'active', lookupKey: 'rubis_pro_monthly', organizationId: org.id, }), }, } const response = await client .post(WEBHOOK_PATH) .header('stripe-signature', 'dummy') .json(event) response.assertStatus(200) await org.refresh() assert.equal(org.subscriptionStatus, 'active') }) test('customer.subscription.trial_will_end → enqueue recap pour cette org', async ({ client, assert, }) => { const enqueueCalls: Array<{ orgId: string; subId: string }> = [] __setTrialRecapEnqueueForTests(async (orgId, subId) => { enqueueCalls.push({ orgId, subId }) }) const { org } = await createTestUser() org.stripeCustomerId = 'cus_e2e_will_end' await org.save() installFullStripeMock({ passThroughWebhook: true }) const event = { id: 'evt_will_end_1', type: 'customer.subscription.trial_will_end', data: { object: fakeSubscription({ id: 'sub_e2e_will_end', customerId: 'cus_e2e_will_end', status: 'trialing', }), }, } const response = await client .post(WEBHOOK_PATH) .header('stripe-signature', 'dummy') .json(event) response.assertStatus(200) assert.lengthOf(enqueueCalls, 1) assert.equal(enqueueCalls[0]?.orgId, org.id) assert.equal(enqueueCalls[0]?.subId, 'sub_e2e_will_end') }) test('invoice.payment_failed → org passe en past_due', async ({ client, assert }) => { const { org } = await createTestUser() org.plan = 'pro' org.subscriptionStatus = 'active' org.stripeCustomerId = 'cus_e2e_failed' await org.save() installFullStripeMock({ passThroughWebhook: true }) const event = { id: 'evt_failed_1', type: 'invoice.payment_failed', data: { object: { id: 'in_1', customer: 'cus_e2e_failed', status: 'open' } }, } const response = await client .post(WEBHOOK_PATH) .header('stripe-signature', 'dummy') .json(event) response.assertStatus(200) await org.refresh() assert.equal(org.subscriptionStatus, 'past_due') assert.equal(org.plan, 'pro', 'le plan reste pro pendant les Stripe smart retries') }) test('customer.subscription.deleted → bascule free + clear stripe', async ({ client, assert, }) => { const { org } = await createTestUser() org.plan = 'pro' org.subscriptionStatus = 'active' org.stripeCustomerId = 'cus_e2e_del' org.stripeSubscriptionId = 'sub_e2e_del' org.billingCycle = 'monthly' org.trialEndsAt = DateTime.utc().minus({ days: 30 }) await org.save() installFullStripeMock({ passThroughWebhook: true }) const event = { id: 'evt_del_1', type: 'customer.subscription.deleted', data: { object: fakeSubscription({ id: 'sub_e2e_del', customerId: 'cus_e2e_del', }), }, } const response = await client .post(WEBHOOK_PATH) .header('stripe-signature', 'dummy') .json(event) response.assertStatus(200) await org.refresh() assert.equal(org.plan, 'free') assert.equal(org.subscriptionStatus, 'canceled') assert.isNull(org.stripeSubscriptionId) assert.isNotNull(org.trialEndsAt, 'trial_ends_at conservé pour l\'historique') }) test('idempotence : 2× le même event = même état final', async ({ client, assert }) => { const { org } = await createTestUser() org.stripeCustomerId = 'cus_e2e_idem' org.subscriptionStatus = 'trialing' org.trialEndsAt = DateTime.utc().plus({ days: 5 }) await org.save() installFullStripeMock({ passThroughWebhook: true }) const event = { id: 'evt_idem_1', type: 'customer.subscription.updated', data: { object: fakeSubscription({ id: 'sub_idem', customerId: 'cus_e2e_idem', status: 'active', lookupKey: 'rubis_pro_monthly', organizationId: org.id, }), }, } const r1 = await client .post(WEBHOOK_PATH) .header('stripe-signature', 'dummy') .json(event) r1.assertStatus(200) await org.refresh() const snapshot1 = { plan: org.plan, status: org.subscriptionStatus, subId: org.stripeSubscriptionId, } const r2 = await client .post(WEBHOOK_PATH) .header('stripe-signature', 'dummy') .json(event) r2.assertStatus(200) await org.refresh() const snapshot2 = { plan: org.plan, status: org.subscriptionStatus, subId: org.stripeSubscriptionId, } assert.deepEqual(snapshot1, snapshot2) }) test('event type inconnu → 200 silencieux (pas de throw, pas de DB write)', async ({ client, assert, }) => { const { org } = await createTestUser() await org.refresh() const beforePlan = org.plan installFullStripeMock({ passThroughWebhook: true }) const event = { id: 'evt_unknown', type: 'customer.tax_id.created', data: { object: {} }, } const response = await client .post(WEBHOOK_PATH) .header('stripe-signature', 'dummy') .json(event) response.assertStatus(200) await org.refresh() assert.equal(org.plan, beforePlan, 'org pas modifiée par event inconnu') }) test('400 si stripe-signature header absent', async ({ client }) => { installFullStripeMock({ passThroughWebhook: true }) const response = await client.post(WEBHOOK_PATH).json({ type: 'whatever' }) response.assertStatus(400) }) }) // --------------------------------------------------------------------------- // E2E : signature validation (bypass désactivé) // --------------------------------------------------------------------------- test.group('Billing E2E — POST /webhook (signature)', (group) => { group.each.teardown(() => uninstallStripeMock()) test('400 si signature invalide (constructEvent throw)', async ({ client }) => { // passThroughWebhook=false → notre constructEvent mocké throw, simulant // ce que Stripe ferait sur une signature corrompue. installFullStripeMock({ passThroughWebhook: false }) const response = await client .post(WEBHOOK_PATH) .header('stripe-signature', 'bad_signature_payload') .json({ type: 'whatever' }) response.assertStatus(400) }) })