All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m18s
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>
518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
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)
|
||
})
|
||
})
|