From fc66d80f566dc64d6b89b09c636d0492685f8a75 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 15:45:11 +0200 Subject: [PATCH] test(api): setup Japa + tests fonctionnels auth (signup/login/logout/onboarding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` --- apps/api/.env.test | 11 ++ apps/api/app/services/checkin_scheduler.ts | 40 +++-- apps/api/app/services/relance_scheduler.ts | 51 +++--- apps/api/tests/functional/auth.spec.ts | 180 +++++++++++++++++++++ apps/api/tests/helpers/auth.ts | 57 +++++++ 5 files changed, 305 insertions(+), 34 deletions(-) create mode 100644 apps/api/tests/functional/auth.spec.ts create mode 100644 apps/api/tests/helpers/auth.ts diff --git a/apps/api/.env.test b/apps/api/.env.test index 28d2da0..0f799cf 100644 --- a/apps/api/.env.test +++ b/apps/api/.env.test @@ -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. diff --git a/apps/api/app/services/checkin_scheduler.ts b/apps/api/app/services/checkin_scheduler.ts index 46f9248..9c37ce1 100644 --- a/apps/api/app/services/checkin_scheduler.ts +++ b/apps/api/app/services/checkin_scheduler.ts @@ -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() diff --git a/apps/api/app/services/relance_scheduler.ts b/apps/api/app/services/relance_scheduler.ts index 9b528b1..e3ec1b4 100644 --- a/apps/api/app/services/relance_scheduler.ts +++ b/apps/api/app/services/relance_scheduler.ts @@ -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) diff --git a/apps/api/tests/functional/auth.spec.ts b/apps/api/tests/functional/auth.spec.ts new file mode 100644 index 0000000..ced8044 --- /dev/null +++ b/apps/api/tests/functional/auth.spec.ts @@ -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) + }) +}) diff --git a/apps/api/tests/helpers/auth.ts b/apps/api/tests/helpers/auth.ts new file mode 100644 index 0000000..931d1f0 --- /dev/null +++ b/apps/api/tests/helpers/auth.ts @@ -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 } +}