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, 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 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)¶
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
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 le code de test). DB/Redis surlocalhost: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: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- 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: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.