# Integration Test Framework Integration tests run against real testcontainers (PostgreSQL × 2, Redis, Kafka, LocalStack) with the full Express app booted. Tests execute in parallel (`maxWorkers: 14`) in random order sharing one database. **Every test must be completely self-contained.** For high-level integration test patterns, see the parent `humand-packages/monolith/AGENTS.md` — Testing section. This file documents the internals of the framework itself. ## Running Tests **The full suite takes a long time. Locally, always run only the affected tests.** CI runs the full suite across 14 parallel shards — let it do that job. ### Build dependencies Integration tests boot the full app, which imports compiled output from several packages. These must be built before the test process starts: | Nx target | Package | |-----------|---------| | `common:build` | `humand-packages/common` | | `app-ratings:build` | `humand-packages/app-ratings` | | `scheduled-actions:build` | `humand-packages/scheduled-actions` | | `community-features:build` | `humand-packages/community-features` | | `migrations-runner:build` | `humand-packages/migrations-runner` | | `monolith:build-proto` | gRPC proto generation | **Always prefer running via Nx** so that dependencies are resolved and cached automatically: ```bash # Full suite — nx builds deps (cached) then runs tests pnpm nx run monolith:test-integration-all # Pass extra jest flags with -- pnpm nx run monolith:test-integration-all -- --testPathPattern="test-integration/api/recognitions" ``` Running `pnpm jest --config jest.integration.config.ts` directly skips nx and will fail if any of the above packages haven't been built yet. If you must run jest directly (e.g. for a quick re-run after nx already resolved deps), build deps first: ```bash pnpm nx run-many --target=build --projects=common,app-ratings,scheduled-actions,community-features,migrations-runner pnpm nx run monolith:build-proto ``` ### Locally: affected tests only (preferred) ```bash cd humand-packages/monolith # 1. Preview what would run (same logic as CI, no side effects) node --no-warnings --experimental-strip-types --experimental-detect-module scripts/validate-affected.ts # 2. Get the pattern and run it PATTERN=$(node --no-warnings --experimental-strip-types --experimental-detect-module scripts/get-affected-test-pattern.ts --type=integration) # PATTERN is one of: # ALL → full suite (common or monolith-shared changed) # NONE → nothing affected, skip # test-integration/api/(recognitions|users) → only these modules # 3a. If NONE or ALL — don't run locally, push and let CI handle it # 3b. If specific pattern: pnpm jest --config jest.integration.config.ts --testPathPattern="$PATTERN" --passWithNoTests ``` The script uses `nx show projects --affected` (comparing against `origin/develop`) and maps affected Nx projects to their `test-integration/api/{module}` folders. If `common`, `monolith-shared`, or `migrations-runner` is affected it returns `ALL`. If >50% of modules are affected it also returns `ALL`. ### Locally: specific file or module ```bash cd humand-packages/monolith # Single file (any substring of the path works — it's a regex) pnpm jest --config jest.integration.config.ts --testPathPattern="test-integration/api/recognitions/get-product" # All tests in a module pnpm jest --config jest.integration.config.ts --testPathPattern="test-integration/api/recognitions" # Filter by test name (matches concatenated describe + it strings) pnpm jest --config jest.integration.config.ts --testNamePattern="should return 404" # Both filters pnpm jest --config jest.integration.config.ts \ --testPathPattern="test-integration/api/recognitions" \ --testNamePattern="should return 404" ``` ### CI: full suite with sharding CI runs `get-affected-test-pattern.ts --type=integration` to get the pattern, then fans out across 14 shards: ```bash pnpm jest --config jest.integration.config.ts \ --testPathPattern="" \ --passWithNoTests \ --shard=/14 ``` Each shard covers ~1/14 of the matched test files in parallel. Coverage JSONs from each shard are merged by `nyc` at the end. You never need to run shards locally. ## Directory Structure ``` test-integration/ ├── api/{module}/{action}.test.ts # Test files (385 files, ~70 domains) ├── commands/{module}/{action}.ts # Reusable command classes (~855 files) ├── sessions/sessions.ts # Session types, base command classes ├── mockedPortsDI.ts # Port overrides injected at boot ├── setup/ │ ├── global.ts # Jest globalSetup — boots containers + app │ ├── teardown.ts # Jest globalTeardown — stops containers │ ├── jest.setup.ts # Per-test retry config and jest-extended │ ├── app/humand.ts # App initializer, DI wiring │ ├── containers/ # postgres, redis, kafka, localStack │ └── mocks/ # Mock adapters for external ports │ ├── baseMockAdapter.ts # Base class for all mocks — call tracking via Proxy │ ├── helpers.ts # getServerAdapter(), getMock() │ ├── attachments/, audiences/, communityFeatures/, eventHandler/ │ ├── facialRecognition/, featureFlags/, learningSession/, prode/ │ ├── roles/, services/, timeTracking/ │ └── services/ │ ├── cloudMessaging/ # MockFirebaseService, MockApnService │ ├── metricRecorderMock.ts │ ├── otp/otpTwilio.ts │ └── realTimeNotificator/ ├── utils/ │ ├── queryHelpers.ts # runSelectQueryOnMainDb, runCountQueryOnMainDb, runExecuteQueryOnMainDb, runExecuteQueryOnInsightsDb │ ├── queryToken.ts # generateQueryToken — signed JWT for validateQueryToken middleware │ ├── eventHandler.ts # checkMessageSentToEventHandler │ ├── insightsEventHandler.ts # checkMessagesCountFromInsightsQueue │ ├── webhooks.ts # waitForWebhookMessage │ ├── redisHelper.ts # Redis query helpers │ ├── permissionsHelper.ts # Permission assertion helpers │ ├── fakeImageHelper.ts # Generate test image buffers │ ├── pdfValidationHelpers.ts # PDF assertion helpers │ ├── reportsHelper.ts # Report download helpers │ ├── signatureTestHelpers.ts # Signature workflow helpers │ ├── notificationCenter.ts # Notification assertion helpers │ ├── cdc.ts # CDC event helpers │ ├── multimediaHelper.ts # Multimedia upload helpers │ ├── audienceInstanceUserOutboxHelpers.ts │ ├── externalIdpMock.ts # SAML/SSO mock │ ├── rawRequest.ts # Raw HTTP (no session wrapper) │ ├── queryParams.ts # Query string helpers │ └── sessions.ts # createAdminSession / createUserSession helpers ├── fixtures/ │ ├── example-customer-idp-metadata.xml │ ├── example-idp-signingCert.cer / .key │ ├── huge-token.xml.base64 │ ├── profile-pic-1.jpg │ ├── sample-1.pdf, small-example-pdf-file.pdf, SM047-undefined.pdf │ └── test-video.mov / .mp4 └── database/ └── pgConnection.ts # pg client factory used by queryHelpers ``` ## Naming Conventions | Artifact | Convention | Example | |----------|-----------|---------| | Test file | `camelCase.test.ts` | `sessionCacheLifecycle.test.ts`, `getUserById.test.ts` | | Command file | `camelCase.ts` | `getUserById.ts`, `createApiKey.ts` | | Test directory | `camelCase` (matches module name) | `api/timeTracking/`, `api/auth/` | | Command directory | `camelCase` (matches module name) | `commands/timeTracking/`, `commands/auth/` | ## Session Types All live in `sessions/sessions.ts`. All HTTP calls go to `http://localhost:8080/api/v1` by default. | Class | Auth header | Use for | |-------|-------------|---------| | `UserSession` | none | Unauthenticated requests | | `LoggedSession` | `Bearer {JWT}` | Standard authenticated user | | `BotAppLoggedSession` | `Bearer {JWT}` (bot) | Bot app endpoints | | `BotAppImpersonatingUserSession` | `Bearer {JWT}` + `X-Humand-User-Id` | Bot acting as a user | | `TokenSession` | `Bearer {token}` | Raw token (one-time tokens, etc.) | | `PublicAPISession` | `Basic {apiKey}` | Public API — baseURL `localhost:8080/public` | All sessions expose: `apiPost`, `apiGet`, `apiPut`, `apiPatch`, `apiDelete`. Execute a command: `session.executeNoResult(command)` → returns `O`. Or `session.execute(command)` → returns `{ input, output }`. **Prefer `executeNoResult` over `execute`.** Use `execute` only when you need both the input and the output together (e.g., for command chaining). In all other cases, use `executeNoResult` — it returns the output directly without the wrapper object. ## Command Pattern Commands encapsulate one API call with its assertions. Location: `commands/{module}/{camelCaseAction}.ts`. ```typescript // commands/recognitions/get-product.ts export class GetProduct extends BaseLoggedRequestCommand<{ id: number }, RecognitionProduct> { constructor(input: { id: number }) { super(input); } async _executeOn(session: LoggedSession): Promise { const res = await session.apiGet(`/recognitions/products/${this.input.id}`); expect(res.status).toBe(OK); return res.data; } } ``` **Base class selection:** | Base class | Session type | |-----------|-------------| | `BaseLoggedRequestCommand` | `LoggedSession` | | `BaseUserRequestCommand` | `UserSession` | | `BasePublicRequestCommand` | `PublicAPISession` | | `BaseBotAppsRequestCommand` | `BotAppLoggedSession` | **Commands with no body:** Use `BaseLoggedRequestCommand` and `super()` — never use `void` as the input type (it breaks the type system). **Command chaining:** `session.execute(commandA.chain(CommandB, result => ({ ... })))` — useful for setup pipelines in `beforeAll`. ## Bootstrap: CreateCommunity `commands/instances/createCommunity.ts` exports `CreateCommunity` (the command class) and the `Community` return type. This is the standard setup for ~90% of tests. ```typescript import { CreateCommunity, Community } from '../../commands/instances/createCommunity'; describe('My Feature', () => { let community: Community; beforeAll(async () => { community = await CreateCommunity.new(); }); it('should ...', async () => { const result = await community.adminSession.executeNoResult( new CreateMyEntity({ name: faker.string.uuid() }), ); }); }); ``` `community` has: - `community.instance` — the created instance - `community.adminSession` — `LoggedSession` for the admin user - `community.users[]` — array of N default users (check `CreateCommunity.new()` signature for options) **One community per `describe`, one user per `it`.** Creating a community spins up a full instance — it's expensive. Create it once in `beforeAll` at the `describe` level and reuse it across all `it` blocks. Each `it` that needs an isolated actor should use a different user from `community.users` (or create an additional user with a command), not a new community. ```typescript // Good describe('GET /recognitions/products/:id', () => { let community: Community; beforeAll(async () => { community = await CreateCommunity.new({ userCount: 3 }); }); it('admin can see the product', async () => { // uses community.adminSession }); it('regular user can see the product', async () => { // uses community.users[0].session — different actor, same instance }); it('returns 404 for unknown id', async () => { // uses community.users[1].session }); }); // Bad — creates a new instance per test case it('admin can see the product', async () => { const community = await CreateCommunity.new(); // ❌ wasteful }); ``` ## Mocked Ports (`mockedPortsDI.ts`) At boot, `mockedPortsDI` replaces production adapters with mock implementations. Currently mocked: | Port | Mock class | |------|-----------| | `FirebaseService` | `MockFirebaseService` | | `ApnService` | `MockApnService` | | `RealTimeNotificatorService` | `MockRealTimeNotificator` | | `OtpTwilioPort` | `OtpTwilioAdapterMock` | | `AudiencesPort` | `MockAudiencesAdapter` | | `MetricRecorderPort` | `MetricRecorderMock` | | `EventHandlerService` | `MockEventHandlerService` | | `MediaConvertClientPort` | `MockMediaConvertClientService` | | `TimeTrackingNotificationsPort` | `MockTimeTrackingNotificationsAdapter` | | `RolesRepositoryPort` | `MockRolesRepositoryAdapter` | | `AdminLoginGatePort` | `MockAdminLoginGateAdapter` | | `LearningSessionsSchedulerPort` | `MockLearningSchedulerAdapter` | | `LearningSessionFilesPort` | `MockLearningSessionFilesAdapter` | | `LearningCertificateRenderingPort` | `MockLearningCertificateRenderingAdapter` | | `LearningSessionNotificationsPort` | `MockLearningSessionNotificationsAdapter` | | `LearningSessionStoragePort` | `MockLearningSessionStorageAdapter` | | `FeatureFlagRepositoryPort` | `MockFeatureFlagCacheRepositoryAdapter` | | `CloudFaceRecognitionPort` | `MockCloudFaceRecognitionAdapter` | | `ProdeQueuePort` | `MockProdeQueueAdapter` | | `AcknowledgementNotificationsPort` | `MockAcknowledgementNotificationsAdapter` | | + call, livestream, peopleExperience | domain-specific mock bundles | **Adding a new mock:** Create `setup/mocks/{domain}/mock{Port}.ts` extending `BaseMockAdapter`, override the port's abstract methods, then add `[MyPort.name]: MockMyPort` to `mockedPortsDI.ts`. ## BaseMockAdapter — Call Tracking Every mock extends `BaseMockAdapter`. It wraps all methods via `Proxy` and records calls per `requestId` (taken from the async local storage context set by each HTTP request). ```typescript // In a test — retrieve mock and inspect calls made during a request import { getMock } from '../setup/mocks/helpers'; const mock = getMock(MyPort.name); // After an API call, check what the mock received: const calls = mock.someMethod.getCalls(requestId); // or const calls = mock.getCalls(mock.someMethod, requestId); // All calls across all requests: const allCalls = mock.getAllCallsByMethodName('someMethod'); ``` `getServerAdapter(portName)` returns the raw DI instance. `getMock(portName)` returns it typed with `.getCalls` on every method. ## Utilities Reference | Utility | File | Signature | |---------|------|-----------| | `runCountQueryOnMainDb` | `utils/queryHelpers` | `(query, ...values) => Promise` | | `runSelectQueryOnMainDb` | `utils/queryHelpers` | `(query, ...values) => Promise` | | `runExecuteQueryOnMainDb` | `utils/queryHelpers` | `(query, ...values) => Promise` | | `runExecuteQueryOnInsightsDb` | `utils/queryHelpers` | `(query, ...values) => Promise` | | `generateQueryToken` | `utils/queryToken` | `(params) => string` — JWT for `validateQueryToken` middleware | | `checkMessageSentToEventHandler` | `utils/eventHandler` | assert SQS message sent | | `checkMessagesCountFromInsightsQueue` | `utils/insightsEventHandler` | `(count) => Promise` | | `waitForWebhookMessage` | `utils/webhooks` | polling helper for webhook delivery | | `getServerAdapter` | `setup/mocks/helpers` | `(portName) => adapter instance` | | `getMock` | `setup/mocks/helpers` | `(portName) => T with getCalls` | **Query parameters always use `$1, $2, ...` placeholders** (pg driver format), never string interpolation. ## Query Token Authentication Endpoints behind `validateQueryToken(API_PREFIXES.APP)` require a signed JWT as `?token=`. The token encodes `{ userId, instanceId, method, url }`. ```typescript // Inside _executeOn: import { generateQueryToken } from '../../utils/queryToken'; const token = generateQueryToken({ instanceId: session.loginDTO.instanceId, method: 'GET', url: '/api/v1/my/endpoint', userId: session.loginDTO.userId, }); const res = await session.apiGet(`/my/endpoint?token=${token}`); ``` If a command gets `401 Invalid query token`, the router applies `validateQueryToken`. Check the router file to confirm and add token generation. ## Test Isolation Rules Tests run with `randomize: true` and `maxWorkers: 14`, sharing one database. Violation → flaky tests. 1. **Never use unscoped DELETE/TRUNCATE.** Always `WHERE "instanceId" = $1` using your community's instanceId. 2. **Never assert on global counts.** Always filter by your test's instanceId/userId. 3. **Never hardcode unique values.** Use `faker.string.uuid()`, `faker.internet.email()`, etc. 4. **Self-contain setup.** All prerequisites in `beforeAll` via `CreateCommunity.new()` + commands. 5. **Clean up only your own data.** Scope `afterAll` cleanup to your own IDs. 6. **Never mutate `process.env` or global singletons.** Scope feature flag overrides to your instanceId; restore in `afterAll`. 7. **Never depend on auto-increment ID order.** Sort explicitly; assert on field values, not positions. 8. **No fixed `setTimeout` delays.** Poll for expected state instead. 9. **Shared reset helpers must undo every mutation any test makes, not just the obvious ones.** If one `it` soft-deletes a user, sets a feature flag, or flips a boolean via raw SQL, the `beforeEach` reset must restore it. With `randomize: true`, a test that mutates shared state without cleanup poisons whichever test Jest happens to run after it — producing intermittent failures that only reproduce under specific seeds. ## Infrastructure (setup/) `global.ts` is the Jest `globalSetup`. It: 1. Loads `.env.test` 2. Starts all four containers in parallel: `postgres()`, `redis()`, `kafka()`, `localStack()` 3. Calls `setupConfig()` + `createGlobalNamespace()` 4. Boots the Express app (`startHumandApp`) with `mockedPortsDI` injected Globals set by setup: - `globalThis.__CONTAINERS__` — array of started testcontainers - `globalThis.__DI_CONTAINER__` — TypeDI container instance - `globalThis.__DI_HANDLERS__` — port-name → adapter class map `teardown.ts` stops all containers. `jest.setup.ts` configures retry logic and jest-extended matchers. ## Writing New Commands Create a command when the same API operation is needed in more than one test or when the caller shouldn't need to know the URL/status code. 1. **File location:** `commands/{moduleName}/{camelCaseAction}.ts` 2. **Extend the right base class** (see table above) 3. **Assert the happy-path status code** inside `_executeOn` 4. **For error-path commands**, omit the assertion or accept `{ ignoreError: true }` via `RequestConfig` 5. **Input type** — use the module's VC type when available; inline `{ id: number }` for simple cases 6. **No input** — use `BaseLoggedRequestCommand` with `super()` (not `super({})`). ## Asserting Error Responses ```typescript import { AxiosError } from 'axios'; await expect( community.adminSession.executeNoResult(new DoForbiddenThing({ id: 999 })), ).rejects.toThrow(AxiosError); // Or catch and inspect: try { await community.adminSession.executeNoResult(new DoForbiddenThing({ id: 999 })); } catch (e) { const err = e as AxiosError<{ code: string }>; expect(err.response?.status).toBe(FORBIDDEN); expect(err.response?.data.code).toBe(ErrorCodes.SOME_CODE); } ``` ## Agent Context Map Each module with its own test conventions has an `AGENTS.md` (with a `CLAUDE.md` symlink) inside `api/{module}/` and/or `commands/{module}/`. Read the relevant file before writing tests or commands for that module. Whenever you add a new `AGENTS.md` under `test-integration/`, add an entry to this table. | Path | Covers | |------|--------| | `api/call/AGENTS.md` | Call test file map (endpoints / 1-1 / group), shared `setupCallTestContext`, mocked `CallProvider`, notification assertion patterns | | `api/groups/AGENTS.md` | Groups test file map, where to add new tests, bootstrap pattern, flaky test rules | | `commands/groups/AGENTS.md` | All 84 group command classes organized by feature area | ## Fixtures Binary/text assets for tests that need real file uploads: | File | Use | |------|-----| | `fixtures/profile-pic-1.jpg` | Image upload tests | | `fixtures/sample-1.pdf` | PDF upload/processing tests | | `fixtures/test-video.mp4` | Video upload tests | | `fixtures/example-customer-idp-metadata.xml` | SAML SSO tests | | `fixtures/huge-token.xml.base64` | Large token edge case tests |