test(api): tests fonctionnels Clients + Plans (CRUD + cross-org + validation)
Helper response.ts : `body<T>()` pour caster Tuyau strict response shapes (Tuyau type chaque code de statut comme une union, assertStatus ne narrow pas → on cast explicitement vers ApiOk<T>/ApiError/ApiConflict<T>). 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)
This commit is contained in:
parent
fc66d80f56
commit
691b5fd09f
196
apps/api/tests/functional/clients.spec.ts
Normal file
196
apps/api/tests/functional/clients.spec.ts
Normal file
@ -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<ApiError>(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<ApiOk<ClientShape>>(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<ApiConflict<ClientShape>>(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<ApiOk<ClientShape[]>>(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<ApiOk<ClientShape[]>>(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<ApiOk<Array<ClientShape & {
|
||||
invoiceCount: number
|
||||
lateInvoiceCount: number
|
||||
paidLifetimeCents: number
|
||||
lastActivityAt: string | null
|
||||
}>>>(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<ApiOk<ClientShape[]>>(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<ApiOk<ClientShape>>(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<ApiOk<ClientShape>>(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)
|
||||
})
|
||||
})
|
||||
142
apps/api/tests/functional/plans.spec.ts
Normal file
142
apps/api/tests/functional/plans.spec.ts
Normal file
@ -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<ApiOk<PlanShape[]>>(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<ApiOk<PlanShape[]>>(fromA).data.map((p) => p.id).sort()
|
||||
const idsB = body<ApiOk<PlanShape[]>>(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<ApiOk<PlanShape>>(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<ApiOk<PlanShape>>(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<ApiOk<PlanShape>>(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<ApiOk<PlanShape>>(fromA).data.name, 'Standard B2B')
|
||||
})
|
||||
})
|
||||
18
apps/api/tests/helpers/response.ts
Normal file
18
apps/api/tests/helpers/response.ts
Normal file
@ -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<T>(response: { body(): unknown }): T {
|
||||
return response.body() as T
|
||||
}
|
||||
|
||||
export type ApiOk<T> = { data: T }
|
||||
export type ApiOkPaged<T> = { data: T; meta: { total: number; page: number } }
|
||||
export type ApiError = {
|
||||
errors: Array<{ code: string; message: string; field?: string }>
|
||||
}
|
||||
export type ApiConflict<T> = ApiError & { existing: T }
|
||||
Loading…
x
Reference in New Issue
Block a user