From 691b5fd09fb433771ca2cba934dd91a2f2073474 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 15:51:03 +0200 Subject: [PATCH] test(api): tests fonctionnels Clients + Plans (CRUD + cross-org + validation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Helper response.ts : `body()` pour caster Tuyau strict response shapes (Tuyau type chaque code de statut comme une union, assertStatus ne narrow pas → on cast explicitement vers ApiOk/ApiError/ApiConflict). clients.spec.ts (16 cas) : - POST /clients : refus sans email (422 + field=email), refus SIRET ≠ 14 chiffres, création OK avec UUID + association org, doublon nom case-insensitive (409 + payload existing) - GET /clients : isolation cross-org (user A ne voit pas les clients de B), withStats=1 enrichit (zéros sans factures), recherche q ILIKE - Perms cross-org : user B → 404 sur GET/PATCH d'un client de A, l'objet ne bouge pas plans.spec.ts (7 cas) : - GET /plans : 4 plans pré-fournis avec steps préchargés, isolation cross-org (UUIDs disjoints entre A et B) - GET /plans/:slug : steps ordonnés, 404 si inconnu - PATCH /plans/:slug : remplace les steps en bloc dans une tx, rejette tone invalide, cross-org (B édite SA copie sans toucher celle de A) --- apps/api/tests/functional/clients.spec.ts | 196 ++++++++++++++++++++++ apps/api/tests/functional/plans.spec.ts | 142 ++++++++++++++++ apps/api/tests/helpers/response.ts | 18 ++ 3 files changed, 356 insertions(+) create mode 100644 apps/api/tests/functional/clients.spec.ts create mode 100644 apps/api/tests/functional/plans.spec.ts create mode 100644 apps/api/tests/helpers/response.ts diff --git a/apps/api/tests/functional/clients.spec.ts b/apps/api/tests/functional/clients.spec.ts new file mode 100644 index 0000000..f27efee --- /dev/null +++ b/apps/api/tests/functional/clients.spec.ts @@ -0,0 +1,196 @@ +import { test } from '@japa/runner' +import testUtils from '@adonisjs/core/services/test_utils' +import Client from '#models/client' +import { createTestUser, createTwoOrgs } from '../helpers/auth.js' +import { body, type ApiError, type ApiOk, type ApiConflict } from '../helpers/response.js' + +type ClientShape = { + id: string + organizationId: string + name: string + email: string + phone: string | null + address: string | null + siret: string | null + notes: string | null +} + +test.group('Clients — POST /clients', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test('refuse sans email (422 + field=email)', async ({ client, assert }) => { + const { bearer } = await createTestUser() + const response = await client + .post('/api/v1/clients') + .headers(bearer) + .json({ name: 'Boulangerie X' }) + + response.assertStatus(422) + assert.equal(body(response).errors[0].field, 'email') + }) + + test('refuse SIRET non-14-chiffres (422)', async ({ client }) => { + const { bearer } = await createTestUser() + const response = await client + .post('/api/v1/clients') + .headers(bearer) + .json({ + name: 'Test SIRET', + email: 'compta@test.fr', + siret: '123', // 3 chiffres au lieu de 14 + }) + response.assertStatus(422) + }) + + test("crée un client (201) + UUID + association à l'org du user", async ({ + client, + assert, + }) => { + const { bearer, org } = await createTestUser() + const response = await client + .post('/api/v1/clients') + .headers(bearer) + .json({ name: 'Boulangerie X', email: 'compta@x.fr' }) + + response.assertStatus(201) + const data = body>(response).data + assert.match(data.id, /^[0-9a-f-]{36}$/u) + assert.equal(data.organizationId, org.id) + assert.equal(data.email, 'compta@x.fr') + }) + + test('rejette doublon par nom case-insensitive (409 + payload existing)', async ({ + client, + assert, + }) => { + const { bearer } = await createTestUser() + + await client + .post('/api/v1/clients') + .headers(bearer) + .json({ name: 'Boulangerie Martin', email: 'a@martin.fr' }) + + const dup = await client + .post('/api/v1/clients') + .headers(bearer) + .json({ name: 'BOULANGERIE MARTIN', email: 'b@martin.fr' }) + + dup.assertStatus(409) + const payload = body>(dup) + assert.equal(payload.errors[0].code, 'duplicate_client') + assert.exists(payload.existing.id) + }) +}) + +test.group('Clients — GET /clients', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test("liste seulement les clients de l'org du user (cross-org)", async ({ + client, + assert, + }) => { + const { a, b } = await createTwoOrgs() + + await client + .post('/api/v1/clients') + .headers(a.bearer) + .json({ name: 'Client A1', email: 'a1@a.fr' }) + + await client + .post('/api/v1/clients') + .headers(b.bearer) + .json({ name: 'Client B1', email: 'b1@b.fr' }) + + const fromA = await client.get('/api/v1/clients').headers(a.bearer) + fromA.assertStatus(200) + const namesFromA = body>(fromA).data.map((c) => c.name) + assert.deepEqual(namesFromA, ['Client A1']) + + const fromB = await client.get('/api/v1/clients').headers(b.bearer) + fromB.assertStatus(200) + const namesFromB = body>(fromB).data.map((c) => c.name) + assert.deepEqual(namesFromB, ['Client B1']) + }) + + test('?withStats=1 enrichit avec compteurs (zéros sans factures)', async ({ + client, + assert, + }) => { + const { bearer } = await createTestUser() + await client + .post('/api/v1/clients') + .headers(bearer) + .json({ name: 'Boulangerie', email: 'a@b.fr' }) + + const response = await client.get('/api/v1/clients?withStats=1').headers(bearer) + response.assertStatus(200) + const c = body>>(response).data[0] + assert.equal(c.invoiceCount, 0) + assert.equal(c.lateInvoiceCount, 0) + assert.equal(c.paidLifetimeCents, 0) + assert.isNull(c.lastActivityAt) + }) + + test('?q=foo filtre par nom et email (ILIKE)', async ({ client, assert }) => { + const { bearer } = await createTestUser() + await client + .post('/api/v1/clients') + .headers(bearer) + .json({ name: 'Boulangerie Martin', email: 'martin@bp.fr' }) + await client + .post('/api/v1/clients') + .headers(bearer) + .json({ name: 'Atelier Durand', email: 'durand@a.fr' }) + + const r = await client.get('/api/v1/clients?q=BOULANG').headers(bearer) + r.assertStatus(200) + const data = body>(r).data + assert.lengthOf(data, 1) + assert.equal(data[0].name, 'Boulangerie Martin') + }) +}) + +test.group('Clients — perms cross-org', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test("user B ne peut pas GET /clients/:id d'un client de A (404)", async ({ + client, + }) => { + const { a, b } = await createTwoOrgs() + const created = await client + .post('/api/v1/clients') + .headers(a.bearer) + .json({ name: 'Client A', email: 'a@a.fr' }) + const id = body>(created).data.id + + const fromB = await client.get(`/api/v1/clients/${id}`).headers(b.bearer) + fromB.assertStatus(404) + }) + + test("user B ne peut pas PATCH /clients/:id d'un client de A (404)", async ({ + client, + assert, + }) => { + const { a, b } = await createTwoOrgs() + const created = await client + .post('/api/v1/clients') + .headers(a.bearer) + .json({ name: 'Client A', email: 'a@a.fr' }) + const id = body>(created).data.id + + const r = await client + .patch(`/api/v1/clients/${id}`) + .headers(b.bearer) + .json({ phone: '06 11 22 33 44' }) + r.assertStatus(404) + + // Le client A n'a pas été touché + const fresh = await Client.findOrFail(id) + assert.isNull(fresh.phone) + }) +}) diff --git a/apps/api/tests/functional/plans.spec.ts b/apps/api/tests/functional/plans.spec.ts new file mode 100644 index 0000000..4d69fb0 --- /dev/null +++ b/apps/api/tests/functional/plans.spec.ts @@ -0,0 +1,142 @@ +import { test } from '@japa/runner' +import testUtils from '@adonisjs/core/services/test_utils' +import PlanStep from '#models/plan_step' +import { createTestUser, createTwoOrgs } from '../helpers/auth.js' +import { body, type ApiOk } from '../helpers/response.js' + +type PlanShape = { + id: string + slug: string | null + name: string + description: string + isDefault: boolean + steps: Array<{ id: string; order: number; tone: string }> + usageCount?: number +} + +test.group('Plans — GET /plans', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test('retourne les 4 plans pré-fournis avec steps préchargés', async ({ + client, + assert, + }) => { + const { bearer } = await createTestUser() + const r = await client.get('/api/v1/plans').headers(bearer) + r.assertStatus(200) + + const plans = body>(r).data + assert.lengthOf(plans, 4) + for (const p of plans) { + assert.isTrue(p.isDefault) + assert.isAbove(p.steps.length, 0) + assert.equal(p.usageCount, 0) + } + }) + + test('isolation cross-org : chaque org voit ses propres copies', async ({ + client, + assert, + }) => { + const { a, b } = await createTwoOrgs() + const fromA = await client.get('/api/v1/plans').headers(a.bearer) + const fromB = await client.get('/api/v1/plans').headers(b.bearer) + + const idsA = body>(fromA).data.map((p) => p.id).sort() + const idsB = body>(fromB).data.map((p) => p.id).sort() + for (const id of idsA) assert.notInclude(idsB, id) + }) +}) + +test.group('Plans — GET /plans/:slug', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test('lookup par slug avec steps ordonnés', async ({ client, assert }) => { + const { bearer } = await createTestUser() + const r = await client.get('/api/v1/plans/standard-30j').headers(bearer) + r.assertStatus(200) + + const plan = body>(r).data + assert.equal(plan.slug, 'standard-30j') + const orders = plan.steps.map((s) => s.order) + assert.deepEqual(orders, [...orders].sort((a, b) => a - b)) + }) + + test('404 si slug inconnu', async ({ client }) => { + const { bearer } = await createTestUser() + const r = await client.get('/api/v1/plans/inexistant').headers(bearer) + r.assertStatus(404) + }) +}) + +test.group('Plans — PATCH /plans/:slug', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test('édite name + remplace les steps en bloc', async ({ client, assert }) => { + const { bearer } = await createTestUser() + + const before = await client.get('/api/v1/plans/standard-30j').headers(bearer) + const planId = body>(before).data.id + + const r = await client + .patch('/api/v1/plans/standard-30j') + .headers(bearer) + .json({ + name: 'Standard édité', + steps: [ + { + order: 0, + offsetDays: 5, + tone: 'amical', + subject: 'Hop', + body: 'Hop hop', + requiresManualValidation: false, + }, + ], + }) + + r.assertStatus(200) + const data = body>(r).data + assert.equal(data.name, 'Standard édité') + assert.lengthOf(data.steps, 1) + + const steps = await PlanStep.query().where('plan_id', planId) + assert.lengthOf(steps, 1) + }) + + test('rejette tone invalide (422)', async ({ client }) => { + const { bearer } = await createTestUser() + const r = await client + .patch('/api/v1/plans/standard-30j') + .headers(bearer) + .json({ + steps: [ + { + order: 0, + offsetDays: 1, + tone: 'pas-un-ton', + subject: 'X', + body: 'X', + requiresManualValidation: false, + }, + ], + }) + r.assertStatus(422) + }) + + test("cross-org : chaque org édite SA copie, pas celle de l'autre", async ({ + client, + assert, + }) => { + const { a, b } = await createTwoOrgs() + + const r = await client + .patch('/api/v1/plans/standard-30j') + .headers(b.bearer) + .json({ name: 'Renommé par B' }) + r.assertStatus(200) + + const fromA = await client.get('/api/v1/plans/standard-30j').headers(a.bearer) + assert.equal(body>(fromA).data.name, 'Standard B2B') + }) +}) diff --git a/apps/api/tests/helpers/response.ts b/apps/api/tests/helpers/response.ts new file mode 100644 index 0000000..e9df542 --- /dev/null +++ b/apps/api/tests/helpers/response.ts @@ -0,0 +1,18 @@ +/** + * Tuyau type strictement chaque code de statut possible d'une route. Quand + * on `assertStatus(...)` puis on lit `.body()`, TS ne narrow pas — il garde + * l'union. + * + * Ce helper sert juste à caster `.body()` vers la forme attendue dans le + * contexte du test, sans perdre la lisibilité du `.data` / `.errors`. + */ +export function body(response: { body(): unknown }): T { + return response.body() as T +} + +export type ApiOk = { data: T } +export type ApiOkPaged = { data: T; meta: { total: number; page: number } } +export type ApiError = { + errors: Array<{ code: string; message: string; field?: string }> +} +export type ApiConflict = ApiError & { existing: T }