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:
parent
01f3edcf08
commit
fc66d80f56
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
180
apps/api/tests/functional/auth.spec.ts
Normal file
180
apps/api/tests/functional/auth.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
57
apps/api/tests/helpers/auth.ts
Normal file
57
apps/api/tests/helpers/auth.ts
Normal 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 }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user