app.module Bootstrap Smoke Tests (4 NestJS services)
SummaryExtended the Sprint 195 lesson (submission CacheModule circular dependency throwing 26ms into bootstrap → CrashLoopBackOff, undetected because the 14 unit specs assembled providers manually) from a single module to the entire AppModule. Introduced Test.createTestingModule({imports:[AppModule]}).compile() bootstrap smoke tests across 4 NestJS services (gateway·submission·problem·identity) to catch DI-graph throws (circular dependencies, missing providers) at bootstrap-equivalent level. TypeORM real DB connection blocked via getDataSourceToken() override of initialize() (problem also overrides the NEW_DB_CONNECTION named connection); Redis blocked via REDIS_CLIENT override (submission/problem) and jest.mock('ioredis') for gateway (many constructors call new Redis() directly); RabbitMQ avoided by calling .compile() only (not .init()), skipping onModuleInit. Single it · zero branches · no uninvoked function literals keep coverage thresholds intact for spec-collected services (problem/identity/gateway). Negative check (empty DataSource → repo factory TypeError) proves the graph is genuinely compiled. Critic (Codex gpt-5.x) ran 2 rounds — R1 P2 (problem DUAL_WRITE_MODE env leak) resolved → clean R2. 4 specs pass standalone + full regression, tsc 0, ESLint 0, CI #347 Passed 412/Failed 0. PR #347 squash → 5d1e5fc.
Goal
- Introduce bootstrap smoke tests that compile the entire DI graph in one shot, catching Sprint 195-class circular dependency / module-graph throws at bootstrap-equivalent level.
- Extend the Sprint 195 hotfix's
cache.module.spec.ts(single-module compile regression) pattern to the entire AppModule, making "tests green = bootstrap possible" hold for every NestJS service.
Background
- In Sprint 195, new submission code threw
circular dependency inside CacheModule26ms into bootstrap, causing CrashLoopBackOff. Yet the 14 existing unit specs assembled providers manually (providers:[...]), so they never compiled the real module DI graph — bootstrap failed 100% even though "tests were green." - The Sprint 195 hotfix added a
Test.createTestingModule({imports:[CacheModule]}).compile()regression test, but it was single-module scope → other cycles/omissions in the full AppModule graph remained undefended. - Target NestJS services: gateway·submission·problem·identity. (github-worker is plain Node.js with no
app.module; ai-analysis is FastAPI — both excluded.) - Technical challenge:
.compile()runs graph build +useFactory(TypeORM/Throttler, etc.), so providers that attempt real infra (Postgres/Redis/RabbitMQ) connections must be blocked to validate only the DI graph without networking.
Decision
D1. Scope — all 4 NestJS services (user, AskUserQuestion)
- Add
src/app.module.spec.tsto each of gateway·submission·problem·identity. Every NestJS service guarantees "tests green = bootstrap possible" → Sprint 195-class graph throws blocked across all services.
D2. Call .compile() only (.init() out of scope)
.compile()builds and instantiates the DI graph but does not callonModuleInit/onApplicationBootstrap(those are.init()-only). Thus MqPublisherService'samqplib.connect(onModuleInit) does not run → RabbitMQ connection avoided. But circular dependencies / missing providers throw during graph build (compile), so they are still caught (same rationale as the Sprint 195 cache.module.spec).- Lifecycle-hook (onModuleInit) verification + amqplib mock are deferred to a separate scope (future sprint).
D3. Infra-blocking mechanism (verified against @nestjs/[email protected] source)
| Infra | Blocking method |
|---|---|
| TypeORM real DB connection | .overrideProvider(getDataSourceToken()).useValue(mockDataSource) — replaces TypeOrmCoreModule's dataSourceProvider (whose useFactory calls dataSource.initialize()) wholesale → initialize never runs. The mock only needs the members the repository factory references: entityMetadatas:[]·options.type·getRepository. |
Redis (new Redis()) | submission/problem: .overrideProvider(REDIS_CLIENT). gateway: jest.mock('ioredis') — many constructors (~10, including throttler) call new Redis() directly at .compile() time; this is not via DI, so overrideProvider cannot intercept it and a module-level mock is required. |
RabbitMQ (amqplib.connect) | .compile() only (D2). |
D4. problem dual connection — companion named DataSource override
- DualWriteModule adds a
NEW_DB_CONNECTION='new-problem-db'named connection beyond the default, sogetDataSourceToken(NEW_DB_CONNECTION)(='new-problem-dbDataSource') is also overridden.DUAL_WRITE_MODE=offis set explicitly (D6) so only the OFF branch is evaluated → noNEW_DATABASE_*needed.
D5. No coverage impact on spec-collected services (problem/identity/gateway)
- Only submission excludes specs from coverage (
!**/*.spec.tsinjest.config.ts). problem/identity/gateway collect specs too, so the new specs are written with a singleit, zero branches, and no uninvoked function literals (barejest.fn();() => ...literals only where actually invoked at compile) to avoid affecting function/branch thresholds (95~98%).
D6. Test env is set explicitly, not preserved via spread (Critic P2)
beforeAllsetsprocess.env;afterAllrestores via spread. This satisfies constructor/factory-timegetOrThrow(submission: DATABASE_·INTERNAL_KEY_AI_ANALYSIS; gateway: JWT_SECRET·OAUTH_CALLBACK_URL·INTERNAL_KEY_). problem setsDUAL_WRITE_MODE='off'explicitly — a spread alone would preserve an expand·switch-read value from the environment/local.env, leaking into the active branch (caught in Critic R1).
Implementation
Implementation commits (2 commits, PR #347 squash → 5d1e5fc)
1c5f126test — add 4 AppModule bootstrap smoke tests- New
services/{gateway,submission,problem,identity}/src/app.module.spec.ts - submission: override
getDataSourceToken()·getEntityManagerToken()·REDIS_CLIENT(./cache/cache.constants) - problem: default +
NEW_DB_CONNECTION(./database/dual-write.config) named DataSource/EntityManager +REDIS_CLIENT(./cache/cache.module) overrides - identity: DataSource/EntityManager override only (no module-level Redis;
NODE_ENV='test'skips TokenEncryptionService key validation) - gateway: top-of-file
jest.mock('ioredis')(default-import constructor mock, chainable.on()) + no TypeORM - common: single
it+ onecompile()(avoids MetricsModule/prom-client duplicate registration) +expect(moduleRef).toBeDefined()+ no close() (on teardown, TypeOrmCoreModule.onApplicationShutdown re-resolves the overridden DataSource and fails; with mocked infra there are no real handles to clean up)
- New
8fcd15etest — [Critic R1 P2] setDUAL_WRITE_MODE='off'explicitly in problem smoke (hermetic env)
Verification
- Type/build:
tsc --noEmit0 (4 services). ESLint 0 (4 services; spec override turns offno-explicit-any). - Tests: 4 specs pass standalone + full regression. Per-service coverage thresholds pass (
jest --coverageexit 0 — submission/problem/identity/gateway). Zero open handles/hangs (jest exits cleanly; gateway throttler setInterval is.unref()). - Negative check: temporarily swapped submission's DataSource override for an empty object → the repo factory's
dataSource.entityMetadatas.find(...)failed compile() withTypeError: Cannot read properties of undefined (reading 'find')→ proves the test genuinely compiles the graph and the mock is load-bearing (reverted). - Critic:
codex review --base main(gpt-5.x) 2 rounds — Critical/High 0. R1 P2 (problemDUAL_WRITE_MODEenv leak) resolved → R2 0 ("no discrete introduced issue that would break existing behavior or tests"). ✅ mergeable. - CI #347: Passed 412 / Failed 0 /
MERGEABLE·CLEAN→ Squash merge.
New Patterns
- AppModule bootstrap smoke test —
Test.createTestingModule({imports:[AppModule]}).overrideProvider(getDataSourceToken()).useValue(mockDataSource)...compile(). Override infra providers (DataSource·REDIS_CLIENT), module-mock directly-newed clients (gateway ioredis), avoid RabbitMQ by limiting to.compile(). New NestJS services/modules validate the bootstrap graph once with this smoke test ("tests green = bootstrap possible"). - Minimal TypeORM DataSource mock —
{ entityMetadatas:[], options:{type:'postgres'}, getRepository: jest.fn(()=>mockRepository), manager:{...} }. Just the members the repository provider factory references resolve the graph. Named connections overridegetDataSourceToken(name)separately.