rubis/apps/api/tests/functional/invoices.spec.ts
ordinarthur 554ae4ba4a test(api): tests fonctionnels Invoices + Imports + Dashboard
invoices.spec.ts (10 cas) :
- Création : 201 + rubisEarned=1 (bonus saisie) + status=pending
- Création client à la volée : si nom non matché + email fourni → client créé
- 422 client_email_required si pas d'email pour création à la volée
- Si planId fourni : RelanceTasks scheduled créées (assertion sur DB count)
- Numéro unique par org : 422 sur duplicate (testant l'exception handler)
- mark-paid idempotent : 2e appel ne re-bumpe pas rubisEarned ni org.rubis_count
- mark-paid annule les RelanceTasks scheduled (passe à cancelled)
- Cross-org : user B → 404 sur mark-paid d'une facture de A
- GET /invoices : pagination + meta total/page
- GET /invoices/counts : agrège par status

imports.spec.ts (6 cas) :
- POST /upload mock JSON : 1 batch + N drafts en pending, mock OCR rempli les fields
- Refus > 20 fichiers
- Validate transforme draft en Invoice + status=validated + invoiceId set
- Validate sur draft déjà processed → 409 draft_already_processed
- Skip → status=skipped, idempotent
- DELETE batch → CASCADE supprime les drafts

dashboard.spec.ts (6 cas) :
- KPIs zéros sur org vierge
- factureToRelance compte les invoices pending
- Après mark-paid : encaisseCents et rubisCount bumpent (org.rubisCount agrégé)
- Activity vide sur org sans actions
- Activity loggue invoice_paid après mark-paid (label dans le feed)
- Top-late liste les clients avec invoices actives en retard (dueDate < today)
2026-05-06 15:53:14 +02:00

295 lines
9.4 KiB
TypeScript

import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
import RelanceTask from '#models/relance_task'
import Organization from '#models/organization'
import { createTestUser, createTwoOrgs } from '../helpers/auth.js'
import { body, type ApiOk, type ApiOkPaged } from '../helpers/response.js'
type InvoiceShape = {
id: string
organizationId: string
clientId: string
numero: string
amountTtcCents: number
status: string
rubisEarned: number
paidAt: string | null
}
type ClientShape = { id: string; name: string }
type PlanShape = { id: string; slug: string }
async function createClient(client: any, headers: Record<string, string>, name: string) {
const r = await client
.post('/api/v1/clients')
.headers(headers)
.json({ name, email: `${name.toLowerCase().replace(/\s+/g, '')}@spec.test` })
return body<ApiOk<ClientShape>>(r).data
}
async function getStandardPlan(client: any, headers: Record<string, string>) {
const r = await client.get('/api/v1/plans/standard-30j').headers(headers)
return body<ApiOk<PlanShape>>(r).data
}
test.group('Invoices — POST /invoices', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('crée une facture (201) avec rubisEarned=1 (bonus saisie)', async ({
client,
assert,
}) => {
const { bearer } = await createTestUser()
const c = await createClient(client, bearer, 'Boulangerie Spec')
const r = await client
.post('/api/v1/invoices')
.headers(bearer)
.json({
clientId: c.id,
clientName: c.name,
numero: 'F-2026-T01',
amountTtcCents: 124000,
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
r.assertStatus(201)
const inv = body<ApiOk<InvoiceShape>>(r).data
assert.equal(inv.rubisEarned, 1)
assert.equal(inv.status, 'pending')
})
test('crée à la volée un client si nom non matché + email fourni', async ({
client,
assert,
}) => {
const { bearer, accessToken } = await createTestUser()
const r = await client
.post('/api/v1/invoices')
.headers(bearer)
.json({
clientName: 'Nouveau Client',
clientEmail: 'nouveau@spec.test',
numero: 'F-2026-T02',
amountTtcCents: 50000,
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
r.assertStatus(201)
// Le client doit exister maintenant
const list = await client.get('/api/v1/clients').headers(bearer)
const names = body<ApiOk<Array<{ name: string }>>>(list).data.map((c) => c.name)
assert.include(names, 'Nouveau Client')
// assert le token est bien valide
assert.isString(accessToken)
})
test('refuse création client à la volée sans email (422 client_email_required)', async ({
client,
assert,
}) => {
const { bearer } = await createTestUser()
const r = await client
.post('/api/v1/invoices')
.headers(bearer)
.json({
clientName: 'Sans Email',
numero: 'F-2026-T03',
amountTtcCents: 50000,
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
r.assertStatus(422)
const errors = body<{ errors: Array<{ code: string }> }>(r).errors
assert.equal(errors[0].code, 'client_email_required')
})
test('schedule des RelanceTasks si planId est fourni', async ({ client, assert }) => {
const { bearer } = await createTestUser()
const c = await createClient(client, bearer, 'Avec Plan')
const plan = await getStandardPlan(client, bearer)
const r = await client
.post('/api/v1/invoices')
.headers(bearer)
.json({
clientId: c.id,
clientName: c.name,
planId: plan.id,
numero: 'F-2026-T04',
amountTtcCents: 50000,
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
r.assertStatus(201)
const inv = body<ApiOk<InvoiceShape>>(r).data
const tasks = await RelanceTask.query().where('invoice_id', inv.id)
// Le plan standard-30j a 3 steps
assert.lengthOf(tasks, 3)
for (const t of tasks) assert.equal(t.status, 'scheduled')
})
test('numéro unique par org (422 duplicate)', async ({ client }) => {
const { bearer } = await createTestUser()
const c = await createClient(client, bearer, 'Dup Test')
const payload = {
clientId: c.id,
clientName: c.name,
numero: 'F-2026-DUP',
amountTtcCents: 50000,
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
}
await client.post('/api/v1/invoices').headers(bearer).json(payload)
const r2 = await client.post('/api/v1/invoices').headers(bearer).json(payload)
r2.assertStatus(422)
})
})
test.group('Invoices — POST /invoices/:id/mark-paid', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('idempotent : 2e appel ne re-bumpe pas rubisEarned', async ({ client, assert }) => {
const { bearer, org } = await createTestUser()
const c = await createClient(client, bearer, 'Idem')
const created = await client
.post('/api/v1/invoices')
.headers(bearer)
.json({
clientId: c.id,
clientName: c.name,
numero: 'F-2026-T10',
amountTtcCents: 100000,
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
const id = body<ApiOk<InvoiceShape>>(created).data.id
const first = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(bearer)
first.assertStatus(200)
const afterFirst = body<ApiOk<InvoiceShape>>(first).data
assert.equal(afterFirst.status, 'paid')
assert.equal(afterFirst.rubisEarned, 2) // 1 saisie + 1 encaissement
const second = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(bearer)
second.assertStatus(200)
assert.equal(body<ApiOk<InvoiceShape>>(second).data.rubisEarned, 2) // pas re-bumpé
// Org rubisCount n'a été incrémenté qu'une fois
const orgFresh = await Organization.findOrFail(org.id)
assert.equal(orgFresh.rubisCount, 1)
})
test("annule les RelanceTasks scheduled de la facture", async ({ client, assert }) => {
const { bearer } = await createTestUser()
const c = await createClient(client, bearer, 'Cancel Tasks')
const plan = await getStandardPlan(client, bearer)
const created = await client
.post('/api/v1/invoices')
.headers(bearer)
.json({
clientId: c.id,
clientName: c.name,
planId: plan.id,
numero: 'F-2026-T11',
amountTtcCents: 100000,
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
const id = body<ApiOk<InvoiceShape>>(created).data.id
const beforeTasks = await RelanceTask.query().where('invoice_id', id)
assert.lengthOf(beforeTasks, 3)
await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(bearer)
const afterTasks = await RelanceTask.query().where('invoice_id', id)
for (const t of afterTasks) assert.equal(t.status, 'cancelled')
})
test('cross-org : user B ne peut pas mark-paid une facture de A (404)', async ({
client,
}) => {
const { a, b } = await createTwoOrgs()
const c = await createClient(client, a.bearer, 'Of A')
const created = await client
.post('/api/v1/invoices')
.headers(a.bearer)
.json({
clientId: c.id,
clientName: c.name,
numero: 'F-2026-XO1',
amountTtcCents: 50000,
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
const id = body<ApiOk<InvoiceShape>>(created).data.id
const r = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(b.bearer)
r.assertStatus(404)
})
})
test.group('Invoices — GET /invoices', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('liste paginée + meta total/page', async ({ client, assert }) => {
const { bearer } = await createTestUser()
const c = await createClient(client, bearer, 'Liste Spec')
for (let i = 0; i < 3; i++) {
await client
.post('/api/v1/invoices')
.headers(bearer)
.json({
clientId: c.id,
clientName: c.name,
numero: `F-2026-L${i}`,
amountTtcCents: 10000 + i,
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
}
const r = await client.get('/api/v1/invoices').headers(bearer)
r.assertStatus(200)
const payload = body<ApiOkPaged<InvoiceShape[]>>(r)
assert.equal(payload.meta.total, 3)
assert.equal(payload.meta.page, 1)
})
test('GET /invoices/counts agrège par status', async ({ client, assert }) => {
const { bearer } = await createTestUser()
const c = await createClient(client, bearer, 'Counts Spec')
await client
.post('/api/v1/invoices')
.headers(bearer)
.json({
clientId: c.id,
clientName: c.name,
numero: 'F-2026-C01',
amountTtcCents: 10000,
issueDate: '2026-04-01T09:00:00.000Z',
dueDate: '2026-05-01T09:00:00.000Z',
})
const r = await client.get('/api/v1/invoices/counts').headers(bearer)
r.assertStatus(200)
const c2 = body<ApiOk<{ all: number; pending: number }>>(r).data
assert.equal(c2.all, 1)
assert.equal(c2.pending, 1)
})
})