Setup : - .env.test étoffé : DRIVE_DISK=fs, MAIL_DRIVER=smtp local, OCR_PROVIDER=mock. Réutilise la DB rubis_dev avec global transactions par test (rollback auto, isolation parfaite). - Schedulers (relance + checkin) détectent NODE_ENV=test et skippent BullMQ.add. Les tasks DB sont quand même créées (utiles pour assertions) mais aucun job orphelin n'arrive en Redis après rollback de tx. - helpers/auth.ts : factory createTestUser() qui crée org + user + 4 plans pré-fournis dans une tx, retourne user/org/accessToken/bearer header. createTwoOrgs() pour les tests cross-org à venir. Tests fonctionnels auth (tests/functional/auth.spec.ts) : - Signup : crée user + org + 4 plans pré-fournis (vérifie les slugs), refuse email mal formé / password court / email déjà pris - Login : émet AuthSession avec credentials valides, rejette mauvais password / email inconnu - Bearer auth : 401 sans token, 401 avec token bidon, 200 avec token valide - Logout : révoque le token courant, requêtes suivantes en 401 - Onboarding : PATCH /organizations/me pose onboardingCompletedAt à la 1re mise du nom, idempotent ensuite Pour lancer : `pnpm -F api test`
136 lines
4.3 KiB
TypeScript
136 lines
4.3 KiB
TypeScript
import { DateTime } from 'luxon'
|
|
import RelanceTask from '#models/relance_task'
|
|
import Plan from '#models/plan'
|
|
import Invoice from '#models/invoice'
|
|
import { getQueue } from '#services/queue'
|
|
import app from '@adonisjs/core/services/app'
|
|
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
|
|
|
const RELANCE_QUEUE = 'relances'
|
|
|
|
/**
|
|
* En tests, les RelanceTasks DB sont créées (utile pour assertions) mais
|
|
* l'enqueue BullMQ est skippé : les tx auto-rollback laisseraient des jobs
|
|
* orphelins en Redis sinon, et on ne veut pas dépendre d'une instance
|
|
* Redis live pour tourner les tests.
|
|
*/
|
|
function shouldEnqueue(): boolean {
|
|
return app.getEnvironment() !== 'test'
|
|
}
|
|
|
|
/**
|
|
* Programme toutes les relances d'une facture selon son plan.
|
|
*
|
|
* - Pour chaque step du plan, calcule sendAt = invoice.dueDate + offsetDays
|
|
* - Crée une RelanceTask `scheduled`
|
|
* - Enqueue un BullMQ job `send-relance` avec delay = sendAt - now
|
|
*
|
|
* Si sendAt est dans le passé (cas : facture importée avec une dueDate
|
|
* ancienne), on programme quand même la task pour `now + 1 min` — l'user
|
|
* est probablement en train de "rattraper" un retard, l'envoi immédiat
|
|
* est cohérent.
|
|
*
|
|
* Idempotent par invoice.id : si des tasks `scheduled` existent déjà
|
|
* pour cette facture, on les annule avant de re-programmer (cas où on
|
|
* change de plan).
|
|
*/
|
|
export async function scheduleRelancesForInvoice(
|
|
invoice: Invoice,
|
|
trx?: TransactionClientContract
|
|
): Promise<RelanceTask[]> {
|
|
if (!invoice.planId) return []
|
|
|
|
const plan = await Plan.query(trx ? { client: trx } : undefined)
|
|
.where('id', invoice.planId)
|
|
.preload('steps', (q) => q.orderBy('order', 'asc'))
|
|
.first()
|
|
if (!plan) return []
|
|
|
|
// Cancel les tasks scheduled existantes (re-scheduling après changement
|
|
// de plan ou de dueDate).
|
|
const existing = await RelanceTask.query(trx ? { client: trx } : undefined)
|
|
.where('invoice_id', invoice.id)
|
|
.where('status', 'scheduled')
|
|
const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null
|
|
for (const t of existing) {
|
|
if (t.queueJobId && queue) {
|
|
await queue.remove(t.queueJobId).catch(() => {
|
|
// Ignore — le job peut déjà être consommé.
|
|
})
|
|
}
|
|
t.useTransaction(trx ?? null as never)
|
|
t.status = 'cancelled'
|
|
await t.save()
|
|
}
|
|
|
|
const now = DateTime.now()
|
|
const created: RelanceTask[] = []
|
|
|
|
for (const step of plan.steps) {
|
|
const sendAtRaw = invoice.dueDate.plus({ days: step.offsetDays })
|
|
const sendAt = sendAtRaw < now ? now.plus({ minutes: 1 }) : sendAtRaw
|
|
|
|
const task = await RelanceTask.create(
|
|
{
|
|
organizationId: invoice.organizationId,
|
|
invoiceId: invoice.id,
|
|
planStepId: step.id,
|
|
sendAt,
|
|
status: 'scheduled',
|
|
sentAt: null,
|
|
queueJobId: null,
|
|
},
|
|
trx ? { client: trx } : undefined
|
|
)
|
|
|
|
const delay = Math.max(0, sendAt.toMillis() - now.toMillis())
|
|
const job = queue
|
|
? await queue.add(
|
|
'send-relance',
|
|
{ taskId: task.id },
|
|
{
|
|
delay,
|
|
// Idempotency : un seul job actif par task.
|
|
// BullMQ 5+ interdit `:` dans les custom jobIds → tiret.
|
|
jobId: `relance-${task.id}`,
|
|
// Retry exponentiel — si Mailpit est down, BullMQ retry 5x avec
|
|
// backoff (cf. backend.md §13.2).
|
|
attempts: 5,
|
|
backoff: { type: 'exponential', delay: 30_000 },
|
|
}
|
|
)
|
|
: null
|
|
|
|
task.queueJobId = job?.id ?? null
|
|
await task.save()
|
|
created.push(task)
|
|
}
|
|
|
|
return created
|
|
}
|
|
|
|
/**
|
|
* Annule toutes les relances futures d'une facture (appelé quand on
|
|
* mark-paid ou cancel une invoice). Les tasks déjà `sent` restent
|
|
* intactes — c'est de l'historique.
|
|
*/
|
|
export async function cancelFutureRelances(
|
|
invoiceId: string,
|
|
trx?: TransactionClientContract
|
|
): Promise<void> {
|
|
const tasks = await RelanceTask.query(trx ? { client: trx } : undefined)
|
|
.where('invoice_id', invoiceId)
|
|
.where('status', 'scheduled')
|
|
if (tasks.length === 0) return
|
|
|
|
const queue = shouldEnqueue() ? getQueue(RELANCE_QUEUE) : null
|
|
for (const t of tasks) {
|
|
if (t.queueJobId && queue) {
|
|
await queue.remove(t.queueJobId).catch(() => {})
|
|
}
|
|
t.useTransaction(trx ?? null as never)
|
|
t.status = 'cancelled'
|
|
await t.save()
|
|
}
|
|
}
|