test(api): setup Japa + tests fonctionnels auth (signup/login/logout/onboarding)

Setup :
- .env.test étoffé : DRIVE_DISK=fs, MAIL_DRIVER=smtp local, OCR_PROVIDER=mock. Réutilise la DB rubis_dev avec global transactions par test (rollback auto, isolation parfaite).
- Schedulers (relance + checkin) détectent NODE_ENV=test et skippent BullMQ.add. Les tasks DB sont quand même créées (utiles pour assertions) mais aucun job orphelin n'arrive en Redis après rollback de tx.
- helpers/auth.ts : factory createTestUser() qui crée org + user + 4 plans pré-fournis dans une tx, retourne user/org/accessToken/bearer header. createTwoOrgs() pour les tests cross-org à venir.

Tests fonctionnels auth (tests/functional/auth.spec.ts) :
- Signup : crée user + org + 4 plans pré-fournis (vérifie les slugs), refuse email mal formé / password court / email déjà pris
- Login : émet AuthSession avec credentials valides, rejette mauvais password / email inconnu
- Bearer auth : 401 sans token, 401 avec token bidon, 200 avec token valide
- Logout : révoque le token courant, requêtes suivantes en 401
- Onboarding : PATCH /organizations/me pose onboardingCompletedAt à la 1re mise du nom, idempotent ensuite

Pour lancer : `pnpm -F api test`
This commit is contained in:
ordinarthur 2026-05-06 15:45:11 +02:00
parent 01f3edcf08
commit fc66d80f56
5 changed files with 305 additions and 34 deletions

View File

@ -1 +1,12 @@
NODE_ENV=test
SESSION_DRIVER=memory
# Désactive les vraies connexions Redis/MinIO/SMTP pendant les tests.
# Les schedulers détectent NODE_ENV=test et skip BullMQ.add.
DRIVE_DISK=fs
MAIL_DRIVER=smtp
SMTP_HOST=localhost
SMTP_PORT=1025
OCR_PROVIDER=mock
# Utilise la même DB que dev avec global transactions par test (rollback).
# Si tu veux une DB séparée : crée `rubis_test` dans Postgres et override
# PG_DB_NAME=rubis_test ici.

View File

