/** 配置加载(ADR-0011 / 决策 D15、D16)的用例。 */ import assert from 'node:assert/strict'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { configFilePath, isEngineConfigured, loadConfig, redactConfig } from '@aps/server/config'; async function withConfigFile(content, run) { const dir = await mkdtemp(join(tmpdir(), 'aps-config-')); const file = join(dir, 'config.json'); try { if (content !== undefined) await writeFile(file, content, 'utf8'); return await run({ dir, file }); } finally { await rm(dir, { recursive: true, force: true }); } } test('没有配置文件与环境变量时用默认值:DeepSeek,密钥为空,引擎不可用', async () => { await withConfigFile(undefined, async ({ dir, file }) => { const config = loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir }); assert.equal(config.llm.provider, 'deepseek'); assert.equal(config.llm.baseUrl, 'https://api.deepseek.com/v1'); assert.equal(config.llm.model, 'deepseek-chat'); assert.equal(config.llm.apiKey, ''); assert.equal(isEngineConfigured(config), false, '没有密钥时引擎不应被视为可用'); assert.equal(config.database.path, join(dir, '.aps', 'aps.sqlite'), '默认库在用户目录下'); assert.equal(config.server.port, 5199); }); }); test('配置文件提供端点与密钥', async () => { const content = JSON.stringify({ llm: { provider: 'compatible', baseUrl: 'http://gw.internal/v1', model: 'internal-chat', apiKey: 'sk-file-123' }, database: { path: 'D:/data/aps.sqlite' }, server: { port: 6000 }, }); await withConfigFile(content, async ({ dir, file }) => { const config = loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir }); assert.equal(config.llm.provider, 'compatible'); assert.equal(config.llm.baseUrl, 'http://gw.internal/v1'); assert.equal(config.llm.model, 'internal-chat'); assert.equal(config.llm.apiKey, 'sk-file-123'); assert.equal(isEngineConfigured(config), true); assert.equal(config.database.path, 'D:/data/aps.sqlite'); assert.equal(config.server.port, 6000); }); }); test('环境变量优先于配置文件', async () => { const content = JSON.stringify({ llm: { model: 'from-file', apiKey: 'sk-file' }, server: { port: 6000 } }); await withConfigFile(content, async ({ dir, file }) => { const config = loadConfig({ env: { APS_CONFIG_FILE: file, APS_LLM_MODEL: 'from-env', APS_PORT: '7000' }, homeDir: dir, }); assert.equal(config.llm.model, 'from-env'); assert.equal(config.llm.apiKey, 'sk-file', '未覆盖的字段仍取配置文件'); assert.equal(config.server.port, 7000); }); }); test('脱敏视图不含密钥明文', async () => { const content = JSON.stringify({ llm: { apiKey: 'sk-secret-abcdef' } }); await withConfigFile(content, async ({ dir, file }) => { const config = loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir }); const redacted = JSON.stringify(redactConfig(config)); assert.ok(!redacted.includes('sk-secret-abcdef'), `脱敏后仍能看到密钥:${redacted}`); assert.ok(redacted.includes('已配置'), redacted); }); }); test('配置文件损坏时报错,不静默降级', async () => { await withConfigFile('{ 这不是 JSON', async ({ dir, file }) => { assert.throws(() => loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir }), /配置文件解析失败/); }); }); test('配置文件路径:默认在用户目录,可用 APS_CONFIG_FILE 覆盖', () => { assert.equal(configFilePath({ env: {}, homeDir: 'C:/Users/test' }).replace(/\\/g, '/'), 'C:/Users/test/.aps/config.json'); assert.equal(configFilePath({ env: { APS_CONFIG_FILE: 'D:/cfg/aps.json' }, homeDir: 'C:/Users/test' }), 'D:/cfg/aps.json'); }); test('引擎选择:没密钥用假引擎,有密钥自动选 Pi,可显式覆盖', async () => { await withConfigFile(undefined, async ({ dir, file }) => { const noKey = loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir }); assert.equal(noKey.engine.kind, 'fake', '没有密钥时应回退到假引擎'); }); const content = JSON.stringify({ llm: { apiKey: 'sk-file-123' } }); await withConfigFile(content, async ({ dir, file }) => { const withKey = loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir }); assert.equal(withKey.engine.kind, 'pi', '有密钥时应自动选 Pi'); const forced = loadConfig({ env: { APS_CONFIG_FILE: file, APS_ENGINE: 'fake' }, homeDir: dir }); assert.equal(forced.engine.kind, 'fake', '显式配置应覆盖自动选择'); }); });