diff --git a/apps/api/tests/functional/dashboard.spec.ts b/apps/api/tests/functional/dashboard.spec.ts new file mode 100644 index 0000000..79b6e72 --- /dev/null +++ b/apps/api/tests/functional/dashboard.spec.ts @@ -0,0 +1,156 @@ +import { test } from '@japa/runner' +import testUtils from '@adonisjs/core/services/test_utils' +import { createTestUser } from '../helpers/auth.js' +import { body, type ApiOk } from '../helpers/response.js' + +type Kpis = { + rubisCount: number + rubisThisMonth: number + encaisseCents: number + factureToRelance: number + factureInRelance: number + miseEnDemeurePending: number + monthlyGoalProgress: number +} + +type ActivityEvent = { id: string; kind: string; label: string } +type LatePayer = { clientId: string; name: string; lateInvoicesCount: number } + +test.group('Dashboard — GET /dashboard/kpis', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test('zéros sur une org vierge', async ({ client, assert }) => { + const { bearer } = await createTestUser() + const r = await client.get('/api/v1/dashboard/kpis').headers(bearer) + r.assertStatus(200) + + const k = body>(r).data + assert.equal(k.rubisCount, 0) + assert.equal(k.rubisThisMonth, 0) + assert.equal(k.encaisseCents, 0) + assert.equal(k.factureToRelance, 0) + assert.equal(k.factureInRelance, 0) + assert.equal(k.miseEnDemeurePending, 0) + }) + + test('factureToRelance compte les pending', async ({ client, assert }) => { + const { bearer } = await createTestUser() + const c = await client + .post('/api/v1/clients') + .headers(bearer) + .json({ name: 'Dash', email: 'dash@spec.test' }) + const cid = body>(c).data.id + + await client.post('/api/v1/invoices').headers(bearer).json({ + clientId: cid, + clientName: 'Dash', + numero: 'F-DASH-01', + amountTtcCents: 10000, + issueDate: '2026-04-01T09:00:00.000Z', + dueDate: '2026-05-01T09:00:00.000Z', + }) + + const r = await client.get('/api/v1/dashboard/kpis').headers(bearer) + assert.equal(body>(r).data.factureToRelance, 1) + }) + + test('encaisseCents et rubisCount bumpent après mark-paid', async ({ + client, + assert, + }) => { + const { bearer } = await createTestUser() + const c = await client + .post('/api/v1/clients') + .headers(bearer) + .json({ name: 'Paid Spec', email: 'paid@spec.test' }) + const cid = body>(c).data.id + + const inv = await client.post('/api/v1/invoices').headers(bearer).json({ + clientId: cid, + clientName: 'Paid Spec', + numero: 'F-PAID-01', + amountTtcCents: 250000, + // issueDate/dueDate dans le mois courant pour rentrer dans rubisThisMonth + issueDate: new Date().toISOString(), + dueDate: new Date(Date.now() + 86_400_000).toISOString(), + }) + const invId = body>(inv).data.id + + await client.post(`/api/v1/invoices/${invId}/mark-paid`).headers(bearer) + + const r = await client.get('/api/v1/dashboard/kpis').headers(bearer) + const k = body>(r).data + assert.equal(k.rubisCount, 1) + assert.equal(k.encaisseCents, 250000) + }) +}) + +test.group('Dashboard — GET /dashboard/activity', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test('vide sur une org sans actions', async ({ client, assert }) => { + const { bearer } = await createTestUser() + const r = await client.get('/api/v1/dashboard/activity').headers(bearer) + r.assertStatus(200) + assert.deepEqual(body>(r).data, []) + }) + + test('mark-paid logge un event invoice_paid', async ({ client, assert }) => { + const { bearer } = await createTestUser() + const c = await client + .post('/api/v1/clients') + .headers(bearer) + .json({ name: 'Activity', email: 'activity@spec.test' }) + const cid = body>(c).data.id + + const inv = await client.post('/api/v1/invoices').headers(bearer).json({ + clientId: cid, + clientName: 'Activity', + numero: 'F-ACT-01', + amountTtcCents: 50000, + issueDate: '2026-04-01T09:00:00.000Z', + dueDate: '2026-05-01T09:00:00.000Z', + }) + const invId = body>(inv).data.id + + await client.post(`/api/v1/invoices/${invId}/mark-paid`).headers(bearer) + + const r = await client.get('/api/v1/dashboard/activity').headers(bearer) + const events = body>(r).data + assert.isAbove(events.length, 0) + assert.equal(events[0].kind, 'invoice_paid') + }) +}) + +test.group('Dashboard — GET /dashboard/top-late', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test('liste les clients avec factures actives en retard', async ({ + client, + assert, + }) => { + const { bearer } = await createTestUser() + const c = await client + .post('/api/v1/clients') + .headers(bearer) + .json({ name: 'En Retard', email: 'late@spec.test' }) + const cid = body>(c).data.id + + // dueDate dans le passé → en retard + await client.post('/api/v1/invoices').headers(bearer).json({ + clientId: cid, + clientName: 'En Retard', + numero: 'F-LATE-01', + amountTtcCents: 50000, + issueDate: '2025-12-01T09:00:00.000Z', + dueDate: '2025-12-31T09:00:00.000Z', + }) + + const r = await client.get('/api/v1/dashboard/top-late').headers(bearer) + r.assertStatus(200) + const list = body>(r).data + assert.lengthOf(list, 1) + assert.equal(list[0].name, 'En Retard') + assert.equal(list[0].lateInvoicesCount, 1) + }) +}) diff --git a/apps/api/tests/functional/imports.spec.ts b/apps/api/tests/functional/imports.spec.ts new file mode 100644 index 0000000..a9dcdf1 --- /dev/null +++ b/apps/api/tests/functional/imports.spec.ts @@ -0,0 +1,164 @@ +import { test } from '@japa/runner' +import testUtils from '@adonisjs/core/services/test_utils' +import ImportDraft from '#models/import_draft' +import { createTestUser } from '../helpers/auth.js' +import { body, type ApiOk } from '../helpers/response.js' + +type DraftShape = { + id: string + filename: string + status: 'pending' | 'validated' | 'skipped' + extracted: { clientName: string; numero: string } + edited: { clientName: string; numero: string } +} + +type BatchShape = { + id: string + drafts: DraftShape[] +} + +test.group('Imports — POST /invoices/upload (mock JSON)', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test('crée 1 batch + N drafts depuis filenames (mock OCR)', async ({ + client, + assert, + }) => { + const { bearer } = await createTestUser() + const r = await client + .post('/api/v1/invoices/upload') + .headers(bearer) + .json({ filenames: ['facture-001.pdf', 'facture-002.pdf'] }) + + r.assertStatus(201) + const batch = body>(r).data + assert.lengthOf(batch.drafts, 2) + for (const d of batch.drafts) { + assert.equal(d.status, 'pending') + assert.exists(d.extracted.clientName) + assert.exists(d.extracted.numero) + } + }) + + test('refuse > 20 fichiers (422)', async ({ client }) => { + const { bearer } = await createTestUser() + const filenames = Array.from({ length: 21 }, (_, i) => `f-${i}.pdf`) + const r = await client + .post('/api/v1/invoices/upload') + .headers(bearer) + .json({ filenames }) + r.assertStatus(422) + }) +}) + +test.group('Imports — Validate / Skip draft', (group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + + test('validate transforme un draft en Invoice + status=validated', async ({ + client, + assert, + }) => { + const { bearer } = await createTestUser() + const upload = await client + .post('/api/v1/invoices/upload') + .headers(bearer) + .json({ filenames: ['facture-validate.pdf'] }) + const batch = body>(upload).data + const draft = batch.drafts[0] + + const r = await client + .post(`/api/v1/invoices/import-batch/${batch.id}/drafts/${draft.id}/validate`) + .headers(bearer) + .json({ + clientId: null, + clientName: 'Client OCR', + clientEmail: 'ocr@spec.test', + numero: 'F-OCR-01', + amountTtcCents: 50000, + issueDate: '2026-04-01T09:00:00.000Z', + dueDate: '2026-05-01T09:00:00.000Z', + planId: null, + }) + r.assertStatus(201) + + const fresh = await ImportDraft.findOrFail(draft.id) + assert.equal(fresh.status, 'validated') + assert.isNotNull(fresh.invoiceId) + }) + + test('validate sur draft déjà processed → 409', async ({ client }) => { + const { bearer } = await createTestUser() + const upload = await client + .post('/api/v1/invoices/upload') + .headers(bearer) + .json({ filenames: ['facture-once.pdf'] }) + const batch = body>(upload).data + const draft = batch.drafts[0] + + const payload = { + clientId: null, + clientName: 'Client', + clientEmail: 'c@spec.test', + numero: 'F-OCR-X', + amountTtcCents: 10000, + issueDate: '2026-04-01T09:00:00.000Z', + dueDate: '2026-05-01T09:00:00.000Z', + planId: null, + } + + await client + .post(`/api/v1/invoices/import-batch/${batch.id}/drafts/${draft.id}/validate`) + .headers(bearer) + .json(payload) + + const r2 = await client + .post(`/api/v1/invoices/import-batch/${batch.id}/drafts/${draft.id}/validate`) + .headers(bearer) + .json({ ...payload, numero: 'F-OCR-Y' }) + + r2.assertStatus(409) + }) + + test('skip → status=skipped, idempotent', async ({ client, assert }) => { + const { bearer } = await createTestUser() + const upload = await client + .post('/api/v1/invoices/upload') + .headers(bearer) + .json({ filenames: ['facture-skip.pdf'] }) + const batch = body>(upload).data + const draft = batch.drafts[0] + + const r = await client + .post(`/api/v1/invoices/import-batch/${batch.id}/drafts/${draft.id}/skip`) + .headers(bearer) + r.assertStatus(200) + const data = body>(r).data + assert.equal(data.status, 'skipped') + + // Re-skip = idempotent (200 toujours) + const again = await client + .post(`/api/v1/invoices/import-batch/${batch.id}/drafts/${draft.id}/skip`) + .headers(bearer) + again.assertStatus(200) + }) + + test('DELETE /import-batch/:id supprime le batch + drafts (cascade)', async ({ + client, + assert, + }) => { + const { bearer } = await createTestUser() + const upload = await client + .post('/api/v1/invoices/upload') + .headers(bearer) + .json({ filenames: ['a.pdf', 'b.pdf'] }) + const batch = body>(upload).data + + const del = await client + .delete(`/api/v1/invoices/import-batch/${batch.id}`) + .headers(bearer) + del.assertStatus(204) + + const remaining = await ImportDraft.query().where('batch_id', batch.id) + assert.lengthOf(remaining, 0) + }) +}) diff --git a/apps/api/tests/functional/invoices.spec.ts b/apps/api/tests/functional/invoices.spec.ts new file mode 100644 index 0000000..349c91c --- /dev/null +++ b/apps/api/tests/functional/invoices.spec.ts @@ -0,0 +1,294 @@ +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, name: string) { + const r = await client + .post('/api/v1/clients') + .headers(headers) + .json({ name, email: `${name.toLowerCase().replace(/\s+/g, '')}@spec.test` }) + return body>(r).data +} + +async function getStandardPlan(client: any, headers: Record) { + const r = await client.get('/api/v1/plans/standard-30j').headers(headers) + return body>(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>(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>>(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>(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>(created).data.id + + const first = await client.post(`/api/v1/invoices/${id}/mark-paid`).headers(bearer) + first.assertStatus(200) + const afterFirst = body>(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>(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>(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>(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>(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>(r).data + assert.equal(c2.all, 1) + assert.equal(c2.pending, 1) + }) +})