rubis/apps/api/tests/functional/billing_trial.spec.ts
ordinarthur 59f81879d8
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m39s
Build & Deploy API / build-and-deploy (push) Successful in 2m30s
Build & Deploy Web / build-and-deploy (push) Successful in 1m21s
test(e2e): tests Playwright multi-stack — vrai navigateur, DB isolée, Stripe mocké
Ajoute une couche end-to-end où un Chromium drive la SPA + API ensemble
contre une DB Postgres séparée, avec Stripe entièrement mocké au niveau
API. 6 scénarios couverts (signup + onboarding + 4 sur le billing trial).

Architecture :
  - DB `rubis_test_e2e` séparée, TRUNCATE entre tests (~50 ms reset)
  - Routes test-only `/__test__/*` gated par NODE_ENV=test_e2e
    (reset, install Stripe mock, fire webhook, lire org state, last-org)
  - Stripe mocké via __setStripeForTests — pas d'appel réseau
  - Playwright spawn API + SPA automatiquement (webServer config)
  - CORS étendu à test_e2e pour le cross-origin localhost:5173 → :3333

Scénarios :
  - signup.spec.ts : signup → onboarding 3 étapes → dashboard (assert rubis hero)
  - billing-trial.spec.ts :
      • démarrer essai 14j → redirect Stripe Checkout (mock)
      • fallback Free 2 factures continue l'onboarding
      • webhook checkout.completed → org en trialing + trial_ends_at
      • retour ?trial=cancel après abandon
      • inspection DB : stripeCustomerId posé après start-trial

Scripts :
  - pnpm e2e          (headless)
  - pnpm e2e:headed   (Chromium visible)
  - pnpm e2e:ui       (mode interactif Playwright)
  - pnpm e2e:setup    (crée + migre rubis_test_e2e via docker exec)

Documentation : docs/tech/e2e-tests.md — architecture, scénarios,
extensions, CI, troubleshooting.

Limites assumées :
  - L'UI Stripe Checkout (3DS, formulaire CB) n'est pas testée — externe.
    Pour ça : playbook manuel docs/tech/stripe-trial-e2e-playbook.md.
  - Le rendu du banner "Essai Pro" n'est pas asserté en E2E à cause de
    TanStack Query staleTime — couvert par les tests vitest à la place.

État global du chantier billing : 127 tests japa + 6 Playwright + 11
vitest = couverture multi-niveaux.

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

517 lines
16 KiB
TypeScript
Raw Permalink 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 {
__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)
})
})