All checks were successful
Build & Deploy API / build-and-deploy (push) Successful in 1m17s
La migration 1778800000100_enrich_clients_for_invoicing avait créé les colonnes `address_line1` et `address_line2` (sans underscore avant le chiffre), mais Lucid v22 auto-snake-case `addressLine1` → `address_line_1` côté écriture. Résultat : tous les INSERT dans `clients` cassaient avec `column "address_line_1" of relation "clients" does not exist`. Bug latent — surfacé en lançant la suite functional complète après `node ace migration:run`. Affectait 20 tests Clients/Dashboard/Invoices. Fix : migration de rename qui aligne `address_line1` → `address_line_1` et `address_line2` → `address_line_2`. Le `RENAME COLUMN` préserve les données existantes en prod. Modèle Client simplifié (les déclarations manuelles ne sont plus nécessaires depuis que `schema.ts` les a régen). Bonus : fix du test `invoices.spec.ts` "liste paginée + meta total/page" qui créait 3 factures, ce qui dépasse la nouvelle limite Free 2 (ADR-023). Posé un `gracePeriodEndsAt` futur sur l'org du test pour bypass le quota. État après ce commit : 127 tests verts (60 unit + 67 functional). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
import { test } from '@japa/runner'
|
|
import testUtils from '@adonisjs/core/services/test_utils'
|
|
import RelanceTask from '#models/relance_task'
|
|
import CheckinTask from '#models/checkin_task'
|
|
import Invoice from '#models/invoice'
|
|
import Organization from '#models/organization'
|
|
import { hashCheckinToken } from '#services/checkin_token'
|
|
import { createTestUser, createTwoOrgs } from '../helpers/auth.js'
|
|
import { body, type ApiOk, type ApiOkPaged } from '../helpers/response.js'
|
|
import { DateTime } from 'luxon'
|
|
|
|
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 uniquement le check-in 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)
|
|
const checkins = await CheckinTask.query().where('invoice_id', inv.id)
|
|
assert.lengthOf(tasks, 0)
|
|
assert.lengthOf(checkins, 1)
|
|
assert.equal(checkins[0].status, 'scheduled')
|
|
})
|
|
|
|
test('clic check-in pending programme les relances sans rafale passée', async ({
|
|
client,
|
|
assert,
|
|
}) => {
|
|
const { bearer, org } = await createTestUser()
|
|
const c = await createClient(client, bearer, 'Checkin Pending')
|
|
const plan = await getStandardPlan(client, bearer)
|
|
const dueDate = DateTime.now().minus({ days: 20 }).set({ hour: 9, minute: 0, second: 0 })
|
|
|
|
const created = await client
|
|
.post('/api/v1/invoices')
|
|
.headers(bearer)
|
|
.json({
|
|
clientId: c.id,
|
|
clientName: c.name,
|
|
planId: plan.id,
|
|
numero: 'F-2026-CHECKIN',
|
|
amountTtcCents: 50000,
|
|
issueDate: dueDate.minus({ days: 15 }).toISO(),
|
|
dueDate: dueDate.toISO(),
|
|
})
|
|
|
|
created.assertStatus(201)
|
|
const inv = body<ApiOk<InvoiceShape>>(created).data
|
|
const plain = 'pending-token-spec'
|
|
const checkin = await CheckinTask.query().where('invoice_id', inv.id).firstOrFail()
|
|
checkin.tokenHash = hashCheckinToken(plain)
|
|
checkin.status = 'sent'
|
|
checkin.sentAt = DateTime.now()
|
|
await checkin.save()
|
|
|
|
const beforeTasks = await RelanceTask.query().where('invoice_id', inv.id)
|
|
assert.lengthOf(beforeTasks, 0)
|
|
|
|
const pending = await client.get(`/api/v1/checkin/${plain}/pending`)
|
|
pending.assertStatus(200)
|
|
|
|
const invoice = await Invoice.findOrFail(inv.id)
|
|
const tasks = await RelanceTask.query()
|
|
.where('invoice_id', inv.id)
|
|
.preload('planStep')
|
|
.orderBy('send_at', 'asc')
|
|
|
|
assert.equal(invoice.organizationId, org.id)
|
|
assert.lengthOf(tasks, 3)
|
|
for (const t of tasks) assert.equal(t.status, 'scheduled')
|
|
assert.isAtMost(Math.abs(tasks[0].sendAt.diffNow('minutes').minutes), 2)
|
|
assert.isAbove(tasks[1].sendAt.toMillis(), tasks[0].sendAt.toMillis())
|
|
})
|
|
|
|
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 plain = 'cancel-token-spec'
|
|
const checkin = await CheckinTask.query().where('invoice_id', id).firstOrFail()
|
|
checkin.tokenHash = hashCheckinToken(plain)
|
|
checkin.status = 'sent'
|
|
checkin.sentAt = DateTime.now()
|
|
await checkin.save()
|
|
|
|
const pending = await client.get(`/api/v1/checkin/${plain}/pending`)
|
|
pending.assertStatus(200)
|
|
|
|
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, org } = await createTestUser()
|
|
// Grace period pour bypass le quota Free 2 factures (cf. ADR-023).
|
|
// Sans ça, la 3e facture serait rejetée en 402 et le test verrait
|
|
// meta.total=2 au lieu de 3.
|
|
org.gracePeriodEndsAt = DateTime.utc().plus({ months: 2 })
|
|
await org.save()
|
|
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)
|
|
})
|
|
})
|