feat: 结构化 observation 替代 statusDetail,API 层动态构造 detail
- CheckResult: statusDetail -> observation (持久化) + detail (API 动态派生) - 存储: status_detail 列 -> observation TEXT (JSON) - CheckerDefinition: 新增 buildDetail(observation) 方法 - 各 checker 返回结构化 observation,API 层通过 registry 调用 buildDetail - HTTP: bodyPreview 在 status/header 失败时也提前采集 - UDP: observation 包含 durationMs,未响应归为 error failure - CMD: 超时/输出超限时保留已收集 observation - TCP: connectTimeMs 仅含连接建立耗时,不含 banner 等待 - 新增 buildDetail 单测和 mapCheckResult 覆盖测试 - 同步 openspec 主规范,归档 checker-observation 变更
This commit is contained in:
@@ -80,7 +80,7 @@ describe("API 路由", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
@@ -95,7 +95,7 @@ describe("API 路由", () => {
|
||||
phase: "status",
|
||||
},
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T00:10:00.000Z",
|
||||
});
|
||||
@@ -110,7 +110,7 @@ describe("API 路由", () => {
|
||||
phase: "status",
|
||||
},
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T00:20:00.000Z",
|
||||
});
|
||||
@@ -118,7 +118,7 @@ describe("API 路由", () => {
|
||||
durationMs: 200,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T00:40:00.000Z",
|
||||
});
|
||||
@@ -126,7 +126,7 @@ describe("API 路由", () => {
|
||||
durationMs: 400,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T01:10:00.000Z",
|
||||
});
|
||||
@@ -136,7 +136,7 @@ describe("API 路由", () => {
|
||||
durationMs: 120,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: new Date(now - 90 * 60 * 1000).toISOString(),
|
||||
});
|
||||
@@ -151,7 +151,7 @@ describe("API 路由", () => {
|
||||
phase: "status",
|
||||
},
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: new Date(now - 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
@@ -166,7 +166,7 @@ describe("API 路由", () => {
|
||||
phase: "status",
|
||||
},
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: new Date(now - 30 * 60 * 1000).toISOString(),
|
||||
});
|
||||
@@ -428,7 +428,7 @@ describe("API 路由", () => {
|
||||
durationMs: 100,
|
||||
failure: { kind: "error", message: "test", path: "$", phase: "body" },
|
||||
matched: false,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-06-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("ProbeEngine", () => {
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!["matched"]).toBe(true);
|
||||
expect(results[0]!["statusDetail"]).toBe("exitCode=0");
|
||||
expect((results[0]!["observation"] as Record<string, unknown>)["exitCode"]).toBe(0);
|
||||
});
|
||||
|
||||
test("多个目标并发执行", async () => {
|
||||
@@ -181,7 +181,7 @@ describe("ProbeEngine", () => {
|
||||
expect(results[0]!["targetId"]).toBe("reject-cmd");
|
||||
expect(results[0]!["matched"]).toBe(false);
|
||||
expect(results[0]!["durationMs"]).toBeNull();
|
||||
expect(results[0]!["statusDetail"]).toBeNull();
|
||||
expect(results[0]!["observation"]).toBeNull();
|
||||
expect(results[0]!["failure"]).toEqual({
|
||||
kind: "error",
|
||||
message: "boom",
|
||||
@@ -288,7 +288,7 @@ describe("ProbeEngine", () => {
|
||||
const results = (mockStore as unknown as { _results: Array<Record<string, unknown>> })._results;
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]!["matched"]).toBe(true);
|
||||
expect(results[0]!["statusDetail"]).toBe("HTTP 200");
|
||||
expect((results[0]!["observation"] as Record<string, unknown>)["statusCode"]).toBe(200);
|
||||
} finally {
|
||||
void httpServer.stop();
|
||||
}
|
||||
|
||||
@@ -45,14 +45,14 @@ describe("CommandChecker", () => {
|
||||
test("exitCode=0 成功", async () => {
|
||||
const result = await checker.execute(makeTarget({ args: ["-e", "process.exit(0)"], exec: "bun" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("exitCode=0");
|
||||
expect(result.observation).toMatchObject({ exitCode: 0 });
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("exitCode=1 不匹配默认 [0]", async () => {
|
||||
const result = await checker.execute(makeTarget({ args: ["-e", "process.exit(1)"], exec: "bun" }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("exitCode=1");
|
||||
expect(result.observation).toMatchObject({ exitCode: 1 });
|
||||
expect(result.failure!.phase).toBe("exitCode");
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("CommandChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("exitCode=1");
|
||||
expect(result.observation).toMatchObject({ exitCode: 1 });
|
||||
});
|
||||
|
||||
test("命令不存在返回 spawn 错误", async () => {
|
||||
@@ -79,6 +79,7 @@ describe("CommandChecker", () => {
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.message).toContain("超时");
|
||||
expect(result.observation?.["error"]).toContain("超时");
|
||||
});
|
||||
|
||||
test("stdout 输出捕获", async () => {
|
||||
@@ -130,6 +131,7 @@ describe("CommandChecker", () => {
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure!.message).toContain("超过限制");
|
||||
expect(result.observation?.["error"]).toContain("超过限制");
|
||||
});
|
||||
|
||||
test("durationMs 非空", async () => {
|
||||
|
||||
@@ -34,14 +34,14 @@ describe("DbChecker", () => {
|
||||
test("无 query 时仅测试连接成功", async () => {
|
||||
const result = await checker.execute(makeTarget({}), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("connected");
|
||||
expect(result.observation).toMatchObject({ connected: true });
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
|
||||
test("执行查询成功", async () => {
|
||||
const result = await checker.execute(makeTarget({ query: "SELECT 1 as num, 'hello' as str" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("1 rows");
|
||||
expect(result.observation).toMatchObject({ connected: true, rowCount: 1 });
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
@@ -51,13 +51,13 @@ describe("DbChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("3 rows");
|
||||
expect(result.observation).toMatchObject({ connected: true, rowCount: 3 });
|
||||
});
|
||||
|
||||
test("查询返回空结果", async () => {
|
||||
const result = await checker.execute(makeTarget({ query: "SELECT 1 as n WHERE 1=0" }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("0 rows");
|
||||
expect(result.observation).toMatchObject({ connected: true, rowCount: 0 });
|
||||
});
|
||||
|
||||
test("连接失败返回 connect phase 错误", async () => {
|
||||
|
||||
67
tests/server/checker/runner/detail.test.ts
Normal file
67
tests/server/checker/runner/detail.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { CommandChecker } from "../../../../src/server/checker/runner/cmd/execute";
|
||||
import { DbChecker } from "../../../../src/server/checker/runner/db/execute";
|
||||
import { HttpChecker } from "../../../../src/server/checker/runner/http/execute";
|
||||
import { IcmpChecker } from "../../../../src/server/checker/runner/icmp/execute";
|
||||
import { LlmChecker } from "../../../../src/server/checker/runner/llm/execute";
|
||||
import { TcpChecker } from "../../../../src/server/checker/runner/tcp/execute";
|
||||
import { UdpChecker } from "../../../../src/server/checker/runner/udp/execute";
|
||||
|
||||
describe("Checker buildDetail", () => {
|
||||
test("HTTP detail", () => {
|
||||
expect(new HttpChecker().buildDetail({ statusCode: 200 })).toBe("HTTP 200");
|
||||
});
|
||||
|
||||
test("TCP detail", () => {
|
||||
const detail = new TcpChecker().buildDetail({
|
||||
banner: "220 smtp.example.com ESMTP",
|
||||
connected: true,
|
||||
connectTimeMs: 12,
|
||||
});
|
||||
expect(detail).toContain("connected in 12ms");
|
||||
expect(detail).toContain("banner:");
|
||||
});
|
||||
|
||||
test("UDP detail", () => {
|
||||
const checker = new UdpChecker();
|
||||
expect(checker.buildDetail({ durationMs: 12, responded: true, responsePreview: "PONG", responseSize: 4 })).toBe(
|
||||
"responded in 12ms, 4 bytes, response: PONG",
|
||||
);
|
||||
expect(checker.buildDetail({ durationMs: 200, responded: false })).toBe("no response in 200ms");
|
||||
});
|
||||
|
||||
test("Ping detail", () => {
|
||||
const checker = new IcmpChecker();
|
||||
expect(checker.buildDetail({ alive: true, avgLatencyMs: 12, packetLoss: 0, received: 3, transmitted: 3 })).toBe(
|
||||
"alive, avg 12ms, loss 0% (3/3)",
|
||||
);
|
||||
expect(checker.buildDetail({ alive: false, received: 0, transmitted: 3 })).toBe("unreachable (0/3 received)");
|
||||
});
|
||||
|
||||
test("DB detail", () => {
|
||||
const checker = new DbChecker();
|
||||
expect(checker.buildDetail({ connected: true, rowCount: 3 })).toBe("3 rows");
|
||||
expect(checker.buildDetail({ connected: true, rowCount: null })).toBe("connected");
|
||||
});
|
||||
|
||||
test("CMD detail", () => {
|
||||
expect(new CommandChecker().buildDetail({ exitCode: 0 })).toBe("exitCode=0");
|
||||
});
|
||||
|
||||
test("LLM detail", () => {
|
||||
const detail = new LlmChecker().buildDetail({
|
||||
finishReason: "stop",
|
||||
http: { status: 200 },
|
||||
mode: "http",
|
||||
outputLength: 2,
|
||||
provider: "openai",
|
||||
usage: { inputTokens: 12, outputTokens: 2, totalTokens: 14 },
|
||||
});
|
||||
expect(detail).toContain("LLM openai http");
|
||||
expect(detail).toContain("200");
|
||||
expect(detail).toContain("finish=stop");
|
||||
expect(detail).toContain("output=2 chars");
|
||||
expect(detail).toContain("usage=12/2 tokens");
|
||||
});
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe("HttpChecker", () => {
|
||||
test("成功请求 200", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/ok` }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("HTTP 200");
|
||||
expect(result.observation).toMatchObject({ statusCode: 200 });
|
||||
expect(result.durationMs).not.toBeNull();
|
||||
expect(result.failure).toBeNull();
|
||||
});
|
||||
@@ -192,7 +192,7 @@ describe("HttpChecker", () => {
|
||||
test("404 不匹配默认 status [200]", async () => {
|
||||
const result = await checker.execute(makeTarget({ url: `${baseUrl}/notfound` }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("HTTP 404");
|
||||
expect(result.observation).toMatchObject({ bodyPreview: "not found", statusCode: 404 });
|
||||
expect(result.failure!.phase).toBe("status");
|
||||
});
|
||||
|
||||
@@ -218,6 +218,7 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.observation).toMatchObject({ bodyPreview: "hello world", statusCode: 200 });
|
||||
expect(result.failure!.phase).toBe("headers");
|
||||
});
|
||||
|
||||
@@ -328,13 +329,13 @@ describe("HttpChecker", () => {
|
||||
test("maxRedirects=0 不跟随重定向", async () => {
|
||||
const result = await checker.execute(makeTarget({ maxRedirects: 0, url: `${baseUrl}/redirect` }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("HTTP 301");
|
||||
expect(result.observation).toMatchObject({ statusCode: 301 });
|
||||
});
|
||||
|
||||
test("maxRedirects>0 跟随重定向", async () => {
|
||||
const result = await checker.execute(makeTarget({ maxRedirects: 5, url: `${baseUrl}/redirect` }), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("HTTP 200");
|
||||
expect(result.observation).toMatchObject({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("maxRedirects 精确限制跟随次数", async () => {
|
||||
@@ -343,7 +344,7 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.statusDetail).toBe("HTTP 302");
|
||||
expect(result.observation).toMatchObject({ statusCode: 302 });
|
||||
});
|
||||
|
||||
test("maxRedirects 允许足够次数时到达最终目标", async () => {
|
||||
@@ -352,7 +353,7 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("HTTP 200");
|
||||
expect(result.observation).toMatchObject({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("ignoreSSL 跳过自签名证书校验", async () => {
|
||||
@@ -370,14 +371,14 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(strictResult.matched).toBe(false);
|
||||
expect(strictResult.statusDetail).toBeNull();
|
||||
expect(strictResult.observation).toBeNull();
|
||||
|
||||
const ignoredResult = await checker.execute(
|
||||
makeTarget({ ignoreSSL: true, url: `https://localhost:${httpsServer.port}/` }),
|
||||
makeCtx(),
|
||||
);
|
||||
expect(ignoredResult.matched).toBe(true);
|
||||
expect(ignoredResult.statusDetail).toBe("HTTP 200");
|
||||
expect(ignoredResult.observation).toMatchObject({ statusCode: 200 });
|
||||
} finally {
|
||||
void httpsServer.stop();
|
||||
}
|
||||
@@ -594,7 +595,7 @@ describe("HttpChecker", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toBe("HTTP 200");
|
||||
expect(result.observation).toMatchObject({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("混合 body rules 集成检查", async () => {
|
||||
|
||||
@@ -54,7 +54,13 @@ rtt min/avg/max/mdev = 1.234/2.345/3.456/0.567 ms`);
|
||||
const result = await checker.execute(makeTarget(), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.statusDetail).toBe("alive, avg 2.345ms, loss 0% (3/3)");
|
||||
expect(result.observation).toMatchObject({
|
||||
alive: true,
|
||||
avgLatencyMs: 2.345,
|
||||
packetLoss: 0,
|
||||
received: 3,
|
||||
transmitted: 3,
|
||||
});
|
||||
expect(calls[0]).toContain("ping");
|
||||
});
|
||||
|
||||
@@ -66,7 +72,7 @@ rtt min/avg/max/mdev = 1.234/2.345/3.456/0.567 ms`);
|
||||
);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("alive");
|
||||
expect(result.statusDetail).toBe("unreachable (0/3 received)");
|
||||
expect(result.observation).toMatchObject({ alive: false, received: 0, transmitted: 3 });
|
||||
});
|
||||
|
||||
test("反向 alive 断言通过", async () => {
|
||||
@@ -81,7 +87,7 @@ rtt min/avg/max/mdev = 1.234/156.000/340.000/0.567 ms`);
|
||||
const result = await checker.execute(makeTarget({ expect: { packetLossPercent: { lte: 10 } } }), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.phase).toBe("packetLoss");
|
||||
expect(result.statusDetail).toContain("max 340ms");
|
||||
expect(result.observation).toMatchObject({ alive: true, maxLatencyMs: 340 });
|
||||
});
|
||||
|
||||
test("解析失败返回结构化错误", async () => {
|
||||
@@ -98,7 +104,7 @@ rtt min/avg/max/mdev = 1.234/156.000/340.000/0.567 ms`);
|
||||
const result = await checker.execute(makeTarget(), makeCtx());
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure?.message).toContain("ping 命令不可用");
|
||||
expect(result.statusDetail).toBe("ping command not found");
|
||||
expect(result.observation).toBeNull();
|
||||
});
|
||||
|
||||
test("预 abort 返回超时错误", async () => {
|
||||
|
||||
@@ -111,10 +111,10 @@ describe("LlmChecker execute - 非流式", () => {
|
||||
const result = await checker.execute(makeTarget(), makeCtx());
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.statusDetail).toContain("openai");
|
||||
expect(result.statusDetail).toContain("http");
|
||||
expect(result.statusDetail).toContain("200");
|
||||
expect(result.statusDetail).toContain("finish=stop");
|
||||
expect(result.observation).toMatchObject({ provider: "openai" });
|
||||
expect(result.observation).toMatchObject({ mode: "http" });
|
||||
expect(result.observation).toMatchObject({ http: { status: 200 } });
|
||||
expect(result.observation).toMatchObject({ finishReason: "stop" });
|
||||
});
|
||||
|
||||
test("status expect 不匹配", async () => {
|
||||
@@ -168,12 +168,11 @@ describe("LlmChecker execute - 非流式", () => {
|
||||
expect(result.failure?.phase).toBe("request");
|
||||
});
|
||||
|
||||
test("statusDetail 包含 output 长度和 usage", async () => {
|
||||
test("observation 包含 output 长度和 usage", async () => {
|
||||
const result = await checker.execute(makeTarget(), makeCtx());
|
||||
expect(result.statusDetail).toContain("output=");
|
||||
expect(result.statusDetail).toContain("chars");
|
||||
expect(result.statusDetail).toContain("usage=");
|
||||
expect(result.statusDetail).toContain("tokens");
|
||||
expect(result.observation).toHaveProperty("outputPreview");
|
||||
expect(result.observation).toHaveProperty("outputLength");
|
||||
expect(result.observation).toHaveProperty("usage");
|
||||
});
|
||||
|
||||
test("无文本输出且配置 output expect 失败", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CheckerRegistry } from "../../../../src/server/checker/runner/registry"
|
||||
|
||||
function createChecker(type: string): Checker {
|
||||
return {
|
||||
buildDetail: () => null,
|
||||
configKey: type,
|
||||
execute: () => Promise.resolve<CheckResult>({} as unknown as CheckResult),
|
||||
resolve: () => ({}) as unknown as ResolvedTargetBase,
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("TcpChecker execute", () => {
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
expect(result.statusDetail).toMatch(/^connected in \d+ms$/);
|
||||
expect(result.observation).toMatchObject({ connected: true });
|
||||
});
|
||||
|
||||
test("TCP 连接失败", async () => {
|
||||
@@ -145,7 +145,7 @@ describe("TcpChecker execute", () => {
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.statusDetail).toBeTruthy();
|
||||
expect(result.observation).toMatchObject({ connected: false });
|
||||
});
|
||||
|
||||
test("期望端口不可达但连接成功", async () => {
|
||||
@@ -171,8 +171,11 @@ describe("TcpChecker execute", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toContain("banner:");
|
||||
expect(result.statusDetail).toContain("220 smtp.example.com ESMTP");
|
||||
const obs = result.observation!;
|
||||
expect(obs).toMatchObject({
|
||||
connected: true,
|
||||
});
|
||||
expect(obs["banner"]).toContain("220 smtp.example.com ESMTP");
|
||||
});
|
||||
|
||||
test("banner operator 校验通过", async () => {
|
||||
@@ -206,7 +209,7 @@ describe("TcpChecker execute", () => {
|
||||
makeCtx(),
|
||||
);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).not.toContain("banner:");
|
||||
expect(result.observation?.["banner"]).toBeFalsy();
|
||||
});
|
||||
|
||||
test("banner 超时空字符串继续执行", async () => {
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("UdpChecker execute", () => {
|
||||
cleanup();
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.failure).toBeNull();
|
||||
expect(result.statusDetail).toContain("responded");
|
||||
expect(result.observation).toMatchObject({ responded: true });
|
||||
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
||||
} finally {
|
||||
server.close();
|
||||
@@ -103,7 +103,10 @@ describe("UdpChecker execute", () => {
|
||||
const result = await checker.execute(target, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
expect(result.matched).toBe(false);
|
||||
expect(result.failure).not.toBeNull();
|
||||
expect(result.failure).toMatchObject({ kind: "error", phase: "response" });
|
||||
expect(result.observation?.["durationMs"]).toBeGreaterThanOrEqual(0);
|
||||
expect(result.observation?.["error"]).toBeTruthy();
|
||||
expect(result.observation).toMatchObject({ responded: false });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
@@ -131,7 +134,8 @@ describe("UdpChecker execute", () => {
|
||||
const result = await checker.execute(target, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toContain("no response");
|
||||
expect(result.observation?.["durationMs"]).toBeGreaterThanOrEqual(0);
|
||||
expect(result.observation).toMatchObject({ error: null, responded: false });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
@@ -223,7 +227,7 @@ describe("UdpChecker execute", () => {
|
||||
const result = await checker.execute(target, { signal });
|
||||
cleanup();
|
||||
expect(result.matched).toBe(true);
|
||||
expect(result.statusDetail).toContain("5 bytes");
|
||||
expect(result.observation).toMatchObject({ responded: true, responseSize: 5 });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 150.5,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: "test-http",
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
@@ -172,7 +172,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 300,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: "test-http",
|
||||
timestamp: "2025-01-01T00:00:30.000Z",
|
||||
});
|
||||
@@ -190,7 +190,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: null,
|
||||
failure,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: "test-http",
|
||||
timestamp: "2025-01-01T00:01:00.000Z",
|
||||
});
|
||||
@@ -214,7 +214,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100 + i,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: "test-http",
|
||||
timestamp: `2025-01-01T01:${String(i).padStart(2, "0")}:00.000Z`,
|
||||
});
|
||||
@@ -302,7 +302,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100 + index,
|
||||
failure: null,
|
||||
matched: index !== 1,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: targetAId,
|
||||
timestamp,
|
||||
});
|
||||
@@ -311,7 +311,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 200,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: targetBId,
|
||||
timestamp: "2025-01-01T00:03:00.000Z",
|
||||
});
|
||||
@@ -319,7 +319,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: null,
|
||||
failure: { kind: "error", message: "fail", path: "request", phase: "request" },
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targetBId,
|
||||
timestamp: "2025-01-01T00:04:00.000Z",
|
||||
});
|
||||
@@ -373,7 +373,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
});
|
||||
@@ -381,7 +381,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: null,
|
||||
failure: { kind: "error", message: "fail", path: "$", phase: "status" },
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: "2025-01-01T00:01:00.000Z",
|
||||
});
|
||||
@@ -493,7 +493,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched,
|
||||
statusDetail: matched ? "200 OK" : "500 ERROR",
|
||||
observation: null,
|
||||
targetId,
|
||||
timestamp: `2025-01-01T00:0${index}:00.000Z`,
|
||||
});
|
||||
@@ -535,7 +535,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targetBId,
|
||||
timestamp: "2025-01-01T00:03:00.000Z",
|
||||
});
|
||||
@@ -543,7 +543,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: targetAId,
|
||||
timestamp: "2025-01-01T00:02:00.000Z",
|
||||
});
|
||||
@@ -551,7 +551,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
observation: null,
|
||||
targetId: targetAId,
|
||||
timestamp: "2025-01-01T00:01:00.000Z",
|
||||
});
|
||||
@@ -574,7 +574,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: "2020-01-01T00:00:00.000Z",
|
||||
});
|
||||
@@ -582,7 +582,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -605,7 +605,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
@@ -626,7 +626,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: new Date(now - 3600000).toISOString(),
|
||||
});
|
||||
@@ -634,7 +634,7 @@ describe("ProbeStore", () => {
|
||||
durationMs: 200,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
observation: null,
|
||||
targetId: t.id,
|
||||
timestamp: new Date(now).toISOString(),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createApiError, createHeaders, formatDuration, jsonResponse } from "../../src/server/helpers";
|
||||
import type { StoredCheckResult } from "../../src/server/checker/types";
|
||||
|
||||
import { createApiError, createHeaders, formatDuration, jsonResponse, mapCheckResult } from "../../src/server/helpers";
|
||||
|
||||
describe("createApiError", () => {
|
||||
test("创建错误响应对象", () => {
|
||||
@@ -107,3 +109,42 @@ describe("formatDuration", () => {
|
||||
expect(formatDuration(61123)).toBe("61123ms");
|
||||
});
|
||||
});
|
||||
|
||||
function makeRow(overrides: Partial<StoredCheckResult> = {}): StoredCheckResult {
|
||||
return {
|
||||
duration_ms: 12,
|
||||
failure: null,
|
||||
id: 1,
|
||||
matched: 1,
|
||||
observation: null,
|
||||
target_id: "target-1",
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mapCheckResult", () => {
|
||||
test("反序列化 observation 并构造 detail", () => {
|
||||
const result = mapCheckResult(makeRow({ observation: JSON.stringify({ statusCode: 200 }) }), "http");
|
||||
expect(result.detail).toBe("HTTP 200");
|
||||
expect(result.observation).toEqual({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("null observation 返回 null detail", () => {
|
||||
const result = mapCheckResult(makeRow(), "http");
|
||||
expect(result.detail).toBeNull();
|
||||
expect(result.observation).toBeNull();
|
||||
});
|
||||
|
||||
test("未知 type 不影响响应序列化", () => {
|
||||
const result = mapCheckResult(makeRow({ observation: JSON.stringify({ statusCode: 200 }) }), "unknown");
|
||||
expect(result.detail).toBeNull();
|
||||
expect(result.observation).toEqual({ statusCode: 200 });
|
||||
});
|
||||
|
||||
test("损坏 observation JSON 返回 null observation", () => {
|
||||
const result = mapCheckResult(makeRow({ observation: "{invalid json" }), "http");
|
||||
expect(result.detail).toBeNull();
|
||||
expect(result.observation).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user