1
0

feat: 配置变量系统与 target id/name 双字段标识

- 新增顶层 variables 段支持 string/number/boolean 字面量
- target 字符串字段支持 、、{...} 转义语法
- 变量解析优先级: variables -> process.env -> 默认值 -> 报错
- 完整引用保留原始类型,部分引用拼接为字符串
- 变量替换在 YAML 解析后、AJV 校验前执行
- 替换仅作用于 targets,跳过 id/type 字段
- target 新增必填 id 字段作为唯一标识,name 改为可选展示名称
- 数据库存储/API/前端全面迁移到 id 标识
- 统一 checker 运行时类型检查为 es-toolkit predicates
- 同步 delta specs 到主 specs,归档 config-variables 变更
This commit is contained in:
2026-05-17 00:37:54 +08:00
parent 366b3211c8
commit 7926514986
53 changed files with 1538 additions and 333 deletions

View File

@@ -50,6 +50,7 @@ describe("API 路由", () => {
method: "GET",
url: "http://a.com",
},
id: "test-a",
intervalMs: 30000,
name: "test-a",
timeoutMs: 10000,
@@ -64,6 +65,7 @@ describe("API 路由", () => {
maxOutputBytes: 104857600,
},
group: "default",
id: "test-b",
intervalMs: 60000,
name: "test-b",
timeoutMs: 5000,
@@ -204,6 +206,7 @@ describe("API 路由", () => {
expect(body.targets).toHaveLength(2);
const tA = body.targets.find((t) => t.name === "test-a")!;
expect(tA.id).toBe("test-a");
expect(tA.type).toBe("http");
expect(tA.target).toBe("http://a.com");
expect(tA.group).toBe("default");
@@ -372,9 +375,9 @@ describe("API 路由", () => {
expect(body["error"]).toContain("from and to");
});
test("metrics 无效 targetId 返回 400", async () => {
test("metrics 无效 target id 返回 400", async () => {
const response = await fetch(
`${baseUrl}/api/targets/invalid/metrics?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z`,
`${baseUrl}/api/targets/_invalid/metrics?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z`,
);
const body = (await response.json()) as Record<string, unknown>;

View File

@@ -12,6 +12,7 @@ type ShutdownSignal = "SIGINT" | "SIGTERM";
const target: ResolvedTargetBase = {
group: "default",
id: "test",
intervalMs: 30000,
name: "test",
timeoutMs: 5000,

View File

@@ -28,6 +28,7 @@ describe("config contract", () => {
targets: [
{
http: { method: "get", unknownHttpField: true, url: "https://example.com" },
id: "api",
name: "api",
type: "http",
},
@@ -36,6 +37,31 @@ describe("config contract", () => {
).toBe(false);
});
test("导出 schema 支持 variables 且要求 target id", () => {
const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
removeAdditional: false,
strict: true,
useDefaults: false,
});
const validate = ajv.compile(createProbeConfigJsonSchema(createDefaultCheckerRegistry()));
expect(
validate({
targets: [{ http: { url: "https://example.com" }, id: "api", type: "http" }],
variables: { base_url: "https://example.com", enabled: true, port: 443 },
}),
).toBe(true);
expect(
validate({
targets: [{ http: { url: "https://example.com" }, type: "http" }],
variables: { bad: null },
}),
).toBe(false);
});
test("Ajv 错误转换为中文结构化 issue", () => {
const result = validateProbeConfigContract(
{
@@ -43,6 +69,7 @@ describe("config contract", () => {
{
group: 123,
http: { extra: true },
id: "api",
name: "api",
type: "http",
},

View File

@@ -107,6 +107,7 @@ describe("loadConfig", () => {
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -121,6 +122,7 @@ describe("loadConfig", () => {
expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as ResolvedHttpTarget;
expect(t.type).toBe("http");
expect(t.id).toBe("test");
expect(t.name).toBe("test");
expect(t.http.url).toBe("http://example.com");
expect(t.http.method).toBe("GET");
@@ -140,6 +142,7 @@ describe("loadConfig", () => {
configPath,
`targets:
- name: "check-nginx"
id: "check-nginx"
type: cmd
cmd:
exec: "pgrep"
@@ -151,6 +154,7 @@ describe("loadConfig", () => {
expect(config.targets).toHaveLength(1);
const t = config.targets[0]! as ResolvedCommandTarget;
expect(t.type).toBe("cmd");
expect(t.id).toBe("check-nginx");
expect(t.name).toBe("check-nginx");
expect(t.cmd.exec).toBe("pgrep");
expect(t.cmd.args).toEqual(["nginx"]);
@@ -181,6 +185,7 @@ defaults:
maxOutputBytes: "10MB"
targets:
- name: "http-target"
id: "http-target"
type: http
interval: "1m"
http:
@@ -193,6 +198,7 @@ targets:
body:
- contains: "ok"
- name: "cmd-target"
id: "cmd-target"
type: cmd
cmd:
exec: "ls"
@@ -228,6 +234,140 @@ targets:
expect(cmd.cmd.maxOutputBytes).toBe(10485760);
});
test("name 缺省时 fallback 到 id", async () => {
const configPath = join(tempDir, "name-fallback.yaml");
await writeFile(
configPath,
`targets:
- id: "api-health"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0]!;
expect(target.id).toBe("api-health");
expect(target.name).toBe("api-health");
});
test("name 支持变量替换且不要求唯一", async () => {
const configPath = join(tempDir, "name-variable.yaml");
await writeFile(
configPath,
`variables:
env: "生产"
targets:
- id: "api-a"
name: "\${env} API"
type: http
http:
url: "http://a.example.com"
- id: "api-b"
name: "\${env} API"
type: http
http:
url: "http://b.example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets.map((target) => [target.id, target.name])).toEqual([
["api-a", "生产 API"],
["api-b", "生产 API"],
]);
});
test("包含 variables 的完整配置在 schema 校验前完成替换", async () => {
const configPath = join(tempDir, "variables-full.yaml");
await writeFile(
configPath,
`variables:
env: "生产"
base_url: "https://example.com"
ignore_ssl: true
max_redirects: 5
targets:
- id: "api-health"
name: "\${env} API 健康检查"
type: http
http:
url: "\${base_url}/health"
ignoreSSL: "\${ignore_ssl}"
maxRedirects: "\${max_redirects}"
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0] as ResolvedHttpTarget;
expect(target.id).toBe("api-health");
expect(target.name).toBe("生产 API 健康检查");
expect(target.http.url).toBe("https://example.com/health");
expect(target.http.ignoreSSL).toBe(true);
expect(target.http.maxRedirects).toBe(5);
});
test("变量替换后类型不匹配导致 schema 校验失败", async () => {
const configPath = join(tempDir, "bad-var-type.yaml");
await writeFile(
configPath,
`variables:
max_redirects: "not-a-number"
targets:
- id: "bad-var"
type: http
http:
url: "http://example.com"
maxRedirects: "\${max_redirects}"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("maxRedirects");
});
test("变量替换后通过 schema 校验", async () => {
const configPath = join(tempDir, "good-var-type.yaml");
const origEnv = process.env["DIAL_VAR_MAX_REDIRECTS"];
process.env["DIAL_VAR_MAX_REDIRECTS"] = "3";
try {
await writeFile(
configPath,
`targets:
- id: "good-var"
type: http
http:
url: "http://example.com"
maxRedirects: "\${DIAL_VAR_MAX_REDIRECTS}"
`,
);
const config = await loadConfig(configPath);
const target = config.targets[0] as ResolvedHttpTarget;
expect(target.http.maxRedirects).toBe(3);
} finally {
if (origEnv === undefined) {
delete process.env["DIAL_VAR_MAX_REDIRECTS"];
} else {
process.env["DIAL_VAR_MAX_REDIRECTS"] = origEnv;
}
}
});
test("未定义变量且无默认值阻止启动", async () => {
const configPath = join(tempDir, "unresolved-var.yaml");
await writeFile(
configPath,
`targets:
- id: "unresolved"
type: http
http:
url: "\${MISSING_BASE_URL}/health"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("未定义的变量");
});
test("绝对 dataDir 保持不变", async () => {
const dataDir = join(tempDir, "absolute-data");
const configPath = join(tempDir, "absolute-data-dir.yaml");
@@ -237,6 +377,7 @@ targets:
dataDir: ${JSON.stringify(dataDir)}
targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -258,6 +399,7 @@ targets:
maxBodyBytes: "10MB"
targets:
- name: "override-all"
id: "override-all"
type: http
interval: "5m"
timeout: "30s"
@@ -281,8 +423,8 @@ targets:
await expect(loadConfig("/nonexistent/file.yaml")).rejects.toThrow("配置文件不存在");
});
test("target 缺少 name 抛出错误", async () => {
const configPath = join(tempDir, "no-name.yaml");
test("target 缺少 id 抛出错误", async () => {
const configPath = join(tempDir, "no-id.yaml");
await writeFile(
configPath,
`targets:
@@ -292,7 +434,7 @@ targets:
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少 name 字段");
await expect(loadConfig(configPath)).rejects.toThrow("缺少 id 字段");
});
test("target 缺少 type 抛出错误", async () => {
@@ -301,6 +443,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
http:
url: "http://example.com"
`,
@@ -315,6 +458,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http: {}
`,
@@ -329,6 +473,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
`,
);
@@ -342,6 +487,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -358,6 +504,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -374,6 +521,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -391,6 +539,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: cmd
cmd: {}
`,
@@ -405,6 +554,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: dns
`,
);
@@ -412,23 +562,70 @@ targets:
await expect(loadConfig(configPath)).rejects.toThrow("不支持的 type");
});
test("target name 重复抛出错误", async () => {
const configPath = join(tempDir, "dup-name.yaml");
test("target id 重复抛出错误", async () => {
const configPath = join(tempDir, "dup-id.yaml");
await writeFile(
configPath,
`targets:
- name: "dup"
id: "dup"
type: http
http:
url: "http://a.com"
- name: "dup"
id: "dup"
type: http
http:
url: "http://b.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("target name 重复");
await expect(loadConfig(configPath)).rejects.toThrow("target id 重复");
});
test("target id 为空字符串抛出错误", async () => {
const configPath = join(tempDir, "empty-id.yaml");
await writeFile(
configPath,
`targets:
- id: ""
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("缺少 id 字段");
});
test("target id 命名不合法抛出错误", async () => {
const configPath = join(tempDir, "bad-id.yaml");
await writeFile(
configPath,
`targets:
- id: "_invalid"
type: http
http:
url: "http://example.com"
`,
);
// eslint-disable-next-line @typescript-eslint/await-thenable
await expect(loadConfig(configPath)).rejects.toThrow("id 不符合命名规则");
});
test("target id 包含下划线和连字符通过", async () => {
const configPath = join(tempDir, "id-underscore-dash.yaml");
await writeFile(
configPath,
`targets:
- id: "db_check-01"
type: http
http:
url: "http://example.com"
`,
);
const config = await loadConfig(configPath);
expect(config.targets[0]!.id).toBe("db_check-01");
});
test("targets 为空数组抛出错误", async () => {
@@ -446,6 +643,7 @@ targets:
port: 99999
targets:
- name: "t"
id: "t"
type: http
http:
url: "http://a.com"
@@ -463,6 +661,7 @@ targets:
maxConcurrentChecks: -1
targets:
- name: "t"
id: "t"
type: http
http:
url: "http://a.com"
@@ -481,6 +680,7 @@ targets:
maxBodyBytes: "100TB"
targets:
- name: "t"
id: "t"
type: http
http:
url: "http://a.com"
@@ -496,6 +696,7 @@ targets:
configPath,
`targets:
- name: "t"
id: "t"
type: http
interval: "30x"
http:
@@ -512,6 +713,7 @@ targets:
configPath,
`targets:
- name: "with-expect"
id: "with-expect"
type: http
http:
url: "http://example.com"
@@ -543,6 +745,7 @@ targets:
configPath,
`targets:
- name: "cmd-with-expect"
id: "cmd-with-expect"
type: cmd
cmd:
exec: "mycheck"
@@ -577,6 +780,7 @@ targets:
configPath,
`targets:
- name: "cwd-test"
id: "cwd-test"
type: cmd
cmd:
exec: "ls"
@@ -595,6 +799,7 @@ targets:
configPath,
`targets:
- name: "env-test"
id: "env-test"
type: cmd
cmd:
exec: "echo"
@@ -617,6 +822,7 @@ targets:
configPath,
`targets:
- name: "grouped"
id: "grouped"
type: http
group: "搜索引擎"
http:
@@ -634,6 +840,7 @@ targets:
configPath,
`targets:
- name: "no-group"
id: "no-group"
type: http
http:
url: "http://example.com"
@@ -650,6 +857,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
group: 123
http:
@@ -667,6 +875,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -684,6 +893,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -700,6 +910,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -716,6 +927,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -732,6 +944,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -749,6 +962,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -766,6 +980,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -783,6 +998,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -800,6 +1016,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -818,6 +1035,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -836,6 +1054,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -855,6 +1074,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -873,6 +1093,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -893,6 +1114,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -912,6 +1134,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -931,6 +1154,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -951,6 +1175,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -971,6 +1196,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -993,6 +1219,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1012,6 +1239,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1028,6 +1256,7 @@ targets:
"lowercase-method.yaml",
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1045,6 +1274,7 @@ targets:
method: POST
targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1059,6 +1289,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1081,6 +1312,7 @@ targets:
X-Default-Header: "default"
targets:
- name: "http-test"
id: "http-test"
type: http
http:
url: "http://example.com"
@@ -1091,6 +1323,7 @@ targets:
X-Response-Header:
contains: "ok"
- name: "cmd-test"
id: "cmd-test"
type: cmd
cmd:
exec: "true"
@@ -1113,6 +1346,7 @@ targets:
"bad-cmd-args.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1127,6 +1361,7 @@ targets:
"bad-cmd-cwd.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1141,6 +1376,7 @@ targets:
"bad-cmd-env.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1156,6 +1392,7 @@ targets:
"bad-cmd-max-output.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1170,6 +1407,7 @@ targets:
"bad-cmd-exit-code.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1185,6 +1423,7 @@ targets:
"bad-cmd-stdout-empty.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1201,6 +1440,7 @@ targets:
"bad-cmd-stderr-operator.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1217,6 +1457,7 @@ targets:
"bad-cmd-stdout-regex.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1233,6 +1474,7 @@ targets:
"bad-cmd-expect-unknown.yaml",
`targets:
- name: "cmd"
id: "cmd"
type: cmd
cmd:
exec: "echo"
@@ -1249,6 +1491,7 @@ targets:
configPath,
`targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1266,6 +1509,7 @@ targets:
retention: "24h"
targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"
@@ -1282,6 +1526,7 @@ targets:
retention: "7x"
targets:
- name: "test"
id: "test"
type: http
http:
url: "http://example.com"

View File

@@ -15,8 +15,7 @@ const processEnv = Object.fromEntries(
);
function createMockStore(targetNames: string[]) {
let nextId = 1;
const targets = targetNames.map((name) => ({ id: nextId++, name }));
const targets = targetNames.map((name) => ({ id: name, name }));
const results: Array<Record<string, unknown>> = [];
return {
@@ -57,6 +56,7 @@ function makeCommandTarget(name: string, overrides?: Partial<ResolvedCommandTarg
maxOutputBytes: 1024 * 1024,
},
group: "default",
id: name,
intervalMs: 60000,
name,
timeoutMs: 5000,
@@ -177,7 +177,7 @@ describe("ProbeEngine", () => {
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
expect(results.length).toBe(2);
expect(results[0]!["targetId"]).toBe(1);
expect(results[0]!["targetId"]).toBe("reject-cmd");
expect(results[0]!["matched"]).toBe(false);
expect(results[0]!["durationMs"]).toBeNull();
expect(results[0]!["statusDetail"]).toBeNull();
@@ -188,7 +188,7 @@ describe("ProbeEngine", () => {
phase: "internal",
});
expect(typeof results[0]!["timestamp"]).toBe("string");
expect(results[1]!["targetId"]).toBe(2);
expect(results[1]!["targetId"]).toBe("good-cmd");
expect(results[1]!["matched"]).toBe(true);
} finally {
checker.execute = originalExecute;
@@ -235,7 +235,7 @@ describe("ProbeEngine", () => {
expect(true).toBe(true);
});
test("未注册的 targetName 不写入结果", async () => {
test("未注册的 target id 不写入结果", async () => {
const target = makeCommandTarget("unknown-target");
const mockStore = createMockStore(["other-name"]) as unknown as ProbeStore;
const engine = new ProbeEngine(mockStore, [target]);
@@ -268,6 +268,7 @@ describe("ProbeEngine", () => {
method: "GET",
url: `http://localhost:${httpServer.port}/`,
},
id: "http-test",
intervalMs: 60000,
name: "http-test",
timeoutMs: 5000,

View File

@@ -31,6 +31,7 @@ function makeTarget(
...cmd,
},
group: "default",
id: "test-cmd",
intervalMs: 60000,
name: "test-cmd",
timeoutMs: 5000,

View File

@@ -20,6 +20,7 @@ function makeTarget(db: Partial<ResolvedDbTarget["db"]>, overrides?: Partial<Res
...db,
},
group: "default",
id: "test-db",
intervalMs: 60000,
name: "test-db",
timeoutMs: 5000,

View File

@@ -11,7 +11,7 @@ describe("validateDbConfig", () => {
test("缺少 db.url 返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ name: "test", type: "db" }],
targets: [{ id: "test", name: "test", type: "db" }],
});
expect(result.length).toBeGreaterThan(0);
const dbError = result.find((e) => e.path.includes("db"));
@@ -22,7 +22,7 @@ describe("validateDbConfig", () => {
test("db.url 为空字符串返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ db: { url: "" }, name: "test", type: "db" }],
targets: [{ db: { url: "" }, id: "test", name: "test", type: "db" }],
});
const urlError = result.find((e) => e.path.includes("db.url"));
expect(urlError).toBeDefined();
@@ -32,7 +32,7 @@ describe("validateDbConfig", () => {
test("db.query 为空字符串返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ db: { query: "", url: "sqlite://:memory:" }, name: "test", type: "db" }],
targets: [{ db: { query: "", url: "sqlite://:memory:" }, id: "test", name: "test", type: "db" }],
});
const queryError = result.find((e) => e.path.includes("db.query"));
expect(queryError).toBeDefined();
@@ -42,7 +42,7 @@ describe("validateDbConfig", () => {
test("db 分组未知字段返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ db: { timeout: 5, url: "sqlite://:memory:" }, name: "test", type: "db" }],
targets: [{ db: { timeout: 5, url: "sqlite://:memory:" }, id: "test", name: "test", type: "db" }],
});
const unknownError = result.find((e) => e.path.includes("db.timeout"));
expect(unknownError).toBeDefined();
@@ -52,7 +52,15 @@ describe("validateDbConfig", () => {
test("expect.maxDurationMs 非数字返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ db: { url: "sqlite://:memory:" }, expect: { maxDurationMs: "invalid" }, name: "test", type: "db" }],
targets: [
{
db: { url: "sqlite://:memory:" },
expect: { maxDurationMs: "invalid" },
id: "test",
name: "test",
type: "db",
},
],
});
const durationError = result.find((e) => e.path.includes("expect.maxDurationMs"));
expect(durationError).toBeDefined();
@@ -62,7 +70,9 @@ describe("validateDbConfig", () => {
test("expect.rowCount 非法 operator 返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ db: { url: "sqlite://:memory:" }, expect: { rowCount: { foo: 1 } }, name: "test", type: "db" }],
targets: [
{ db: { url: "sqlite://:memory:" }, expect: { rowCount: { foo: 1 } }, id: "test", name: "test", type: "db" },
],
});
const rowCountError = result.find((e) => e.path.includes("expect.rowCount"));
expect(rowCountError).toBeDefined();
@@ -72,7 +82,9 @@ describe("validateDbConfig", () => {
test("expect.rows 不是数组返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ db: { url: "sqlite://:memory:" }, expect: { rows: "not-array" }, name: "test", type: "db" }],
targets: [
{ db: { url: "sqlite://:memory:" }, expect: { rows: "not-array" }, id: "test", name: "test", type: "db" },
],
});
const rowsError = result.find((e) => e.path.includes("expect.rows"));
expect(rowsError).toBeDefined();
@@ -82,7 +94,9 @@ describe("validateDbConfig", () => {
test("expect.rows 元素不是对象返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ db: { url: "sqlite://:memory:" }, expect: { rows: ["not-object"] }, name: "test", type: "db" }],
targets: [
{ db: { url: "sqlite://:memory:" }, expect: { rows: ["not-object"] }, id: "test", name: "test", type: "db" },
],
});
const rowError = result.find((e) => e.path.includes("expect.rows[0]"));
expect(rowError).toBeDefined();
@@ -96,6 +110,7 @@ describe("validateDbConfig", () => {
{
db: { url: "sqlite://:memory:" },
expect: { rows: [{ name: { match: "[invalid" } }] },
id: "test",
name: "test",
type: "db",
},
@@ -109,7 +124,7 @@ describe("validateDbConfig", () => {
test("expect 未知字段返回错误", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ db: { url: "sqlite://:memory:" }, expect: { status: [200] }, name: "test", type: "db" }],
targets: [{ db: { url: "sqlite://:memory:" }, expect: { status: [200] }, id: "test", name: "test", type: "db" }],
});
const unknownError = result.find((e) => e.path.includes("expect.status"));
expect(unknownError).toBeDefined();
@@ -123,6 +138,7 @@ describe("validateDbConfig", () => {
{
db: { query: "SELECT 1", url: "sqlite://:memory:" },
expect: { maxDurationMs: 5000, rowCount: { gte: 1 }, rows: [{ cnt: { gte: 1 } }] },
id: "test",
name: "test",
type: "db",
},
@@ -134,7 +150,7 @@ describe("validateDbConfig", () => {
test("忽略非 db 类型 target", () => {
const result = validateDbConfig({
defaults: {},
targets: [{ name: "test", type: "http" }],
targets: [{ id: "test", name: "test", type: "http" }],
});
expect(result).toHaveLength(0);
});
@@ -143,8 +159,8 @@ describe("validateDbConfig", () => {
const result = validateDbConfig({
defaults: {},
targets: [
{ db: { url: "sqlite://:memory:" }, name: "db1", type: "db" },
{ db: { url: "" }, name: "db2", type: "db" },
{ db: { url: "sqlite://:memory:" }, id: "db1", name: "db1", type: "db" },
{ db: { url: "" }, id: "db2", name: "db2", type: "db" },
],
});
expect(result.length).toBeGreaterThan(0);

View File

@@ -166,6 +166,7 @@ describe("HttpChecker", () => {
method: overrides.method ?? "GET",
url: overrides.url ?? `${baseUrl}/ok`,
},
id: "test-http",
intervalMs: 60000,
name: "test-http",
timeoutMs: overrides.timeoutMs ?? 5000,
@@ -850,6 +851,7 @@ describe("HttpChecker.resolve", () => {
const errors = validateHttpTarget({
expect: { status: ["abc"] },
http: { url: "https://example.com" },
id: "test",
name: "test",
type: "http",
});
@@ -858,7 +860,7 @@ describe("HttpChecker.resolve", () => {
test("ignoreSSL 默认值为 false", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, name: "test", type: "http" },
{ http: { url: "https://example.com" }, id: "test", name: "test", type: "http" },
makeResolveContext(),
);
expect(result.http.ignoreSSL).toBe(false);
@@ -866,7 +868,7 @@ describe("HttpChecker.resolve", () => {
test("maxRedirects 默认值为 0", () => {
const result = checker.resolve(
{ http: { url: "https://example.com" }, name: "test", type: "http" },
{ http: { url: "https://example.com" }, id: "test", name: "test", type: "http" },
makeResolveContext(),
);
expect(result.http.maxRedirects).toBe(0);
@@ -874,7 +876,13 @@ describe("HttpChecker.resolve", () => {
test("合法 status 范围模式通过校验", () => {
const result = checker.resolve(
{ expect: { status: ["2xx", 301] }, http: { url: "https://example.com" }, name: "test", type: "http" },
{
expect: { status: ["2xx", 301] },
http: { url: "https://example.com" },
id: "test",
name: "test",
type: "http",
},
makeResolveContext(),
);
expect(result.expect?.status).toEqual(["2xx", 301]);
@@ -882,7 +890,12 @@ describe("HttpChecker.resolve", () => {
test("显式 ignoreSSL 和 maxRedirects 正确解析", () => {
const result = checker.resolve(
{ http: { ignoreSSL: true, maxRedirects: 3, url: "https://example.com" }, name: "test", type: "http" },
{
http: { ignoreSSL: true, maxRedirects: 3, url: "https://example.com" },
id: "test",
name: "test",
type: "http",
},
makeResolveContext(),
);
expect(result.http.ignoreSSL).toBe(true);

View File

@@ -24,6 +24,10 @@ beforeAll(() => {
ensureRegistered();
});
function targetId(store: ProbeStore, name: string): string {
return store.getTargets().find((target) => target.name === name)!.id;
}
const httpTarget: ResolvedHttpTarget = {
expect: { maxDurationMs: 3000, status: [200] },
group: "default",
@@ -35,6 +39,7 @@ const httpTarget: ResolvedHttpTarget = {
method: "GET",
url: "https://example.com/health",
},
id: "test-http",
intervalMs: 30000,
name: "test-http",
timeoutMs: 10000,
@@ -50,6 +55,7 @@ const commandTarget: ResolvedCommandTarget = {
maxOutputBytes: 104857600,
},
group: "default",
id: "test-cmd",
intervalMs: 60000,
name: "test-cmd",
timeoutMs: 5000,
@@ -79,8 +85,7 @@ describe("ProbeStore", () => {
store.syncTargets([httpTarget, commandTarget]);
const targets = store.getTargets();
expect(targets).toHaveLength(2);
expect(targets[0]!.name).toBe("test-http");
expect(targets[1]!.name).toBe("test-cmd");
expect(targets.map((target) => target.name).sort()).toEqual(["test-cmd", "test-http"]);
});
test("http target 字段正确", () => {
@@ -144,20 +149,18 @@ describe("ProbeStore", () => {
});
test("getTargetById", () => {
const targets = store.getTargets();
const found = store.getTargetById(targets[0]!.id);
const found = store.getTargetById(targetId(store, "test-http"));
expect(found).toBeDefined();
expect(found!.name).toBe("test-http");
});
test("getTargetById 不存在", () => {
expect(store.getTargetById(99999)).toBeNull();
expect(store.getTargetById("missing-target")).toBeNull();
});
test("写入 check result 并查询", () => {
store.syncTargets([httpTarget, commandTarget]);
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t1Id = targetId(store, "test-http");
store.insertCheckResult({
durationMs: 150.5,
@@ -209,8 +212,7 @@ describe("ProbeStore", () => {
});
test("getHistory 默认 limit=20", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t1Id = targetId(store, "test-http");
for (let i = 0; i < 25; i++) {
store.insertCheckResult({
@@ -228,8 +230,7 @@ describe("ProbeStore", () => {
});
test("getTargetWindowStats 按时间窗口计算基础计数", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t1Id = targetId(store, "test-http");
const stats = store.getTargetWindowStats(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(stats.totalChecks).toBeGreaterThan(0);
@@ -239,8 +240,7 @@ describe("ProbeStore", () => {
});
test("无记录目标的窗口 stats", () => {
const targets = store.getTargets();
const t2Id = targets.find((t) => t.name === "test-cmd")!.id;
const t2Id = targetId(store, "test-cmd");
const stats = store.getTargetWindowStats(t2Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(stats.totalChecks).toBe(0);
@@ -251,16 +251,14 @@ describe("ProbeStore", () => {
test("getLatestChecksMap 支持 Dashboard 组装当前状态", () => {
const latestChecksMap = store.getLatestChecksMap();
const targets = store.getTargets();
const latest = latestChecksMap.get(targets[0]!.id);
const latest = latestChecksMap.get(targetId(store, "test-http"));
expect(latest).toBeDefined();
expect(latest!.timestamp).toBe("2025-01-01T01:24:00.000Z");
});
test("getTargetCheckpoints 返回窗口内升序检查点", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t1Id = targetId(store, "test-http");
const checkpoints = store.getTargetCheckpoints(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(checkpoints).toEqual([
@@ -271,16 +269,14 @@ describe("ProbeStore", () => {
});
test("getTargetDurations 返回成功检查耗时升序数组", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t1Id = targetId(store, "test-http");
const durations = store.getTargetDurations(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z");
expect(durations).toEqual([150.5, 300]);
});
test("getRecentSamples 返回最近采样数据", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t1Id = targetId(store, "test-http");
const samples = store.getRecentSamples(t1Id, 10);
expect(Array.isArray(samples)).toBe(true);
@@ -293,15 +289,17 @@ describe("ProbeStore", () => {
test("getAllRecentSamples 返回每个 target 的最近采样数据", () => {
const sampleStore = new ProbeStore(join(tempDir, "all-samples.db"));
const httpA: ResolvedHttpTarget = { ...httpTarget, name: "sample-http-a" };
const httpA: ResolvedHttpTarget = { ...httpTarget, id: "sample-http-a", name: "sample-http-a" };
const httpB: ResolvedHttpTarget = {
...httpTarget,
http: { ...httpTarget.http, url: "https://example.com/other" },
id: "sample-http-b",
name: "sample-http-b",
};
const httpEmpty: ResolvedHttpTarget = {
...httpTarget,
http: { ...httpTarget.http, url: "https://example.com/empty" },
id: "sample-http-empty",
name: "sample-http-empty",
};
sampleStore.syncTargets([httpA, httpB, httpEmpty]);
@@ -360,7 +358,7 @@ describe("ProbeStore", () => {
const closedStore = new ProbeStore(join(tempDir, "closed.db"));
closedStore.close();
expect(closedStore.getTargets()).toHaveLength(0);
expect(closedStore.getTargetById(1)).toBeNull();
expect(closedStore.getTargetById("closed-target")).toBeNull();
});
test("删除 target 级联删除 check_results", () => {
@@ -375,6 +373,7 @@ describe("ProbeStore", () => {
method: "GET",
url: "http://cascade.test",
},
id: "cascade-test",
intervalMs: 30000,
name: "cascade-test",
timeoutMs: 10000,
@@ -437,6 +436,7 @@ describe("ProbeStore", () => {
method: "GET",
url: "http://no.records",
},
id: "no-records",
intervalMs: 30000,
name: "no-records",
timeoutMs: 10000,
@@ -451,9 +451,8 @@ describe("ProbeStore", () => {
});
test("getAllTargetWindowStats 返回所有 target 的窗口聚合统计", () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
const t2Id = targets[1]!.id;
const t1Id = targetId(store, "test-http");
const t2Id = targetId(store, "test-cmd");
const stats = store.getAllTargetWindowStats("2024-01-01T00:00:00.000Z", "2026-12-31T23:59:59.999Z");
expect(stats).toBeInstanceOf(Map);
@@ -484,6 +483,7 @@ describe("ProbeStore", () => {
method: "GET",
url: "http://no.stats",
},
id: "no-stats",
intervalMs: 30000,
name: "no-stats",
timeoutMs: 10000,
@@ -499,7 +499,7 @@ describe("ProbeStore", () => {
test("getAllTargetWindowStats 与 getTargetWindowStats 的 availability 精度一致", () => {
const statsStore = new ProbeStore(join(tempDir, "stats-precision.db"));
const target: ResolvedHttpTarget = { ...httpTarget, name: "stats-precision" };
const target: ResolvedHttpTarget = { ...httpTarget, id: "stats-precision", name: "stats-precision" };
statsStore.syncTargets([target]);
const targetId = statsStore.getTargets()[0]!.id;
@@ -534,10 +534,11 @@ describe("ProbeStore", () => {
test("getDashboardIncidentStates 返回按 target 和 timestamp 升序排列的状态序列", () => {
const incidentStore = new ProbeStore(join(tempDir, "dashboard-incidents.db"));
const httpA: ResolvedHttpTarget = { ...httpTarget, name: "incident-http-a" };
const httpA: ResolvedHttpTarget = { ...httpTarget, id: "incident-http-a", name: "incident-http-a" };
const httpB: ResolvedHttpTarget = {
...httpTarget,
http: { ...httpTarget.http, url: "https://example.com/incident-b" },
id: "incident-http-b",
name: "incident-http-b",
};
incidentStore.syncTargets([httpA, httpB]);

View File

@@ -0,0 +1,313 @@
import { describe, expect, test } from "bun:test";
import { extractVariables, resolveVariables } from "../../../src/server/checker/variables";
describe("config variables", () => {
test("提取合法 variables 类型", () => {
const result = extractVariables({ variables: { enabled: true, host: "example.com", port: 5432 } });
expect(result.issues).toHaveLength(0);
expect(Object.fromEntries(result.variables)).toEqual({ enabled: true, host: "example.com", port: 5432 });
});
test("拒绝非法 variables value 和 key", () => {
const result = extractVariables({
variables: {
"123start": "value",
empty: null,
list: [1, 2, 3],
obj: { a: 1 },
},
});
expect(result.issues.map((item) => [item.code, item.path, item.message])).toEqual([
["invalid-format", "variables.123start", "变量名不符合命名规则"],
["invalid-type", "variables.empty", "变量值不允许为 null"],
["invalid-type", "variables.list", "变量值不允许为 array"],
["invalid-type", "variables.obj", "变量值不允许为 object"],
]);
});
test("解析简单引用、默认值、转义、多变量拼接和无引用字符串", () => {
const result = resolveVariables({
targets: [
{
http: {
body: "Hello $${name}",
headers: {
Authorization: "Bearer ${token}",
Pattern: "${PATTERN|foo|bar}",
},
url: "${protocol}://${host}:${port}/api",
},
id: "api-health",
name: "API",
type: "http",
},
],
variables: { host: "example.com", port: 443, protocol: "https", token: "abc" },
});
expect(result.issues).toHaveLength(0);
const target = (
result.config as { targets: Array<{ http: { body: string; headers: Record<string, string>; url: string } }> }
).targets[0]!;
expect(target.http.url).toBe("https://example.com:443/api");
expect(target.http.headers["Authorization"]).toBe("Bearer abc");
expect(target.http.headers["Pattern"]).toBe("foo|bar");
expect(target.http.body).toBe("Hello ${name}");
});
test("完整引用保留类型,部分引用强制为字符串", () => {
const result = resolveVariables({
targets: [
{
http: {
body: "port: ${port}",
ignoreSSL: "${ssl}",
maxRedirects: "${port}",
url: "${host}",
},
id: "typed-http",
type: "http",
},
],
variables: { host: "https://example.com", port: 5, ssl: true },
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http["url"]).toBe("https://example.com");
expect(http["maxRedirects"]).toBe(5);
expect(http["ignoreSSL"]).toBe(true);
expect(http["body"]).toBe("port: 5");
});
test("环境变量和默认值在完整引用时做类型推断", () => {
const originalMaxRedirects = process.env["MAX_REDIRECTS"];
const originalIgnoreSsl = process.env["IGNORE_SSL"];
process.env["MAX_REDIRECTS"] = "5";
process.env["IGNORE_SSL"] = "false";
try {
const result = resolveVariables({
targets: [
{
http: {
ignoreSSL: "${IGNORE_SSL}",
maxRedirects: "${MAX_REDIRECTS}",
url: "${HOST|localhost}",
},
id: "env-http",
type: "http",
},
],
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http["maxRedirects"]).toBe(5);
expect(http["ignoreSSL"]).toBe(false);
expect(http["url"]).toBe("localhost");
} finally {
restoreEnv("MAX_REDIRECTS", originalMaxRedirects);
restoreEnv("IGNORE_SSL", originalIgnoreSsl);
}
});
test("解析优先级为 variables、环境变量、默认值", () => {
const originalPort = process.env["PORT"];
const originalHost = process.env["HOST"];
process.env["PORT"] = "3000";
process.env["HOST"] = "env-host";
try {
const result = resolveVariables({
targets: [
{
http: {
body: "${MISSING|fallback}",
headers: { Host: "${HOST}" },
maxRedirects: "${PORT}",
url: "${HOST_FROM_VARIABLES}",
},
id: "priority-http",
type: "http",
},
],
variables: { HOST_FROM_VARIABLES: "config-host", PORT: 5432 },
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http["maxRedirects"]).toBe(5432);
expect((http["headers"] as Record<string, string>)["Host"]).toBe("env-host");
expect(http["body"]).toBe("fallback");
expect(http["url"]).toBe("config-host");
} finally {
restoreEnv("PORT", originalPort);
restoreEnv("HOST", originalHost);
}
});
test("替换范围仅 targets且跳过 id 和 type 字段", () => {
const result = resolveVariables({
defaults: { interval: "${interval}" },
server: { host: "${host}" },
targets: [
{
cmd: {
args: ["--host", "${host}"],
env: { TOKEN: "${token}" },
exec: "echo",
},
id: "${id}",
type: "${type}",
},
],
variables: { host: "localhost", id: "resolved", interval: "30s", token: "abc", type: "cmd" },
});
expect(result.issues).toHaveLength(0);
const config = result.config as {
defaults: { interval: string };
server: { host: string };
targets: Array<{ cmd: { args: string[]; env: Record<string, string> }; id: string; type: string }>;
};
expect(config.server.host).toBe("${host}");
expect(config.defaults.interval).toBe("${interval}");
expect(config.targets[0]!.id).toBe("${id}");
expect(config.targets[0]!.type).toBe("${type}");
expect(config.targets[0]!.cmd.args[1]).toBe("localhost");
expect(config.targets[0]!.cmd.env["TOKEN"]).toBe("abc");
});
test("默认值推断为 booleantrue/false", () => {
const result = resolveVariables({
targets: [
{
http: {
ignoreSSL: "${DUMMY_SSL|false}",
url: "${HOST|localhost}",
},
id: "default-bool",
type: "http",
},
],
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http["ignoreSSL"]).toBe(false);
expect(typeof http["ignoreSSL"]).toBe("boolean");
const result2 = resolveVariables({
targets: [
{
http: {
ignoreSSL: "${DUMMY_SSL|true}",
url: "${HOST|localhost}",
},
id: "default-bool-true",
type: "http",
},
],
});
expect(result2.issues).toHaveLength(0);
const http2 = (result2.config as { targets: Array<{ http: Record<string, unknown> }> }).targets[0]!.http;
expect(http2["ignoreSSL"]).toBe(true);
expect(typeof http2["ignoreSSL"]).toBe("boolean");
});
test("runtime 段不替换", () => {
const result = resolveVariables({
runtime: { maxConcurrentChecks: 10, retention: "${retention}" },
targets: [
{
http: { url: "${host}" },
id: "rt-no-replace",
type: "http",
},
],
variables: { host: "https://example.com", retention: "24h" },
});
expect(result.issues).toHaveLength(0);
const config = result.config as { runtime: { retention: string } };
expect(config.runtime.retention).toBe("${retention}");
});
test("variables 段为非对象时报错", () => {
const strResult = extractVariables({ variables: "invalid" });
expect(strResult.issues).toHaveLength(1);
expect(strResult.issues[0]!.code).toBe("invalid-type");
const numResult = extractVariables({ variables: 123 });
expect(numResult.issues).toHaveLength(1);
expect(numResult.issues[0]!.code).toBe("invalid-type");
const nullResult = extractVariables({ variables: null });
expect(nullResult.issues).toHaveLength(1);
expect(nullResult.issues[0]!.code).toBe("invalid-type");
});
test("无 variables 段时环境变量仍可引用", () => {
const original = process.env["DIAL_TEST_ENV_ONLY"];
process.env["DIAL_TEST_ENV_ONLY"] = "env-value";
try {
const result = resolveVariables({
targets: [
{
http: { url: "${DIAL_TEST_ENV_ONLY}" },
id: "env-only",
type: "http",
},
],
});
expect(result.issues).toHaveLength(0);
const http = (result.config as { targets: Array<{ http: { url: string } }> }).targets[0]!.http;
expect(http.url).toBe("env-value");
} finally {
restoreEnv("DIAL_TEST_ENV_ONLY", original);
}
});
test("缺失变量收集所有 unresolved-variable issue", () => {
const result = resolveVariables({
targets: [
{
http: {
headers: { Authorization: "${missing_token}" },
url: "${missing_base_url}/health/${missing_path}",
},
id: "api-health",
type: "http",
},
],
});
expect(result.issues).toHaveLength(3);
expect(result.issues.map((item) => item.code)).toEqual([
"unresolved-variable",
"unresolved-variable",
"unresolved-variable",
]);
expect(result.issues.map((item) => item.path)).toEqual([
"targets[0].http.headers.Authorization",
"targets[0].http.url",
"targets[0].http.url",
]);
expect(result.issues.every((item) => item.targetId === "api-health")).toBe(true);
expect(result.issues.map((item) => item.message).join("\n")).toContain('变量 "missing_base_url"');
});
});
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
return;
}
process.env[key] = value;
}

View File

@@ -10,14 +10,14 @@ import {
} from "../../src/server/middleware";
describe("validateTargetId", () => {
test("有效的 target ID 返回字", () => {
const result = validateTargetId("123", "production");
test("有效的 target ID 返回字符串", () => {
const result = validateTargetId("api-health_01", "production");
expect(result).not.toHaveProperty("status");
expect((result as { id: number }).id).toBe(123);
expect((result as { id: string }).id).toBe("api-health_01");
});
test("无效的 target ID 返回 400", () => {
const invalid = ["0", "-1", "abc", "1.5", ""];
const invalid = ["-1", "_abc", "has space", "1.5", ""];
for (const id of invalid) {
const result = validateTargetId(id, "production");

View File

@@ -10,7 +10,7 @@ describe("OverviewTab", () => {
const target: TargetStatus = {
currentStreak: null,
group: "default",
id: 1,
id: "1",
interval: "30s",
latestCheck: {
durationMs: 100,
@@ -40,7 +40,7 @@ describe("OverviewTab", () => {
totalChecks: 20,
upChecks: 19,
},
targetId: 1,
targetId: "1",
trend: [],
window: { bucket: "1h", from: "", to: "" },
};

View File

@@ -20,7 +20,7 @@ describe("TargetBoard", () => {
{
currentStreak: null,
group: "default",
id: 1,
id: "1",
interval: "30s",
latestCheck: null,
name: "target-1",
@@ -32,7 +32,7 @@ describe("TargetBoard", () => {
{
currentStreak: null,
group: "production",
id: 2,
id: "2",
interval: "30s",
latestCheck: null,
name: "target-2",

View File

@@ -10,7 +10,7 @@ describe("TargetDetailDrawer", () => {
const target: TargetStatus = {
currentStreak: null,
group: "default",
id: 1,
id: "1",
interval: "30s",
latestCheck: {
durationMs: 100,
@@ -40,7 +40,7 @@ describe("TargetDetailDrawer", () => {
totalChecks: 20,
upChecks: 19,
},
targetId: 1,
targetId: "1",
trend: [],
window: { bucket: "1h", from: "", to: "" },
};

View File

@@ -16,7 +16,7 @@ describe("TargetGroup", () => {
{
currentStreak: null,
group: "default",
id: 1,
id: "1",
interval: "30s",
latestCheck: {
durationMs: 100,
@@ -34,7 +34,7 @@ describe("TargetGroup", () => {
{
currentStreak: null,
group: "default",
id: 2,
id: "2",
interval: "30s",
latestCheck: {
durationMs: 100,

View File

@@ -21,7 +21,7 @@ function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return {
currentStreak: null,
group: "default",
id: 1,
id: "1",
interval: "5s",
latestCheck: null,
name: "test",

View File

@@ -13,7 +13,7 @@ function makeTarget(overrides: Partial<TargetStatus> = {}): TargetStatus {
return {
currentStreak: null,
group: "default",
id: 1,
id: "1",
interval: "5s",
latestCheck: null,
name: "test",