Aller au contenu

Upsert des tests d'intégration API

Compléter l'existant sans dupliquer. Jamais de commit. Les tests s'ajoutent via ce skill ou sur demande explicite — jamais spontanément pendant une implem (cf. api-conventions.mdc §Tests).

Étape 0 — Charger les rules

Lire AVANT d'écrire (source de vérité, ne pas écrire de mémoire) :

  • projects/api/.cursor/rules/integration-test-conventions.mdc — le contrat : cibles, architecture 3 couches, reads/writes, seeding discriminant, descriptors, placement, naming, DO NOT
  • .cursor/rules/test-conventions.mdc — scope « tester le custom, pas la lib » + AI-slop checklist

Étape 1 — Collecter le scope

  • Cible nommée par l'utilisateur (controller, cron task, worker, webhook, module) → la prendre telle quelle
  • Sinon pending : git status --short + git diff HEAD ; branche : git diff develop...HEAD
  • Cibles éligibles : controller HTTP, cron task, worker, webhook — uniquement. Aucune dans le scope → le dire et s'arrêter ; logique pure → proposer /upsert-unit-tests

Étape 2 — Inventorier l'existant

Upsert = compléter, pas dupliquer : chercher le *.integration-test.ts de la cible (__tests__/ à côté du controller/task/worker) et lister les cas déjà couverts avant d'en proposer de nouveaux.

Étape 3 — Design des cas

  • Happy path + cas d'erreur clés (cf. AI-slop) ; frugalité : cf. test-conventions §Scope
  • Seeding discriminant (principe : contrat §Seeder) — mécanique : filtre → asserter l'absence de la row qui ne matche pas (ex. row deletedAt exclue) ; tri → séquence exacte toEqual([...]), pas toContain ; agrégat → ≥ 2 rows dans le groupe + 1 hors critère pour prouver le WHERE
  • Annoncer les cas prévus (1 ligne chacun) avant d'écrire

Étape 4 — Écrire

Decision tree

Je veux… Tu écris
Appeler une route Bricks Import du descriptor → endpoint.request.path(...)
Appeler une route better-auth String hardcodée (exception cadrée)
Construire un objet en mémoire (unit / Storybook) build<Entity>(overrides) depuis tests/integration/fixtures/<module>/<entity>.build.ts
Insérer une row en DB pour le test seed<Entity>(overrides) depuis tests/integration/fixtures/<module>/<entity>.seed.ts
Asserter qu'une row existe / a tel champ <Module>Repository.find<X>By* (ajoute la méthode au repo prod si elle manque)
Appel HTTP authentifié TestAuthHelper.createInvestor() (client) / .createAdmin() (admin) / .createNonAdminBackofficeUser() (403) → .set('Cookie', cookieHeader)
J'ajoute une entité TypeORM (@Entity) L'enregistrer dans helpers/db/all-entities.ts (lint check 3)
Tester une cron task describe('<task>CronTask')await runCronTask(taskCronTask()) + spies provider
Tester un worker infinite-loop describe('<X>Worker')new XWorker().processBatch() + asserts DB (cf. Workers)

Routes via descriptors

// GET sans params — api-communication : path sans slash initial
req().get(`/${getPortfolioRevenuesEndpoint.request.path}`).query({ startDate, endDate })

// GET sans params — api-communication-bricksoffice : path() inclut déjà le slash initial
req().get(getAdminHomeNewsEndpoint.request.path())

// DELETE avec path param typé
req().delete(deleteCustomerEndpoint.request.path(customerId))

// POST avec body typé via `satisfies` (inline, pas de const intermédiaire)
req()
  .post(`/${postMfaTriggerEndpoint.request.path}`)
  .send({ action: 'withdraw' } satisfies typeof postMfaTriggerEndpoint.request.body.T)

// Response validée par le validator du descriptor (contract check côté test)
const parsed = postSignupReferralEndpoint.response.validate(res.body)
if (!parsed.ok) throw new Error(`shape mismatch: ${JSON.stringify(parsed.errors)}`)
expect(parsed.value).toEqual({ referrerCode: '...' })

