rubis/apps/api/tests/functional/billing_trial.spec.ts
ordinarthur 094c26059f
All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m18s
test(billing): tests E2E HTTP du tunnel essai 14 j + playbook Stripe test mode
Ajoute 16 tests E2E qui hit les vraies routes `/api/v1/billing/*` à
travers le middleware auth, les validators et la persistance DB.
Complémentaire des 60 tests unitaires sur les services.

Suites couvertes :
  - POST /start-trial : 200 happy path, customer Stripe réutilisé,
    409 trial déjà consommé (2 garde-fous), 401 sans Bearer
  - GET  /subscription : expose inTrial + trialEndsAt, garde-fou
    trial_ends_at passé
  - POST /webhook : checkout.completed, subscription.updated trialing→active,
    trial_will_end → enqueue recap (avec spy), payment_failed → past_due,
    subscription.deleted → free + trial_ends_at conservé
  - Idempotence : 2× le même event = même état final
  - Event type inconnu → 200 silencieux (pas de DB write)
  - 400 si stripe-signature absent / signature invalide

Helpers de test :
  - installFullStripeMock(opts) → mock complet : customers, prices,
    checkout, billingPortal, subscriptions, webhooks. Avec
    passThroughWebhook qui bypass la vérif signature pour tester
    le routing applicatif sans signer manuellement chaque payload.

env.test : STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET dummy +
WEB_URL/LANDING_URL.

Documentation : docs/tech/stripe-trial-e2e-playbook.md — playbook
manuel pour valider en mode Stripe test (5 scénarios : happy path
3DS, carte refusée au prélèvement, annulation Customer Portal,
re-trial bloqué, fallback Free). Utilise Stripe Test Clocks pour
fast-forward sans attendre 14 jours réels.

Total après ce commit : 76 tests sur la chaîne billing (60 unit + 16 E2E).
Les cas Stripe-side (3DS UI réel, prélèvement effectif J+14) restent
à valider manuellement via le playbook avant le go-live.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:01:16 +02:00

518 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_<random>). */
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<string, unknown>) => ({
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<ApiOk<{ url: string }>>(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<ApiOk<{ inTrial: boolean; trialEndsAt: string | null }>>(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<ApiOk<{ inTrial: boolean }>>(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)
})
})