@ -3,10 +3,15 @@ import CheckinTask from '#models/checkin_task'
import Invoice from '#models/invoice'
import { getQueue } from '#services/queue'
import { generateCheckinToken } from '#services/checkin_token'
import app from '@adonisjs/core/services/app'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
const CHECKIN_QUEUE = 'checkins'
function shouldEnqueue(): boolean {
return app.getEnvironment() !== 'test'
}
/**
* Programme un check-in pour une facture.
*
@ -22,6 +27,9 @@ const CHECKIN_QUEUE = 'checkins'
* Idempotent par invoice : si une CheckinTask `scheduled` existe déjà,
* on la cancelle d'abord puis on en crée une nouvelle (cas re-scheduling
* après changement de dueDate).
*
* En tests : la task DB est créée mais l'enqueue BullMQ est skippé
* (les tx auto-rollback laisseraient des jobs orphelins en Redis sinon).
*/
export async function scheduleCheckinForInvoice(
invoice: Invoice,
@ -31,9 +39,9 @@ export async function scheduleCheckinForInvoice(
const existing = await CheckinTask.query(trx ? { client: trx } : undefined)
.where('invoice_id', invoice.id)
.where('status', 'scheduled')
const queue = getQueue(CHECKIN_QUEUE)
const queue = shouldEnqueue() ? getQueue(CHECKIN_QUEUE) : null
for (const t of existing) {
await queue.remove(`checkin-${t.id}`).catch(() => {})
if (queue) await queue.remove(`checkin-${t.id}`).catch(() => {})
t.useTransaction(trx ?? (null as never))
t.status = 'expired'
await t.save()
@ -59,17 +67,19 @@ export async function scheduleCheckinForInvoice(
trx ? { client: trx } : undefined
)
const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
await queue.add(
'send-checkin',
{ taskId: task.id, plain },
{
delay,
jobId: `checkin-${task.id}`,
attempts: 3,
backoff: { type: 'exponential', delay: 30_000 },
}
)
if (queue) {
const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
await queue.add(
'send-checkin',
{ taskId: task.id, plain },
{
delay,
jobId: `checkin-${task.id}`,
attempts: 3,
backoff: { type: 'exponential', delay: 30_000 },
}
)
}
return { task, plain }
}
@ -86,9 +96,9 @@ export async function cancelCheckinForInvoice(
.where('status', 'scheduled')
if (tasks.length === 0) return
const queue = getQueue(CHECKIN_QUEUE)
const queue = shouldEnqueue() ? getQueue(CHECKIN_QUEUE) : null
for (const t of tasks) {
await queue.remove(`checkin-${t.id}`).catch(() => {})
if (queue) await queue.remove(`checkin-${t.id}`).catch(() => {})
t.useTransaction(trx ?? (null as never))
t.status = 'expired'
await t.save()

View File

@ -3,10 +3,21 @@ import RelanceTask from '#models/relance_task'
import Plan from '#models/plan'
import Invoice from '#models/invoice'
import { getQueue } from '#services/queue'
import app from '@adonisjs/core/services/app'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
const RELANCE_QUEUE = 'relances'
/**
* En tests, les RelanceTasks DB sont créées (utile pour assertions) mais
* l'enqueue BullMQ est skippé : les tx auto-rollback laisseraient des jobs
* orphelins en Redis sinon, et on ne veut pas dépendre d'une instance
* Redis live pour tourner les tests.
*/
function shouldEnqueue(): boolean {
return app.getEnvironment() !== 'test'
}
/**
* Programme toutes les relances d'une facture selon son plan.
*
@ -40,9 +51,9 @@ export async function scheduleRelancesForInvoice(
const existing = await RelanceTask.query(trx ? { client: trx } : undefined)
.where('invoice_id', invoice.id)
.where('status', 'scheduled')
const queue = getQueue(RELANCE_QUEUE)
const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null
for (const t of existing) {
if (t.queueJobId) {
if (t.queueJobId && queue) {
await queue.remove(t.queueJobId).catch(() => {
// Ignore — le job peut déjà être consommé.
})
@ -73,22 +84,24 @@ export async function scheduleRelancesForInvoice(
)
const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
const job = await queue.add(
'send-relance',
{ taskId: task.id },
{
delay,
// Idempotency : un seul job actif par task.
// BullMQ 5+ interdit `:` dans les custom jobIds → tiret.
jobId: `relance-${task.id}`,
// Retry exponentiel — si Mailpit est down, BullMQ retry 5x avec
// backoff (cf. backend.md §13.2).
attempts: 5,
backoff: { type: 'exponential', delay: 30_000 },
}
)
const job = queue
? await queue.add(
'send-relance',
{ taskId: task.id },
{
delay,
// Idempotency : un seul job actif par task.
// BullMQ 5+ interdit `:` dans les custom jobIds → tiret.
jobId: `relance-${task.id}`,
// Retry exponentiel — si Mailpit est down, BullMQ retry 5x avec
// backoff (cf. backend.md §13.2).
attempts: 5,
backoff: { type: 'exponential', delay: 30_000 },
}
)
: null
task.queueJobId = job.id ?? null
task.queueJobId = job?.id ?? null
await task.save()
created.push(task)
}
@ -110,9 +123,9 @@ export async function cancelFutureRelances(
.where('status', 'scheduled')
if (tasks.length === 0) return
const queue = getQueue(RELANCE_QUEUE)
const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null
for (const t of tasks) {
if (t.queueJobId) {
if (t.queueJobId && queue) {
await queue.remove(t.queueJobId).catch(() => {})
}
t.useTransaction(trx ?? null as never)

View File

@ -0,0 +1,180 @@
import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
import User from '#models/user'
import Plan from '#models/plan'
import { createTestUser } from '../helpers/auth.js'
/**
* Tests d'auth : signup, login, refresh, logout, perms.
*
* `withGlobalTransaction` wrap chaque test dans une tx PG qui est
* rollback à la fin pas besoin de truncate, isolation parfaite.
*/
test.group('Auth — POST /auth/signup', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('crée user + org + 4 plans pré-fournis dans une tx', async ({ client, assert }) => {
const response = await client.post('/api/v1/auth/signup').json({
email: 'alice@spec.test',
password: 'password123',
fullName: 'Alice Spec',
})
response.assertStatus(201)
const data = response.body().data
assert.properties(data, ['accessToken', 'expiresAt', 'user'])
assert.properties(data.user, ['id', 'email', 'organizationId', 'fullName'])
assert.equal(data.user.email, 'alice@spec.test')
// L'org est créée mais le nom reste vide (rempli en onboarding).
const user = await User.findByOrFail('email', 'alice@spec.test')
assert.isNotNull(user.organizationId)
// Les 4 plans pré-fournis sont en place pour cette org.
const plans = await Plan.query().where('organization_id', user.organizationId!)
assert.lengthOf(plans, 4)
const slugs = plans.map((p) => p.slug).sort()
assert.deepEqual(slugs, ['ferme-7j', 'patient-60j', 'rapide-15j', 'standard-30j'])
})
test('refuse un email mal formé (422)', async ({ client }) => {
const response = await client.post('/api/v1/auth/signup').json({
email: 'pas-un-email',
password: 'password123',
fullName: 'Test',
})
response.assertStatus(422)
})
test('refuse un password < 8 chars (422)', async ({ client }) => {
const response = await client.post('/api/v1/auth/signup').json({
email: 'short@spec.test',
password: 'abc',
fullName: 'Test',
})
response.assertStatus(422)
})
test('refuse un email déjà pris (422)', async ({ client }) => {
await client.post('/api/v1/auth/signup').json({
email: 'twice@spec.test',
password: 'password123',
fullName: 'First',
})
const response = await client.post('/api/v1/auth/signup').json({
email: 'twice@spec.test',
password: 'password123',
fullName: 'Second',
})
response.assertStatus(422)
})
})
test.group('Auth — POST /auth/login', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('émet une AuthSession avec credentials valides', async ({ client, assert }) => {
const { user, plainPassword } = await createTestUser()
const response = await client.post('/api/v1/auth/login').json({
email: user.email,
password: plainPassword,
})
response.assertStatus(200)
const data = response.body().data
assert.equal(data.user.email, user.email)
assert.isString(data.accessToken)
})
test('rejette un mauvais password (401)', async ({ client }) => {
const { user } = await createTestUser()
const response = await client.post('/api/v1/auth/login').json({
email: user.email,
password: 'wrong-password',
})
response.assertStatus(401)
})
test('rejette un email inconnu (401)', async ({ client }) => {
const response = await client.post('/api/v1/auth/login').json({
email: 'ghost@spec.test',
password: 'password123',
})
response.assertStatus(401)
})
})
test.group('Auth — bearer requis', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('GET /account/profile → 401 sans token', async ({ client }) => {
const response = await client.get('/api/v1/account/profile')
response.assertStatus(401)
})
test('GET /account/profile → 401 avec un token bidon', async ({ client }) => {
const response = await client
.get('/api/v1/account/profile')
.header('Authorization', 'Bearer invalid-token')
response.assertStatus(401)
})
test('GET /account/profile → 200 avec token valide', async ({ client, assert }) => {
const { user, accessToken } = await createTestUser()
const response = await client
.get('/api/v1/account/profile')
.header('Authorization', `Bearer ${accessToken}`)
response.assertStatus(200)
assert.equal(response.body().data.email, user.email)
})
})
test.group('Auth — POST /account/logout', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('révoque le token courant (204)', async ({ client }) => {
const { accessToken } = await createTestUser()
const logout = await client
.post('/api/v1/account/logout')
.header('Authorization', `Bearer ${accessToken}`)
logout.assertStatus(204)
// Le token doit être révoqué : la requête suivante revient 401.
const after = await client
.get('/api/v1/account/profile')
.header('Authorization', `Bearer ${accessToken}`)
after.assertStatus(401)
})
})
test.group('Auth — onboarding state', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('PATCH /organizations/me pose onboardingCompletedAt à la 1re mise du nom', async ({
client,
assert,
}) => {
const { accessToken } = await createTestUser()
// 1er PATCH : pose onboardingCompletedAt
const r1 = await client
.patch('/api/v1/organizations/me')
.header('Authorization', `Bearer ${accessToken}`)
.json({ name: 'Boulangerie Spec' })
r1.assertStatus(200)
assert.isNotNull(r1.body().data.onboardingCompletedAt)
// 2e PATCH : ne touche pas onboardingCompletedAt (déjà set)
const onboardedAt = r1.body().data.onboardingCompletedAt
const r2 = await client
.patch('/api/v1/organizations/me')
.header('Authorization', `Bearer ${accessToken}`)
.json({ name: 'Boulangerie Spec 2' })
r2.assertStatus(200)
assert.equal(r2.body().data.onboardingCompletedAt, onboardedAt)
})
})

