feat: 引入分层配置生命周期,支持变量引用和 JSON Schema 校验
- 新增 src/server/config/ 模块(types、issues、variables、normalizer、schema)
- 配置布局从 server.host/server.port 切换为 server.listen.host/server.listen.port
- 移除 HOST/PORT 隐式环境变量覆盖,改为 YAML 显式 ${KEY} 变量引用
- 支持 ${KEY}、${KEY|default}、${KEY|}、$${KEY} 变量语法
- 使用 @sinclair/typebox + ajv 实现运行时严格契约校验和 JSON Schema 导出
- 新增 scripts/generate-config-schema.ts 和 config.schema.json
- 新增 bun run schema / schema:check 命令,check 先执行 schema:check
- 更新 README.md 和 DEVELOPMENT.md 匹配新配置体系
- 新增变量解析、schema 校验和 schema 同步测试
This commit is contained in:
171
tests/server/config/variables.test.ts
Normal file
171
tests/server/config/variables.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { extractVariables, resolveVariables } from "../../../src/server/config/variables";
|
||||
|
||||
describe("extractVariables", () => {
|
||||
test("空对象返回空 variables", () => {
|
||||
const result = extractVariables({});
|
||||
expect(result.variables.size).toBe(0);
|
||||
expect(result.issues.length).toBe(0);
|
||||
});
|
||||
|
||||
test("无 variables 字段返回空", () => {
|
||||
const result = extractVariables({ server: {} });
|
||||
expect(result.variables.size).toBe(0);
|
||||
});
|
||||
|
||||
test("variables 非对象报错", () => {
|
||||
const result = extractVariables({ variables: "bad" });
|
||||
expect(result.issues.length).toBe(1);
|
||||
expect(result.issues[0]!.code).toBe("invalid-type");
|
||||
});
|
||||
|
||||
test("提取有效变量", () => {
|
||||
const result = extractVariables({ variables: { HOST: "127.0.0.1", PORT: 3000 } });
|
||||
expect(result.variables.get("HOST")).toBe("127.0.0.1");
|
||||
expect(result.variables.get("PORT")).toBe(3000);
|
||||
});
|
||||
|
||||
test("无效变量名报错", () => {
|
||||
const result = extractVariables({ variables: { "123bad": "val" } });
|
||||
expect(result.issues.length).toBe(1);
|
||||
expect(result.issues[0]!.code).toBe("invalid-format");
|
||||
});
|
||||
|
||||
test("null 值报错", () => {
|
||||
const result = extractVariables({ variables: { KEY: null } });
|
||||
expect(result.issues.length).toBe(1);
|
||||
expect(result.issues[0]!.code).toBe("invalid-type");
|
||||
});
|
||||
|
||||
test("数组值报错", () => {
|
||||
const result = extractVariables({ variables: { KEY: [1, 2] } });
|
||||
expect(result.issues.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveVariables", () => {
|
||||
test("${KEY} 从 variables 解析", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${MY_HOST}" } },
|
||||
variables: { MY_HOST: "0.0.0.0" },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
test("${KEY|default} 使用默认值", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${MY_HOST|0.0.0.0}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
test("${KEY|} 空默认值", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${MY_HOST|}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("");
|
||||
});
|
||||
|
||||
test("$${KEY} 转义不解析", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "$${NOT_A_VAR}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("${NOT_A_VAR}");
|
||||
});
|
||||
|
||||
test("variables 优先于 process.env", () => {
|
||||
const prev = process.env["TEST_PRIORITY"];
|
||||
process.env["TEST_PRIORITY"] = "from-env";
|
||||
try {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${TEST_PRIORITY}" } },
|
||||
variables: { TEST_PRIORITY: "from-var" },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("from-var");
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["TEST_PRIORITY"];
|
||||
else process.env["TEST_PRIORITY"] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test("process.env fallback", () => {
|
||||
const prev = process.env["TEST_FALLBACK"];
|
||||
process.env["TEST_FALLBACK"] = "from-env";
|
||||
try {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${TEST_FALLBACK}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("from-env");
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["TEST_FALLBACK"];
|
||||
else process.env["TEST_FALLBACK"] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test("完整引用保留类型 - number", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { port: "${PORT|3000}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["port"]).toBe(3000);
|
||||
expect(typeof listen["port"]).toBe("number");
|
||||
});
|
||||
|
||||
test("完整引用保留类型 - boolean", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${FLAG|false}" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe(false);
|
||||
});
|
||||
|
||||
test("部分插值转为 string", () => {
|
||||
const prev = process.env["PARTIAL_HOST"];
|
||||
process.env["PARTIAL_HOST"] = "192.168";
|
||||
try {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "prefix-${PARTIAL_HOST}-suffix" } },
|
||||
});
|
||||
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
|
||||
const listen = server["listen"] as Record<string, unknown>;
|
||||
expect(listen["host"]).toBe("prefix-192.168-suffix");
|
||||
expect(typeof listen["host"]).toBe("string");
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["PARTIAL_HOST"];
|
||||
else process.env["PARTIAL_HOST"] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test("unresolved-variable 报错", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "${UNDEFINED_VAR}" } },
|
||||
});
|
||||
expect(result.issues.length).toBe(1);
|
||||
expect(result.issues[0]!.code).toBe("unresolved-variable");
|
||||
expect(result.issues[0]!.message).toContain("UNDEFINED_VAR");
|
||||
});
|
||||
|
||||
test("variables 段被移除", () => {
|
||||
const result = resolveVariables({
|
||||
server: { listen: { host: "test" } },
|
||||
variables: { KEY: "val" },
|
||||
});
|
||||
const config = result.config as Record<string, unknown>;
|
||||
expect(config["variables"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user