refactor: 后端架构加固 — 泛型化、批量查询、bootstrap 统一、路径修复与 pageSize 上限
- CheckerDefinition 泛型化,HTTP/Command checker 移除 resolved target 断言 - 新增 ProbeStore.getAllRecentSamples 消除 targets 路由 N+1 查询 - 统一 getAllTargetStats 与 getTargetStats 的 availability 精度 - Engine rejected 结果写入 internal error 记录,提升可观测性 - 新增 bootstrap.ts 统一 dev/production 启动序列 - dataDir 相对路径改为基于配置文件目录解析 - validatePagination 增加 pageSize 上限 200 校验 - 修复 ErrorBoundary override 标记 - 更新 README/DEVELOPMENT 文档,新增完整测试覆盖
This commit is contained in:
@@ -183,6 +183,19 @@ describe("API 路由", () => {
|
||||
expect(body.total).toBe(2);
|
||||
});
|
||||
|
||||
test("history pageSize 超过上限返回 400", async () => {
|
||||
const targets = store.getTargets();
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
const to = "2026-12-31T23:59:59.999Z";
|
||||
const response = fetchHandler(
|
||||
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}&pageSize=201`),
|
||||
);
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body["error"]).toBe("pageSize must not exceed 200");
|
||||
});
|
||||
|
||||
test("/api/targets/:id/trend 返回趋势数据", async () => {
|
||||
const targets = store.getTargets();
|
||||
const from = "2024-01-01T00:00:00.000Z";
|
||||
|
||||
141
tests/server/bootstrap.test.ts
Normal file
141
tests/server/bootstrap.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { StaticAssets } from "../../src/server/app";
|
||||
import type { ResolvedConfig } from "../../src/server/checker/config-loader";
|
||||
import type { ProbeEngine } from "../../src/server/checker/engine";
|
||||
import type { ProbeStore } from "../../src/server/checker/store";
|
||||
import type { ResolvedTargetBase } from "../../src/server/checker/types";
|
||||
|
||||
import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap";
|
||||
|
||||
type ShutdownSignal = "SIGINT" | "SIGTERM";
|
||||
|
||||
const target: ResolvedTargetBase = {
|
||||
group: "default",
|
||||
intervalMs: 30000,
|
||||
name: "test",
|
||||
timeoutMs: 5000,
|
||||
type: "command",
|
||||
};
|
||||
|
||||
function createHarness(overrides: BootstrapDependencies = {}) {
|
||||
const calls: string[] = [];
|
||||
const shutdownHandlers = new Map<ShutdownSignal, () => void>();
|
||||
const config: ResolvedConfig = {
|
||||
configDir: "/tmp",
|
||||
dataDir: "/tmp/dial-data",
|
||||
host: "127.0.0.1",
|
||||
maxConcurrentChecks: 3,
|
||||
port: 3000,
|
||||
retentionMs: 1000,
|
||||
targets: [target],
|
||||
};
|
||||
const store = {
|
||||
close() {
|
||||
calls.push("store.close");
|
||||
},
|
||||
syncTargets(targets: ResolvedTargetBase[]) {
|
||||
calls.push(`syncTargets:${targets.length}`);
|
||||
},
|
||||
} as unknown as ProbeStore;
|
||||
const engine = {
|
||||
start() {
|
||||
calls.push("engine.start");
|
||||
},
|
||||
stop() {
|
||||
calls.push("engine.stop");
|
||||
},
|
||||
} as unknown as ProbeEngine;
|
||||
|
||||
const dependencies: BootstrapDependencies = {
|
||||
createEngine(actualStore, targets, maxConcurrentChecks, retentionMs) {
|
||||
expect(actualStore).toBe(store);
|
||||
calls.push(`createEngine:${targets.length}:${maxConcurrentChecks}:${retentionMs}`);
|
||||
return engine;
|
||||
},
|
||||
createStore(dbPath) {
|
||||
calls.push(`createStore:${dbPath}`);
|
||||
return store;
|
||||
},
|
||||
exit(code) {
|
||||
calls.push(`exit:${code}`);
|
||||
throw new Error(`exit:${code}`);
|
||||
},
|
||||
loadConfig(configPath) {
|
||||
calls.push(`loadConfig:${configPath}`);
|
||||
return Promise.resolve(config);
|
||||
},
|
||||
logError(...data) {
|
||||
calls.push(`logError:${String(data[1])}`);
|
||||
},
|
||||
onSignal(signal, handler) {
|
||||
calls.push(`onSignal:${signal}`);
|
||||
shutdownHandlers.set(signal, handler);
|
||||
},
|
||||
startServer(options) {
|
||||
expect(options.config).toEqual({ host: config.host, port: config.port });
|
||||
expect(options.store).toBe(store);
|
||||
calls.push(`startServer:${options.mode}:${options.staticAssets ? "static" : "no-static"}`);
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return { calls, dependencies, shutdownHandlers };
|
||||
}
|
||||
|
||||
describe("bootstrap", () => {
|
||||
test("开发模式执行完整启动序列", async () => {
|
||||
const { calls, dependencies } = createHarness();
|
||||
|
||||
await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development" }, dependencies);
|
||||
|
||||
expect(calls).toEqual([
|
||||
"loadConfig:/tmp/probes.yaml",
|
||||
"createStore:/tmp/dial-data/probe.db",
|
||||
"syncTargets:1",
|
||||
"createEngine:1:3:1000",
|
||||
"engine.start",
|
||||
"onSignal:SIGINT",
|
||||
"onSignal:SIGTERM",
|
||||
"startServer:development:no-static",
|
||||
]);
|
||||
});
|
||||
|
||||
test("生产模式传递 staticAssets", async () => {
|
||||
const { calls, dependencies } = createHarness();
|
||||
const staticAssets: StaticAssets = { files: {}, indexHtml: new Blob(["ok"]) };
|
||||
|
||||
await bootstrap({ configPath: "/tmp/probes.yaml", mode: "production", staticAssets }, dependencies);
|
||||
|
||||
expect(calls.at(-1)).toBe("startServer:production:static");
|
||||
});
|
||||
|
||||
test("收到退出信号时停止 engine 并关闭 store", async () => {
|
||||
const { calls, dependencies, shutdownHandlers } = createHarness();
|
||||
|
||||
await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development" }, dependencies);
|
||||
|
||||
expect(() => shutdownHandlers.get("SIGINT")!()).toThrow("exit:0");
|
||||
|
||||
expect(calls.slice(-3)).toEqual(["engine.stop", "store.close", "exit:0"]);
|
||||
});
|
||||
|
||||
test("启动失败时输出错误并以非零退出", async () => {
|
||||
const { calls, dependencies } = createHarness({
|
||||
loadConfig() {
|
||||
return Promise.reject(new Error("bad config"));
|
||||
},
|
||||
});
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await bootstrap({ configPath: "/tmp/probes.yaml", mode: "development" }, dependencies);
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe("exit:1");
|
||||
expect(calls).toEqual(["logError:bad config", "exit:1"]);
|
||||
});
|
||||
});
|
||||
@@ -116,7 +116,7 @@ describe("loadConfig", () => {
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.host).toBe("127.0.0.1");
|
||||
expect(config.port).toBe(3000);
|
||||
expect(config.dataDir).toBe("./data");
|
||||
expect(config.dataDir).toBe(join(tempDir, "data"));
|
||||
expect(config.maxConcurrentChecks).toBe(20);
|
||||
expect(config.targets).toHaveLength(1);
|
||||
const t = config.targets[0]! as ResolvedHttpTarget;
|
||||
@@ -205,7 +205,7 @@ targets:
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.host).toBe("0.0.0.0");
|
||||
expect(config.port).toBe(8080);
|
||||
expect(config.dataDir).toBe("./my-data");
|
||||
expect(config.dataDir).toBe(join(tempDir, "my-data"));
|
||||
expect(config.maxConcurrentChecks).toBe(5);
|
||||
expect(config.targets).toHaveLength(2);
|
||||
|
||||
@@ -228,6 +228,25 @@ targets:
|
||||
expect(cmd.command.maxOutputBytes).toBe(10485760);
|
||||
});
|
||||
|
||||
test("绝对 dataDir 保持不变", async () => {
|
||||
const dataDir = join(tempDir, "absolute-data");
|
||||
const configPath = join(tempDir, "absolute-data-dir.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`server:
|
||||
dataDir: "${dataDir}"
|
||||
targets:
|
||||
- name: "test"
|
||||
type: http
|
||||
http:
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.dataDir).toBe(dataDir);
|
||||
});
|
||||
|
||||
test("per-target 覆盖 defaults", async () => {
|
||||
const configPath = join(tempDir, "override.yaml");
|
||||
await writeFile(
|
||||
|
||||
@@ -131,6 +131,48 @@ describe("ProbeEngine", () => {
|
||||
expect(goodResult).toBeDefined();
|
||||
});
|
||||
|
||||
test("checker rejected 时写入 internal error 结果", async () => {
|
||||
ensureRegistered();
|
||||
const checker = checkerRegistry.get("command");
|
||||
const originalExecute = checker.execute.bind(checker);
|
||||
checker.execute = async (target, ctx) => {
|
||||
if (target.name === "reject-cmd") {
|
||||
throw new Error("boom");
|
||||
}
|
||||
return originalExecute(target, ctx);
|
||||
};
|
||||
|
||||
try {
|
||||
const rejectTarget = makeCommandTarget("reject-cmd");
|
||||
const goodTarget = makeCommandTarget("good-cmd");
|
||||
const mockStore = createMockStore(["reject-cmd", "good-cmd"]) as unknown as ProbeStore;
|
||||
const engine = new ProbeEngine(mockStore, [rejectTarget, goodTarget]);
|
||||
|
||||
const probeGroup = (
|
||||
engine as unknown as { probeGroup: (t: ResolvedTargetBase[]) => Promise<void> }
|
||||
).probeGroup.bind(engine);
|
||||
await probeGroup([rejectTarget, goodTarget]);
|
||||
|
||||
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]!["matched"]).toBe(false);
|
||||
expect(results[0]!["durationMs"]).toBeNull();
|
||||
expect(results[0]!["statusDetail"]).toBeNull();
|
||||
expect(results[0]!["failure"]).toEqual({
|
||||
kind: "error",
|
||||
message: "boom",
|
||||
path: "engine",
|
||||
phase: "internal",
|
||||
});
|
||||
expect(typeof results[0]!["timestamp"]).toBe("string");
|
||||
expect(results[1]!["targetId"]).toBe(2);
|
||||
expect(results[1]!["matched"]).toBe(true);
|
||||
} finally {
|
||||
checker.execute = originalExecute;
|
||||
}
|
||||
});
|
||||
|
||||
test("并发限制 maxConcurrentChecks", async () => {
|
||||
const targets = Array.from({ length: 5 }, (_, i) =>
|
||||
makeCommandTarget(`cmd-${i}`, {
|
||||
|
||||
@@ -757,7 +757,7 @@ describe("HttpChecker.resolve", () => {
|
||||
{ http: { url: "https://example.com" }, name: "test", type: "http" },
|
||||
makeResolveContext(),
|
||||
);
|
||||
expect((result as ResolvedHttpTarget).http.ignoreSSL).toBe(false);
|
||||
expect(result.http.ignoreSSL).toBe(false);
|
||||
});
|
||||
|
||||
test("maxRedirects 默认值为 0", () => {
|
||||
@@ -765,7 +765,7 @@ describe("HttpChecker.resolve", () => {
|
||||
{ http: { url: "https://example.com" }, name: "test", type: "http" },
|
||||
makeResolveContext(),
|
||||
);
|
||||
expect((result as ResolvedHttpTarget).http.maxRedirects).toBe(0);
|
||||
expect(result.http.maxRedirects).toBe(0);
|
||||
});
|
||||
|
||||
test("合法 status 范围模式通过校验", () => {
|
||||
@@ -773,7 +773,7 @@ describe("HttpChecker.resolve", () => {
|
||||
{ expect: { status: ["2xx", 301] }, http: { url: "https://example.com" }, name: "test", type: "http" },
|
||||
makeResolveContext(),
|
||||
);
|
||||
expect((result as ResolvedHttpTarget).expect?.status).toEqual(["2xx", 301]);
|
||||
expect(result.expect?.status).toEqual(["2xx", 301]);
|
||||
});
|
||||
|
||||
test("显式 ignoreSSL 和 maxRedirects 正确解析", () => {
|
||||
@@ -781,7 +781,7 @@ describe("HttpChecker.resolve", () => {
|
||||
{ http: { ignoreSSL: true, maxRedirects: 3, url: "https://example.com" }, name: "test", type: "http" },
|
||||
makeResolveContext(),
|
||||
);
|
||||
expect((result as ResolvedHttpTarget).http.ignoreSSL).toBe(true);
|
||||
expect((result as ResolvedHttpTarget).http.maxRedirects).toBe(3);
|
||||
expect(result.http.ignoreSSL).toBe(true);
|
||||
expect(result.http.maxRedirects).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -280,6 +280,71 @@ describe("ProbeStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("getAllRecentSamples 返回每个 target 的最近采样数据", () => {
|
||||
const sampleStore = new ProbeStore(join(tempDir, "all-samples.db"));
|
||||
const httpA: ResolvedHttpTarget = { ...httpTarget, name: "sample-http-a" };
|
||||
const httpB: ResolvedHttpTarget = {
|
||||
...httpTarget,
|
||||
http: { ...httpTarget.http, url: "https://example.com/other" },
|
||||
name: "sample-http-b",
|
||||
};
|
||||
const httpEmpty: ResolvedHttpTarget = {
|
||||
...httpTarget,
|
||||
http: { ...httpTarget.http, url: "https://example.com/empty" },
|
||||
name: "sample-http-empty",
|
||||
};
|
||||
sampleStore.syncTargets([httpA, httpB, httpEmpty]);
|
||||
const targets = sampleStore.getTargets();
|
||||
const targetAId = targets.find((t) => t.name === "sample-http-a")!.id;
|
||||
const targetBId = targets.find((t) => t.name === "sample-http-b")!.id;
|
||||
const emptyTargetId = targets.find((t) => t.name === "sample-http-empty")!.id;
|
||||
|
||||
for (const [index, timestamp] of [
|
||||
"2025-01-01T00:00:00.000Z",
|
||||
"2025-01-01T00:01:00.000Z",
|
||||
"2025-01-01T00:02:00.000Z",
|
||||
].entries()) {
|
||||
sampleStore.insertCheckResult({
|
||||
durationMs: 100 + index,
|
||||
failure: null,
|
||||
matched: index !== 1,
|
||||
statusDetail: "200 OK",
|
||||
targetId: targetAId,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
sampleStore.insertCheckResult({
|
||||
durationMs: 200,
|
||||
failure: null,
|
||||
matched: true,
|
||||
statusDetail: "200 OK",
|
||||
targetId: targetBId,
|
||||
timestamp: "2025-01-01T00:03:00.000Z",
|
||||
});
|
||||
sampleStore.insertCheckResult({
|
||||
durationMs: null,
|
||||
failure: { kind: "error", message: "fail", path: "request", phase: "request" },
|
||||
matched: false,
|
||||
statusDetail: null,
|
||||
targetId: targetBId,
|
||||
timestamp: "2025-01-01T00:04:00.000Z",
|
||||
});
|
||||
|
||||
const samples = sampleStore.getAllRecentSamples(2);
|
||||
|
||||
expect(samples.get(targetAId)).toEqual([
|
||||
{ duration_ms: 102, matched: 1, timestamp: "2025-01-01T00:02:00.000Z" },
|
||||
{ duration_ms: 101, matched: 0, timestamp: "2025-01-01T00:01:00.000Z" },
|
||||
]);
|
||||
expect(samples.get(targetBId)).toEqual([
|
||||
{ duration_ms: null, matched: 0, timestamp: "2025-01-01T00:04:00.000Z" },
|
||||
{ duration_ms: 200, matched: 1, timestamp: "2025-01-01T00:03:00.000Z" },
|
||||
]);
|
||||
expect(samples.has(emptyTargetId)).toBe(false);
|
||||
|
||||
sampleStore.close();
|
||||
});
|
||||
|
||||
test("关闭后操作不报错", () => {
|
||||
const closedStore = new ProbeStore(join(tempDir, "closed.db"));
|
||||
closedStore.close();
|
||||
@@ -420,6 +485,33 @@ describe("ProbeStore", () => {
|
||||
freshStore.close();
|
||||
});
|
||||
|
||||
test("getAllTargetStats 与 getTargetStats 的 availability 精度一致", () => {
|
||||
const statsStore = new ProbeStore(join(tempDir, "stats-precision.db"));
|
||||
const target: ResolvedHttpTarget = { ...httpTarget, name: "stats-precision" };
|
||||
statsStore.syncTargets([target]);
|
||||
const targetId = statsStore.getTargets()[0]!.id;
|
||||
|
||||
for (const [index, matched] of [true, true, false].entries()) {
|
||||
statsStore.insertCheckResult({
|
||||
durationMs: 100,
|
||||
failure: null,
|
||||
matched,
|
||||
statusDetail: matched ? "200 OK" : "500 ERROR",
|
||||
targetId,
|
||||
timestamp: `2025-01-01T00:0${index}:00.000Z`,
|
||||
});
|
||||
}
|
||||
|
||||
const targetStats = statsStore.getTargetStats(targetId);
|
||||
const allStats = statsStore.getAllTargetStats().get(targetId)!;
|
||||
|
||||
expect(targetStats.availability).toBe(66.67);
|
||||
expect(allStats.availability).toBe(66.67);
|
||||
expect(allStats.availability).toBe(targetStats.availability);
|
||||
|
||||
statsStore.close();
|
||||
});
|
||||
|
||||
test("prune 删除过期数据", () => {
|
||||
const pruneStore = new ProbeStore(join(tempDir, "prune.db"));
|
||||
pruneStore.syncTargets([httpTarget]);
|
||||
|
||||
Reference in New Issue
Block a user