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, -bricksoffice listes admin, project-owner…) : parseResponse(endpoint, res.body) (tests/integration/helpers/app/parse-response.ts, throw shape mismatch) remplace .validate() (idtlt, { ok, errors }) — jamais un safeParse local.

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, 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('investors')).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.

Exception Lemonway : son axios sort par un HttpsProxyAgent (IP statique) → MSW résout l'URL avec un port réécrit (:80) et un matcher en URL absolue ne matche jamais (passthrough → proxy injoignable → timeout). Matcher par regex de path (cf. lemonway-account-spy, lemonway-p2p-handler).

É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)

Exporter une fn batch test-only depuis le *.worker.ts (ex. processPlayedLemonwayP2PBatch) et l'appeler directement — pas de new WorkerClass() : les workers tournent hors Nest (worker/main.ts, plus de WorkersModule). 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 les tests et les fixtures ; seul global-setup en réécrit, pour appliquer le port surchargé). DB/Redis sur localhost:5432 / localhost:6379 par défaut

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 par défaut)
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
  • 2 runs en parallèle sur la même machine : surcharger les ports et le nom de projet compose — § « Deux worktrees à la fois » de integration-test-conventions.mdc. Sans surcharge les deux stacks se collisionnent
  • 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.