Files

198 lines
6.3 KiB
TypeScript

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<number | null>((resolve) =>
child.once('exit', (code) => resolve(code)),
);
expect(exitCode).toBe(0);
} finally {
if (child.exitCode === null) child.kill('SIGKILL');
}
});
});