app.module Lifecycle (.init) Bootstrap Smoke Expansion (4 NestJS services)
SummaryExtended Sprint 197's .compile() bootstrap smoke tests (4 NestJS services) into .init()/.close() lifecycle verification. moduleRef.init() runs onModuleInit + onApplicationBootstrap at bootstrap-equivalent level, catching lifecycle-stage throws that .compile() misses (saga onModuleInit's incompleteSubmissions.length, MqPublisher's amqplib.connect, missing env). Split into a separate spec file (app.module.init.spec.ts) — compile spec failure = DI graph / init spec failure = lifecycle, a diagnostic separation. submission blocks MqPublisher.onModuleInit via jest.mock('amqplib') + mockRepository.find→[] (avoids saga onModuleInit TypeError). Empirical finding: close() works cleanly for a single TypeORM connection (submission/identity/gateway), but problem's dual TypeOrmCoreModule (default + new-problem-db) throws in onApplicationShutdown while strict-resolving the mock DataSource — ScheduleModule cron timers are already cleaned in the earlier onModuleDestroy (detectOpenHandles 0), so only that quirk is narrowly caught while other teardown errors re-throw. forceExit exists only on submission → the other 3 require close(). A negative check (saga onModuleInit throw → compile passes / init fails) proves the lifecycle actually runs. Critic (Codex) independently reproduced the problem quirk, cross-validating the fix — Critical/High 0. tsc 0 · ESLint 0 · coverage thresholds pass (submission 381/problem 185/gateway 785/identity 265) · CI #351 FAIL 0. PR #351 squash → 4853cf2.
Goal
- Extend Sprint 197's
.compile()bootstrap smoke tests (which validate only DI-graph build) into.init()/.close(), running the lifecycle hooks (onModuleInit+onApplicationBootstrap) at bootstrap-equivalent level. - Catch lifecycle-stage throws that
.compile()misses (e.g. sagaonModuleInit'sincompleteSubmissions.length, RabbitMQ connection, env required only during lifecycle). - Complete the "verify up to the RabbitMQ connection step via amqplib mock" that Sprint 197 deferred to a separate scope.
Background
- Sprint 197 validated the DI graph of 4 services (gateway·submission·problem·identity) via
Test.createTestingModule({imports:[AppModule]}).compile(). But.compile()only runs graph build +useFactoryand does not callonModuleInit/onApplicationBootstrap(those are.init()-only). Thus lifecycle-stage defects such as MqPublisher'samqplib.connectand saga's incomplete-Saga resumption remained undefended. - Target lifecycle (what
.init()additionally runs):- submission: MqPublisherService.onModuleInit (
amqplib.connect), SagaOrchestratorService.onModuleInit (submissionRepo.find()then.lengthaccess, CircuitBreaker registration,setIntervaltimeout timer), ProblemServiceClient.onModuleInit (CB registration), MetricsService.onModuleInit (collectDefaultMetrics) - problem: DualWriteService/ReconciliationService.onModuleInit (
getDualWriteMode()read), MetricsService, ScheduleModule onApplicationBootstrap (@Cron reconcile timer) - gateway: MetricsService, ScheduleModule (@Cron ×3: event-log·notification·deadline-reminder)
- identity: MetricsService, ScheduleModule (@Cron ×1: feedback)
- submission: MqPublisherService.onModuleInit (
- Technical challenge:
.init()runs lifecycle that attempts real infra connections, so RabbitMQ (amqplib) must also be mocked, and timers registered by lifecycle (@Cron, sagasetInterval) must be cleaned via.close()— yet only submission hasforceExit, so the other 3 hang if timers leak.
Decision
D1. Split into a separate spec file (user, AskUserQuestion)
- Create a new
src/app.module.init.spec.tsper service. Sprint 197'sapp.module.spec.ts(.compile()) stays intact. - Rationale: ① diagnostic separation — compile spec failure = DI graph (circular dependency/missing provider), init spec failure (compile passes) = lifecycle (onModuleInit/bootstrap). ② jest isolates the module registry per test file, structurally preventing
prom-clientglobal-state cross-contamination (collectDefaultMetricsruns once per file from fresh module state). ③ Preserves Sprint 197's fast.compile()regression signal as a distinct check. - Alternatives (add an it to the existing spec / replace the existing it with
.init()) were rejected: the former risks prom-client collision on a second instantiation in the same file, the latter loses the standalone compile-only signal.
D2. Scope — all 4 NestJS services
- Add an init spec to all of gateway·submission·problem·identity (consistent with Sprint 197). gateway/identity
onModuleInitis nearly trivial (collectDefaultMetrics), but.init()also runsonApplicationBootstrap(ScheduleModule cron registration, full module runtime wiring), which carries bootstrap-equivalent value.
D3. amqplib mock — block submission MqPublisher.onModuleInit
jest.mock('amqplib')(hoisted) providesconnect → mockConnection(createChannel → mockChannel: assertExchange/assertQueue/bindQueue/publish/close,on) andconnection.on. Inherits the existingmq-publisher.service.spec.tsmock pattern.- Set env
RABBITMQ_URL(satisfiesMqPublisher.onModuleInit'sgetOrThrow('RABBITMQ_URL')). This runs the RabbitMQ connection step ("RabbitMQ connection and Exchange/Queue setup complete" log) at bootstrap-equivalent level.
D4. saga onModuleInit — mockRepository.find → []
SagaOrchestratorService.onModuleInitreads.lengthof the result ofsubmissionRepo.find(...), sofindmust resolve an array (a barejest.fn()returnsundefined→TypeError).find: jest.fn().mockResolvedValue([])passes the "no incomplete Saga -- normal start" path.
D5. close() teardown strategy (empirically grounded)
- Empirical finding: after
moduleRef.init(),moduleRef.close()works without throwing for a single TypeORM connection (submission/identity) or no TypeORM (gateway) — CircuitBreaker/saga/mq/ScheduleModule each clean up via their onModuleDestroy. → The "close() re-resolving the overridden DataSource fails" concern Sprint 197 raised does not materialize for a single connection. - problem is the sole exception: with dual
TypeOrmCoreModule(default +new-problem-db),onApplicationShutdownthrowsUnknownElementExceptionwhile strict-resolving the defaultDataSourceviamoduleRef.get(getDataSourceToken(this.options))(a teardown-only Nest internal quirk, harmless since it is a mock, not real infra). - Timer safety: the close() sequence is
onModuleDestroy(ScheduleModule cron timer cleanup) →onApplicationShutdown(where it throws). So timers are cleaned before the throw → verified with--detectOpenHandles(0 residual handles). - Response: only problem's init spec uses
await moduleRef.close().catch(...)to narrowly ignore the known quirk ('could not find DataSource'), re-throwing any other teardown error (throw e) to preserve detection of futureonModuleDestroyregressions. Adding a globalforceExitwas rejected as it affects other tests.
D6. Services without forceExit require close()
forceExit:trueexists only in submission's jest.config. problem/gateway/identity hang if the @Cron timers (CronJob) registered by.init()leak, so they must clean up via.close().
Implementation
Implementation commit (1 commit, PR #351 squash → 4853cf2)
fd63c09test — Add AppModule lifecycle (.init) bootstrap smoke tests ×4- New
services/{submission,problem,gateway,identity}/src/app.module.init.spec.ts(+389) - Common: single
it,.compile() → moduleRef.init() → moduleRef.close().init()runs only onModuleInit + onApplicationBootstrap without an HTTP adapter. - submission: top-level
jest.mock('amqplib')(connect → mockConnection/mockChannel) +getDataSourceToken()·getEntityManagerToken()·REDIS_CLIENT(./cache/cache.constants) override +find→[]+ envRABBITMQ_URL - problem: base +
NEW_DB_CONNECTIONnamed DataSource/EntityManager +REDIS_CLIENT(./cache/cache.module) override +DUAL_WRITE_MODE='off'+ narrow close() catch (D5) - gateway: top-level
jest.mock('ioredis')(blocks directnew Redis()) + JWT/INTERNAL_KEY env + no TypeORM - identity: DataSource/EntityManager override +
NODE_ENV='test'(avoids TokenEncryptionService key validation)
- New
Verification
- Type/build:
tsc --noEmit0 (4 services). ESLint 0 (4 services, spec override turnsno-explicit-anyoff). - Tests: 4 specs pass standalone + full regression. open handle/hang 0 (verified via
--detectOpenHandlesthat no residual handle remains even after the problem close() quirk). Per-service coverage thresholds pass (jest --coverageexit 0): submission 381 / problem 185 / gateway 785 / identity 265 pass. - Negative check: temporarily inserted a
throwat the top of submission'sSagaOrchestratorService.onModuleInit→app.module.spec.ts(.compile()) passes (onModuleInit not run),app.module.init.spec.ts(.init()) fails (SP199_NEGATIVE_CHECK) → proves the init spec actually runs the lifecycle (reverted). - Critic:
codex review --base main(Codex, session019e4e0d-06d9-7592-b433-677efcae4b06) — Critical/High 0 ("lifecycle smoke tests with appropriate infrastructure mocks and teardown handling. I did not identify a discrete regression introduced by the patch"). Notably, Codex independently reproduced the problem close() quirk with its own node script (submission/identity close cleanly, only problem throwsUnknownElementException), cross-validating the narrow-catch fix. ✅ Mergeable. - CI #351: FAIL 0 (Quality/Test/Build/Audit/Coverage Gate/E2E Programmers/Trivy all pass; skipping = unchanged areas) → Squash merge.
New Patterns
- AppModule lifecycle smoke test —
.compile() → moduleRef.init() → moduleRef.close(). Override infra providers (DataSource·REDIS_CLIENT), module-mock directly-newed clients (gateway ioredis),jest.mock('amqplib')for RabbitMQ, andfind→[]when lifecycle reads a repo. Single TypeORM usesclose()as-is; a dual connection narrowly catches only the teardown quirk. Services withoutforceExitrequire close(). Coexists with Sprint 197's.compile()smoke as a separate file for diagnostic separation. - Negative check for lifecycle execution — inserting a temporary
throwinonModuleInitand confirming the.compile()spec passes while the.init()spec fails proves the init spec actually runs the lifecycle (the lifecycle counterpart of Sprint 197's negative check that showed the mock is load-bearing).