View File

@ -0,0 +1,57 @@
import User from '#models/user'
import Organization from '#models/organization'
import { provisionDefaultPlans } from '#services/default_plans'
import db from '@adonisjs/lucid/services/db'
let counter = 0
/**
* Helper : crée une organisation + un user + provisionne les 4 plans
* pré-fournis, dans une transaction. Retourne le user, l'org et un
* access token Bearer prêt à être passé en header.
*
* `counter` rend les emails uniques entre les tests d'une même suite
* sans ça, les tests qui ne tombent pas dans une global tx risquent un
* email_taken.
*/
export async function createTestUser(overrides: {
email?: string
password?: string
fullName?: string
orgName?: string
} = {}) {
counter += 1
const email = overrides.email ?? `test-${counter}-${Date.now()}@rubis.test`
const password = overrides.password ?? 'password123'
const fullName = overrides.fullName ?? `Test User ${counter}`
const orgName = overrides.orgName ?? ''
const { user, org } = await db.transaction(async (trx) => {
const o = await Organization.create({ name: orgName }, { client: trx })
await provisionDefaultPlans(o.id, trx)
const u = await User.create({ email, password, fullName, organizationId: o.id }, { client: trx })
return { user: u, org: o }
})
const token = await User.accessTokens.create(user)
// .release() consomme la valeur — on capture une seule fois.
const accessToken = token.value!.release()
return {
user,
org,
plainPassword: password,
accessToken,
bearer: { Authorization: `Bearer ${accessToken}` },
}
}
/**
* Variante : crée juste 2 users dans 2 orgs distinctes (pour les tests
* cross-org : user A ne peut pas voir/modifier les ressources de B).
*/
export async function createTwoOrgs() {
const a = await createTestUser({ orgName: 'Org A' })
const b = await createTestUser({ orgName: 'Org B' })
return { a, b }
}