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
deletedAtexclue) ; tri → séquence exactetoEqual([...]), pastoContain; agrégat → ≥ 2 rows dans le groupe + 1 hors critère pour prouver leWHERE - 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 typeTaskgraphile ; une cron ignorepayload/helpers→ le stubJobHelpersvit à un seul endroit, zéro cast aux call sites. - Dry-run :
IS_DRY_RUN: 'false'est posé globalement dansvitest.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 enafterEach. - 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
AppModuleNest + Fastify monté une seule fois par session :setup-file.tsappellegetTestApp()enbeforeAll. Les tests tapentreq()(=supertest(server)) directement — pas delet testAppni debeforeAllpar 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é@Entitydoit y être ajoutée, sinonNo metadata for "X" was found(lint check 3). ⚠️ On décommissionne TypeORM — une nouvelle entité est un signal à questionner - Isolation :
setup-file.tsexécuteDbHelper.resetAll()+redis.flushall()+CustomerioEmailSpy.clear()avant chaque test (__flyway_schema_history__préservée).resetAll()DELETEles tables non-vides soussession_replication_role = 'replica'(~15 ms/test — pas deTRUNCATE … 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éroprocess.env.X = ...dans les tests et les fixtures ; seulglobal-setupen réécrit, pour appliquer le port surchargé). DB/Redis surlocalhost:5432/localhost:6379par 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:upmanuel (global-setup.tsest idempotent :up -d --waitsur postgres/redis puisrun --rm flyway— Flyway hors du--wait, le conteneur one-shot fait flakerup --waitaprès unExited(0), Doppler récupéré par la config Vitest). Relancetest:integration:up(qui garde--build) après un changement depostgres.Dockerfileousnapshot.sql vitest.config.integration.tsfaitprocess.chdir(__dirname): le code prod lit des assets en chemin relatif au cwd (ex.fs.readFileSync('public/bricks-logo.b64')danspdf.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:snapshotpuis 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.