Descriptors zod (api-communication-internal, project-owner…) : .response.parse(res.body) (throw) remplace .validate() (idtlt, { ok, errors }).

Webhooks providers (Checkout, Lemonway…) : path hardcodé autorisé, payload via un builder typé par le validator prod, auth avec le vrai x-api-key lu dans l'env — jamais bypassé (cf. checkout-webhook.integration-test.ts).

Routes internes (/internal/*) : descriptors api-communication-internal, header x-api-key via helpers/internal/api-keys.ts (ticketingKey(), iaWhatsappKey(), …) — erreur explicite si la clé Doppler manque. L'invariant 401-sans-clé est couvert une fois pour toutes les routes par le sweep internal-routes-api-key.integration-test.ts (extractInternalRoutes(app.printRoutes(...))) — ne pas re-tester l'auth par route ; happy path uniquement (cf. api-conventions.mdc §Routes internes).

Writes — quelle couche, grosses entités

// build : pure, importable depuis un unit test
export const buildBetterAuthUser = (overrides = {}) => ({ email: `test-${...}`, ... })

// seed : DB write, passe par l'API publique de la lib (signUpEmail) ou le repo prod
export const seedBetterAuthUser = async (overrides = {}) => {
  const built = buildBetterAuthUser(overrides)
  await testAuth.api.signUpEmail({ body: { ... } })
  return { ...built, id }
}

Quelle couche d'écriture (par ordre de préférence) : l'API publique de la lib si elle existe (auth.api.signUpEmail) → sinon getAppDataSource().manager.insert(Entity, …) (TypeORM, respecte les contraintes entité) → Kysely (DbHelper.testDb()) uniquement pour les tables sans entité TypeORM (ex. better_auth_*).

Grosses entités (projects → entité legacy Property, …) : un buildX(overrides) plat dont les défauts = le cas majoritaire, plus des fragments d'override composables par variante métier (asRoyalty(), withRepaymentDefault(), …) qu'on merge dans les overrides ; colonnes jsonb stubées ({}) sauf si le test les lit. Discriminant requis (contrat §Writes) : chaque call-site le déclare via son fragment (...asApproved() / ...asDeclined()). Graphe de FK (contrat §Writes) : seedProject insère le SPV requis (spvId FK NOT NULL) avant l'INSERT projet. Pour une assertion sur un champ dérivé du payload, lis la valeur attendue sur l'objet construit (webhook.data.source.last_4) plutôt que de redupliquer les constantes du fixture en dur. Fixtures créés au fil de l'eau par la PR qui en a besoin, jamais en masse à l'avance.

Composition des fragments (canon : fixtures/projects/project.build.ts) — as* pose le discriminant, with* pose un état métier (ses champs corrélés ensemble, ex. withRepaymentDelay() = status + financialStatus + repaymentDelayOrDefaultStatus, incohérence impossible), un fragment peut prendre le bouton que le test asserte (withFunding({ investorCount: 3 })) :

await seedProject({
  ...asObligation(),
  ...withRepaymentDelay(),
  ...withFunding({ investorCount: 3 }),
  name: { fr: 'Projet X' }, // inline gagne sur tout
})

Précédence = ordre du spread : défauts du build < fragments < overrides inline. ⚠️ Spread shallow : deux fragments qui touchent la même clé objet (funding) ne se deep-mergent pas — le dernier remplace l'objet entier ; un aspect combiné = un seul fragment qui re-spread le builder de base (cf. withFundingEnded(){ ...buildFunding(), ended }).

Typage des overrides : Partial<Row> & Pick<Row, 'customerId'> — les FK que le test contrôle sont requises via Pick (canon : referral-link.build.ts). Le seed crée lui-même ses dépendances structurelles (seedProject → SPV) ; la FK-lien reste fournie par le caller. Le seed retourne l'objet tel que le test l'assertera (modèle rebuild ou entité insérée). Variantes aux défauts trop divergents → plusieurs builders acceptés (cf. payment-to-be-assigned.build.ts).

createdAt (@CreateDateColumn) : pose-le directement dans l'objet create/insert du seed (cf. seedWalletTransaction), ne te repose pas sur l'ordre d'insertion. @CreateDateColumn n'écrase pas la valeur fournie sur Postgres — le défaut DB ne s'applique que si elle est absente. Donc pas d'UPDATE post-insert.

Canon de référence fixtures : tests/integration/fixtures/better-auth/.

Auth

import { getAdminHomeNewsEndpoint } from '@bricks-common/api-communication-bricksoffice'
import { req } from 'tests-integration/helpers/app/test-app'
import { TestAuthHelper } from 'tests-integration/helpers/auth/auth-helper'

const { customerId, cookieHeader } = await TestAuthHelper.createAdmin()
await req().get(getAdminHomeNewsEndpoint.request.path()).set('Cookie', cookieHeader).expect(200)

customerId est résolu après le sign-up (le hook session.create.after de better-auth upserte la Customer) — utile pour seeder des FKs (referral-link, …) liées à l'investisseur.

Instance admin ≠ instance client. AdminAuthGuard lit la session via adminAuth (préfixe bricks-admin.session_token, monté sur /api/auth/admin/*), distincte de l'instance client. createAdmin() sign-up sur l'instance client puis ouvre une session admin via adminAuth.$context.internalAdapter.createSession + cookie signé (signBetterAuthSessionCookieValue) — le cookie client de createInvestor() n'est pas lu par le guard (préfixe distinct) → 401, pas 403. Pour asserter le 403, createNonAdminBackofficeUser() ouvre une session sur l'instance admin avec le rôle user. L'instance admin n'expose plus le sign-in email/password (Google OAuth uniquement).

Mock des services externes (MSW)

Tout appel HTTP sortant (Customer.io, Lemonway, Checkout, Pappers…) est intercepté à la frontière réseau par MSW (msw/node), jamais en monkey-patchant la méthode JS : on teste le vrai chemin axios et on asserte sur la requête réellement émise. MSW n'est pas un vi-mock — son lifecycle (listen/resetHandlers) est indépendant de restoreMocks/isolate: false.

Lifecycle : ensureMswServerStarted() (dans helpers/providers/msw-server.ts) appelle mswServer.listen(...) une fois au beforeAll de core/setup-file.ts ; mswServer.resetHandlers() en beforeEach. Le handler onUnhandledRequest laisse passer le loopback (supertest tape l'app en HTTP local) et fait échouer tout autre host non mocké → tests hermétiques. Si un service légitime apparaît, ajoute son handler plutôt que de relâcher en 'bypass'.

Pattern par provider (canon : providers/customerio-email-spy.ts) : - un xHandler() = http.<method>(absoluteUrl, resolver) qui pousse la requête dans un captured[] module-level et renvoie une réponse de succès réaliste, - enregistré dans msw-server.ts (setupServer(xHandler(), …)), - un reader (service-object) qui lit captured[] pour les assertions.

Échec simulé par test : mswServer.use(http.post(url, () => HttpResponse.json(…, { status: 500 }))) ; resetHandlers() du beforeEach le révoque. Si le spy expose des factories paramétrées (réponse custom / status d'échec, cf. gestion-defauts-news-spy), les préférer au handler inline : l'URL et la capture restent à un seul endroit. Le stub de réponse passe par le validator prod avant renvoi (garde anti-dérive : contrat qui bouge → stub qui casse bruyamment, cf. lemonway-account-spy) et sa shape wire vient d'un builder fixture (cf. fixtures/gestion-defauts/gestion-defauts-news.build.ts — superset wire typé, extras que le schema doit ignorer) plutôt que d'un literal répété par test.

Spies disponibles (helpers/providers/, tous clearés en beforeEach) : CustomerioEmailSpy (body on-the-wire des emails ; getEmailVerificationOTP(email), waitForResetPasswordToken(email, timeoutMs?) — async poll qui contourne le fire-and-forget —, all()/clear()), CustomerioTrackSpy (.byEventName(), .byCustomerId()), SlackSpy (.byTextIncludes()), LemonwayAccountSpy (GET /accounts/:id, compte KYC2 stub validé contre le schema prod), ProjetAnalyseSpy (.projectUpdatedCalls()), GestionDefautsNewsSpy (news contrat v2 : .getNewsCalls(), .createNewsCalls(), .uploadAttachmentCalls() ; réponses paramétrées via les builders wire).

Cron tasks (graphile-worker)

Une cron task se teste comme un contrôleur, sauf que l'entrée n'est pas une route : on appelle la factory directement, en process. On ne lance pas le runtime graphile-worker.

  • Invocation : await runCronTask(taskCronTask()) (helpers/cron/run-cron-task.ts). Garde le vrai type Task graphile ; une cron ignore payload/helpers → le stub JobHelpers vit à un seul endroit, zéro cast aux call sites.
  • Dry-run : IS_DRY_RUN: 'false' est posé globalement dans vitest.config.integration.ts → la cron commit ses writes et émet ses appels provider. Ne le set jamais par test.
  • Délais métier : sleeps (ex. stabilisation réconciliation compteur) neutralisés via env globale dans vitest.config.integration.ts (FUNDING_COUNTER_RECONCILIATION_STABILIZATION_DELAY_MS: '1'), jamais par test. Pour muter Redis/PG entre deux lectures de stabilisation : FundingCounterReconciliationTestHooks.setOnBeforeRetry(...) ; pour simuler le drain assignation pendant l'attente : setOnBeforeDrainRetry(...) (no-op en prod tant qu'ils ne sont pas branchés) — reset en afterEach.
  • Assertions : reads via repo prod + spies provider (CustomerioTrackSpy, SlackSpy, …) pour les effets de bord externes.

Canon : cron-task/investor-payout/__tests__/investor-payout-capital-repayment.integration-test.ts.

Workers (safeInfiniteLoop)

Un worker boucle infinie expose une méthode publique single-batch (le corps de l'iterationFn) ; le test l'appelle directement sur une instance new — pas de DI Nest (les workers ne sont enregistrés que dans WorkersModule, absent de l'AppModule de test) ni de helper. Asserter l'état DB via repos prod, jamais le retour du batch : les erreurs des handlers sont catchées et avalées, le batch répond 'jobs-processed' quand même. Canon : worker/__tests__/played-lemonway-p2p.integration-test.ts.

Cas particuliers

  • Side effect async (hook better-auth fire-and-forget, write différé) : poll(fn, { timeoutMs }) jusqu'à ce que la row DB apparaisse — prouver la row, pas le 200 (canon : auth-hooks.integration-test.ts)
  • Réponse binaire (XLSX, PDF) : côté supertest .buffer(true).parse((res, cb) => …concat des chunks en Buffer…), puis parser avec la lib idoine (canon : export-transactions.integration-test.ts)
  • Mutation d'un état seedé (arrange complexe) : passer par le repo prod dans ky_safePgTransaction (lock + update), pas d'UPDATE direct (canon : project-analysis-internal.integration-test.ts)

Harness — boot-once, entités, isolation

  • Full AppModule Nest + Fastify monté une seule fois par session : setup-file.ts appelle getTestApp() en beforeAll. Les tests tapent req() (= supertest(server)) directement — pas de let testApp ni de beforeAll par fichier
  • Conf Vitest : singleFork: true + isolate: false. Sans ces flags Vitest re-boote l'app par fichier — symptôme : [AppSharedPgPool] Pool created × N dans les logs
  • Entités TypeORM : le glob d'entités prod ne résout pas sous le loader TS de Vitest → la DataSource de test lit une liste statique helpers/db/all-entities.ts. Toute nouvelle entité @Entity doit y être ajoutée, sinon No metadata for "X" was found (lint check 3). ⚠️ On décommissionne TypeORM — une nouvelle entité est un signal à questionner
  • Isolation : setup-file.ts exécute DbHelper.resetAll() + redis.flushall() + CustomerioEmailSpy.clear() avant chaque test (__flyway_schema_history__ préservée). resetAll() DELETE les tables non-vides sous session_replication_role = 'replica' (~15 ms/test — pas de TRUNCATE … CASCADE : ACCESS EXCLUSIVE + vide le composant FK entier même vide, ~485 ms/test). Les séquences ne sont pas remises à zéro. Requiert un user superuser (bricks_admin, cf. snapshot)
  • Env vars via Doppler config dev_integration_tests (source unique, zéro process.env.X = ... dans le code de test). DB/Redis sur localhost:5432 / localhost:6379

Helpers transversaux

tests/integration/helpers/
├── app/
│   ├── test-app.ts            # getTestApp() (boot once, dans setup-file) + req() → supertest(server)
│   └── internal-routes.ts     # extractInternalRoutes(app.printRoutes(...)) → [{ method, path }]
├── auth/
│   ├── auth-helper.ts         # TestAuthHelper.createInvestor() / .createAdmin() + extractSessionCookie(res)
│   └── better-auth-assertions.ts  # expectBetterAuthError(res, status, code)
├── db/
│   ├── db-helper.ts           # DbHelper.testDb() (Kysely) + resetAll()
│   └── all-entities.ts        # liste statique des entités TypeORM (DataSource de test)
├── internal/
│   └── api-keys.ts            # ticketingKey(), iaWhatsappKey(), … — x-api-key des routes /internal
├── providers/                 # un spy MSW par service externe — miroir de src/__new/lib/providers/
│   ├── msw-server.ts          # mswServer = setupServer(...handlers) — intercepteur HTTP partagé
│   └── *-spy.ts               # customerio-email, customerio-track, slack, lemonway-account, projet-analyse
├── cron/
│   └── run-cron-task.ts       # runCronTask(factory)
├── load-doppler-env.ts        # récupère l'env Doppler si absente (play button)
└── poll.ts                    # poll(fn, { timeoutMs }) — retry jusqu'à ce que fn renvoie une valeur

Sous-dossiers par concern ; utils purs (poll.ts, load-doppler-env.ts) à la racine. Service-object pattern obligatoire (PascalCase + export type X = typeof X) — voir api-conventions.mdc.

Étape 5 — Lancer

Le coût d'une session est un tax fixe one-time (boot AppModule + up docker + import snapshot + validate Flyway), pas du travail par-test. Le bon inner-loop garde le stack + l'app chauds :

pnpm --filter @bricks/api test:integration:watch # watch mode (laisse compose up entre runs) — la boucle rapide
pnpm --filter @bricks/api test:integration       # one-shot : up compose → vitest → down -v — run complet / CI
pnpm --filter @bricks/api test:integration:up    # juste up (debug DB sur localhost:5432)
pnpm --filter @bricks/api test:integration:down  # teardown propre
  • Le play button / Test Explorer VS Code marche sans test:integration:up manuel (global-setup.ts est idempotent : up -d --wait sur postgres/redis puis run --rm flyway — Flyway hors du --wait, le conteneur one-shot fait flaker up --wait après un Exited(0), Doppler récupéré par la config Vitest). Relance test:integration:up (qui garde --build) après un changement de postgres.Dockerfile ou snapshot.sql
  • vitest.config.integration.ts fait process.chdir(__dirname) : le code prod lit des assets en chemin relatif au cwd (ex. fs.readFileSync('public/bricks-logo.b64') dans pdf.ts) — sans ce chdir, ENOENT depuis l'extension Vitest
  • Pas de 2 runs en parallèle sur la même machine (ports fixes 5432/6379)
  • Snapshot (docker/snapshot.sql = schéma + __flyway_schema_history__) : pas besoin de régénérer à chaque migration, Flyway rejoue le delta au boot. Quand le cold start ralentit : pnpm --filter @bricks/api test:integration:snapshot puis review + commit du diff (schéma only, < 1 MB)

Étape 6 — Auto-review

Proposer /review-integration-tests sur le diff produit — review contre les conventions (langue, AI slop, doublons, bonne cible, fixtures, frugalité). Libre choix, pas obligatoire.