All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m18s
Le test "refuse un email déjà pris" vérifiait seulement `assertStatus(422)`.
On enrichit pour vérifier la shape complète :
- body.errors est un array non-vide
- au moins une entrée a `field === 'email'`
- cette entrée a un code et un message strings
Pourquoi : un mutation test du 2026-05-18 a révélé qu'un simple
`assertStatus(422)` laissait passer le retrait du `.unique()` côté
validator Vine — la contrainte DB UNIQUE(email) prenait le relais
silencieusement via le handler PG 23505, qui renvoie le même 422.
Apprentissage : .unique() Vine et DB unique sont équivalents côté
contrat API (le handler `23505` met `field='email'` identique). Le
.unique() Vine est donc une optimisation (skip round-trip DB) plutôt
qu'une garantie de sécurité. On garde la double protection comme
best practice (defense-in-depth).
Le test enrichi ne catche pas le retrait du .unique() spécifiquement
(les 2 paths sont indistinguables côté client) MAIS catche des
régressions plus graves :
- Si le handler 23505 casse → 500 au lieu de 422
- Si `field` n'est plus posé → SPA ne peut plus highlight l'input
- Si la shape {errors:[...]} change → contrat API cassé
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
208 lines
7.2 KiB
TypeScript
208 lines
7.2 KiB
TypeScript
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 + erreur typée sur le champ email)', async ({
|
|
client,
|
|
assert,
|
|
}) => {
|
|
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)
|
|
|
|
// Vérifie le format précis de la réponse — pas juste un 422 vague.
|
|
// Permet de détecter une régression où :
|
|
// 1. La validation unique disparait (validator Vine ou contrainte DB)
|
|
// 2. Le handler d'exception change la shape (`{ errors: [{ ... }] }`)
|
|
// 3. Le `field` n'est plus posé sur l'erreur (le SPA s'en sert pour
|
|
// mettre en rouge le bon input)
|
|
//
|
|
// Ce test a été enrichi suite à un mutation test du 2026-05-18 qui a
|
|
// révélé qu'un seul `assertStatus(422)` laissait passer le retrait
|
|
// du `.unique()` côté validator (la contrainte DB prenait le relais
|
|
// silencieusement, sans test).
|
|
const body = response.body() as {
|
|
errors?: Array<{ code?: string; field?: string; message?: string }>
|
|
}
|
|
assert.isArray(body.errors)
|
|
assert.isAtLeast(body.errors!.length, 1)
|
|
const emailError = body.errors!.find((e) => e.field === 'email')
|
|
assert.exists(
|
|
emailError,
|
|
"le payload d'erreur doit avoir au moins une entrée avec field='email'"
|
|
)
|
|
assert.isString(emailError!.code)
|
|
assert.isString(emailError!.message)
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|