import { spawn } from 'node:child_process'; import { type AddressInfo } from 'node:net'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { bootstrapApi } from '../apps/api/src/main'; import { API_RUNTIME_CONFIG } from '../apps/api/src/api.module'; import { bootstrapScheduler } from '../apps/scheduler/src/main'; import { SCHEDULER_RUNTIME_CONFIG } from '../apps/scheduler/src/scheduler.module'; import { bootstrapWorker } from '../apps/worker/src/main'; import { WORKER_RUNTIME_CONFIG } from '../apps/worker/src/worker.module'; import type { ApiRuntimeConfig, SchedulerRuntimeConfig, WorkerRuntimeConfig, } from '../libs/config/src/runtime-config'; const redisConfig = Object.freeze({ host: '127.0.0.1', port: 6379, username: '', password: '', database: 0, tls: false, }); const mysqlConfig = Object.freeze({ enabled: false, host: '127.0.0.1', port: 3306, database: 'a_share_analysis_dev', username: '', password: '', charset: 'utf8mb4' as const, collation: 'utf8mb4_general_ci' as const, timezone: '+08:00' as const, }); const aiConfig = Object.freeze({ enabled: false, baseUrl: '', apiKey: '', model: '', timeoutMs: 30_000, }); const rocketMqConfig = Object.freeze({ enabled: false, nameServerAddress: '127.0.0.1:9876', endpoint: '127.0.0.1:8081', topicPrefix: 'a-share-analysis', }); describe('NestJS 三进程入口', () => { it('API 使用真实 HTTP 应用监听注入端口并可关闭', async () => { const config: ApiRuntimeConfig = { processName: 'api', appEnv: 'development', logLevel: 'debug', api: Object.freeze({ host: '127.0.0.1', port: 3000 }), tdx: Object.freeze({ baseUrl: 'http://127.0.0.1:17710', timeoutMs: 5000 }), aktools: Object.freeze({ baseUrl: 'http://127.0.0.1:8080', enabled: false, timeoutMs: 10000 }), redis: redisConfig, mysql: mysqlConfig, ai: aiConfig, }; const app = await bootstrapApi({ config, port: 0, logger: false }); const address = app.getHttpServer().address() as AddressInfo; expect(address.address).toBe('127.0.0.1'); expect(address.port).toBeGreaterThan(0); expect(app.get(API_RUNTIME_CONFIG)).toBe(config); await app.close(); expect(app.getHttpServer().listening).toBe(false); }); it('Scheduler 使用无 HTTP 端口的应用上下文并可关闭', async () => { const config: SchedulerRuntimeConfig = { processName: 'scheduler', appEnv: 'development', logLevel: 'debug', scheduler: Object.freeze({ enabled: true, timezone: 'Asia/Shanghai' }), redis: Object.freeze({ ...redisConfig, database: 1 }), rocketMq: Object.freeze({ ...rocketMqConfig, sendTimeoutMs: 3000 }), }; const app = await bootstrapScheduler({ config, logger: false }); expect('getHttpServer' in app).toBe(false); expect(app.get(SCHEDULER_RUNTIME_CONFIG)).toBe(config); await expect(app.close()).resolves.toBeUndefined(); }); it('Worker 使用无 HTTP 端口的应用上下文并可关闭', async () => { const config: WorkerRuntimeConfig = { processName: 'worker', appEnv: 'development', logLevel: 'debug', marketData: Object.freeze({ mode: 'mock', provider: 'deterministic-fixture', baseUrl: '', apiKey: '', }), redis: Object.freeze({ ...redisConfig, database: 2 }), mysql: mysqlConfig, rocketMq: Object.freeze({ ...rocketMqConfig, consumerGroup: 'a-share-analysis-worker', consumerConcurrency: 4, }), ai: aiConfig, }; const app = await bootstrapWorker({ config, logger: false }); expect('getHttpServer' in app).toBe(false); expect(app.get(WORKER_RUNTIME_CONFIG)).toBe(config); await expect(app.close()).resolves.toBeUndefined(); }); it.each([ ['scheduler', 'apps/scheduler/src/main.ts'], ['worker', 'apps/worker/src/main.ts'], ])('%s 命令入口持续运行并在 SIGTERM 后正常退出', async (processName, entry) => { const output: string[] = []; const errors: string[] = []; const launcher = path.resolve(__dirname, '../node_modules/tsx/dist/cli.mjs'); const child = spawn(process.execPath, [launcher, entry], { cwd: path.resolve(__dirname, '..'), env: { PATH: process.env.PATH, APP_ENV: 'production', LOG_LEVEL: 'error', REDIS_HOST: '127.0.0.1', REDIS_PORT: '6379', REDIS_USERNAME: '', REDIS_PASSWORD: '', REDIS_DATABASE: processName === 'scheduler' ? '1' : '2', REDIS_TLS: 'false', MYSQL_ENABLED: 'false', MYSQL_HOST: '127.0.0.1', MYSQL_PORT: '3306', MYSQL_DATABASE: 'a_share_analysis_dev', MYSQL_USERNAME: '', MYSQL_PASSWORD: '', MYSQL_CHARSET: 'utf8mb4', MYSQL_COLLATION: 'utf8mb4_general_ci', MYSQL_TIMEZONE: '+08:00', AI_ENABLED: 'false', AI_BASE_URL: '', AI_API_KEY: '', AI_MODEL: '', AI_TIMEOUT_MS: '30000', ROCKETMQ_ENABLED: 'false', ROCKETMQ_NAMESRV_ADDR: '127.0.0.1:9876', ROCKETMQ_ENDPOINT: '127.0.0.1:8081', ROCKETMQ_TOPIC_PREFIX: 'a-share-analysis', ROCKETMQ_SEND_TIMEOUT_MS: '3000', ROCKETMQ_CONSUMER_GROUP: 'a-share-analysis-worker', ROCKETMQ_CONSUMER_CONCURRENCY: '4', SCHEDULER_ENABLED: 'true', SCHEDULER_TIMEZONE: 'Asia/Shanghai', MARKET_DATA_MODE: 'live', MARKET_DATA_PROVIDER: 'deterministic-fixture', MARKET_DATA_BASE_URL: '', MARKET_DATA_API_KEY: '', }, stdio: ['ignore', 'pipe', 'pipe'], }); child.stdout.on('data', (chunk) => output.push(String(chunk))); child.stderr.on('data', (chunk) => errors.push(String(chunk))); try { await new Promise((resolve) => setTimeout(resolve, 600)); expect( child.exitCode, `${processName} 不应在启动后自行退出\nlauncher=${launcher}\nstdout=${output.join('')}\nstderr=${errors.join('')}`, ).toBeNull(); child.kill('SIGTERM'); const exitCode = await new Promise((resolve) => child.once('exit', (code) => resolve(code)), ); expect(exitCode).toBe(0); } finally { if (child.exitCode === null) child.kill('SIGKILL'); } }); });