feat: 将 demo 项目转化为 HTTP 拨测监控工具
新增 YAML 配置解析(Bun 内置 YAML)、SQLite 数据存储(bun:sqlite)、按 interval 分组并发拨测引擎、REST API(/api/summary、/api/targets、/api/targets/:id/history、/api/targets/:id/trend)、React 前端 Dashboard(统计卡片、目标表格、可展开详情面板、recharts 趋势图)。CLI 简化为仅接受配置文件路径。移除 /api/demo 路由和相关 demo 代码。保留 /health、静态资源服务和 SPA fallback。
This commit is contained in:
@@ -1,132 +1,198 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { createFetchHandler, type StaticAssets } from "../../src/server/app";
|
||||
import { ProbeStore } from "../../src/server/checker/store";
|
||||
import type { SummaryResponse, TargetStatus, HealthResponse } from "../../src/shared/api";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const staticAssets: StaticAssets = {
|
||||
indexHtml: new Blob(['<!doctype html><title>Gateway Checker Demo</title><div id="root"></div>'], {
|
||||
indexHtml: new Blob(['<!doctype html><title>Gateway Checker</title><div id="root"></div>'], {
|
||||
type: "text/html",
|
||||
}),
|
||||
files: {
|
||||
"/assets/app.js": new Blob(["console.log('demo');"], { type: "text/javascript" }),
|
||||
"/assets/app.js": new Blob(["console.log('app');"], { type: "text/javascript" }),
|
||||
},
|
||||
};
|
||||
|
||||
describe("Bun fullstack runtime", () => {
|
||||
const fetchHandler = createFetchHandler({ mode: "test", staticAssets });
|
||||
const productionFetchHandler = createFetchHandler({ mode: "production", staticAssets });
|
||||
describe("API 路由", () => {
|
||||
let tempDir: string;
|
||||
let store: ProbeStore;
|
||||
let fetchHandler: ReturnType<typeof createFetchHandler>;
|
||||
|
||||
test("/api/demo 返回 JSON demo 响应", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/demo"));
|
||||
const body = await response.json();
|
||||
beforeAll(async () => {
|
||||
tempDir = join(tmpdir(), `gc-api-test-${Date.now()}`);
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
store = new ProbeStore(join(tempDir, "test.db"));
|
||||
store.syncTargets([
|
||||
{
|
||||
name: "test-a",
|
||||
url: "http://a.com",
|
||||
method: "GET",
|
||||
headers: {},
|
||||
intervalMs: 30000,
|
||||
timeoutMs: 10000,
|
||||
},
|
||||
{
|
||||
name: "test-b",
|
||||
url: "http://b.com",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
intervalMs: 60000,
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("application/json");
|
||||
expect(body.message).toContain("/api/demo");
|
||||
expect(body.runtime.mode).toBe("test");
|
||||
const targets = store.getTargets();
|
||||
store.insertCheckResult({
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
latencyMs: 150,
|
||||
error: null,
|
||||
matched: true,
|
||||
});
|
||||
store.insertCheckResult({
|
||||
targetId: targets[0]!.id,
|
||||
timestamp: "2025-01-01T00:00:30.000Z",
|
||||
success: false,
|
||||
statusCode: null,
|
||||
latencyMs: null,
|
||||
error: "timeout",
|
||||
matched: false,
|
||||
});
|
||||
|
||||
fetchHandler = createFetchHandler({ mode: "test", staticAssets, store });
|
||||
});
|
||||
|
||||
test("/health 返回机器可读健康检查", async () => {
|
||||
afterAll(async () => {
|
||||
store.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("/health 返回健康检查", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/health"));
|
||||
const body = await response.json();
|
||||
const body = (await response.json()) as HealthResponse;
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.service).toBe("gateway-checker");
|
||||
});
|
||||
|
||||
test("HEAD 请求运行时端点返回 headers 但无 body", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/demo", { method: "HEAD" }));
|
||||
const body = await response.text();
|
||||
test("/api/summary 返回总览统计", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/summary"));
|
||||
const body = (await response.json()) as SummaryResponse;
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.total).toBe(2);
|
||||
expect(body.up).toBeGreaterThanOrEqual(0);
|
||||
expect(body.down).toBeGreaterThanOrEqual(0);
|
||||
expect(body.up + body.down).toBe(2);
|
||||
});
|
||||
|
||||
test("/api/targets 返回目标列表", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/targets"));
|
||||
const body = (await response.json()) as TargetStatus[];
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("application/json");
|
||||
expect(body).toBe("");
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body[0]!.name).toBe("test-a");
|
||||
expect(body[0]!.latestCheck).not.toBeNull();
|
||||
expect(body[0]!.latestCheck!.success).toBe(false);
|
||||
expect(body[0]!.sparkline).toBeDefined();
|
||||
expect(Array.isArray(body[0]!.sparkline)).toBe(true);
|
||||
expect(body[1]!.latestCheck).toBeNull();
|
||||
});
|
||||
|
||||
test("HEAD 请求健康检查端点返回 headers 但无 body", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/health", { method: "HEAD" }));
|
||||
const body = await response.text();
|
||||
test("/api/targets/:id/history 返回历史记录", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history`));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("application/json");
|
||||
expect(body).toBe("");
|
||||
expect(body).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("运行时端点拒绝不支持的 method", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/demo", { method: "POST" }));
|
||||
test("/api/targets/:id/history 支持 limit 参数", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history?limit=1`));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
||||
expect(response.headers.get("content-type")).toContain("application/json");
|
||||
expect(body.status).toBe(405);
|
||||
expect(body.error).toBe("Method not allowed");
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("健康检查端点拒绝不支持的 method", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/health", { method: "POST" }));
|
||||
test("/api/targets/:id/trend 返回趋势数据", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/trend`));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
||||
expect(response.headers.get("content-type")).toContain("application/json");
|
||||
expect(body.status).toBe(405);
|
||||
expect(body.error).toBe("Method not allowed");
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
});
|
||||
|
||||
test("未知 /api/* 路由返回 JSON 404", async () => {
|
||||
test("查询不存在的目标返回 404", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/targets/99999/history"));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body.error).toBe("Target not found");
|
||||
});
|
||||
|
||||
test("无效 limit 参数返回 400", async () => {
|
||||
const targets = store.getTargets();
|
||||
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history?limit=abc`));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe("Invalid limit parameter");
|
||||
});
|
||||
|
||||
test("无效目标 ID 返回 400", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/targets/abc/history"));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.error).toBe("Invalid target ID");
|
||||
});
|
||||
|
||||
test("未知 /api/* 返回 404", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/missing"));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get("content-type")).toContain("application/json");
|
||||
expect(body.error).toBe("API route not found");
|
||||
expect(body.status).toBe(404);
|
||||
});
|
||||
|
||||
test("生产根路径返回前端入口", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/"));
|
||||
test("HEAD 请求返回 headers 无 body", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/summary", { method: "HEAD" }));
|
||||
const body = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("text/html");
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(body).toContain("Gateway Checker Demo");
|
||||
expect(body).toBe("");
|
||||
});
|
||||
|
||||
test("生产静态资源返回正确内容类型", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/assets/app.js"));
|
||||
const body = await response.text();
|
||||
test("不支持的 method 返回 405", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/api/summary", { method: "POST" }));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toContain("text/javascript");
|
||||
expect(response.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
|
||||
expect(body).toContain("demo");
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
||||
});
|
||||
|
||||
test("未知静态资源返回 404 且不 fallback 到入口 HTML", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/assets/missing.js"));
|
||||
const body = await response.text();
|
||||
test("生产响应包含安全 headers", async () => {
|
||||
const prodHandler = createFetchHandler({ mode: "production", staticAssets, store });
|
||||
const response = await prodHandler(new Request("http://localhost/api/summary"));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(body).not.toContain("Gateway Checker Demo");
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
|
||||
});
|
||||
|
||||
test("前端路由 fallback 到入口 HTML", async () => {
|
||||
const response = await fetchHandler(new Request("http://localhost/dashboard"));
|
||||
const body = await response.text();
|
||||
test("静态资源和 SPA fallback 正常工作", async () => {
|
||||
const root = await fetchHandler(new Request("http://localhost/"));
|
||||
expect(root.status).toBe(200);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toContain("Gateway Checker Demo");
|
||||
});
|
||||
const fallback = await fetchHandler(new Request("http://localhost/dashboard"));
|
||||
expect(fallback.status).toBe(200);
|
||||
|
||||
test("生产响应包含低风险安全 headers", async () => {
|
||||
const json = await productionFetchHandler(new Request("http://localhost/api/demo"));
|
||||
const html = await productionFetchHandler(new Request("http://localhost/"));
|
||||
const asset = await productionFetchHandler(new Request("http://localhost/assets/app.js"));
|
||||
|
||||
for (const response of [json, html, asset]) {
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
|
||||
}
|
||||
const asset = await fetchHandler(new Request("http://localhost/assets/app.js"));
|
||||
expect(asset.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
230
tests/server/checker/config-loader.test.ts
Normal file
230
tests/server/checker/config-loader.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { beforeAll, afterAll, describe, expect, test } from "bun:test";
|
||||
import { loadConfig, parseDuration } from "../../../src/server/checker/config-loader";
|
||||
import { readRuntimeConfig } from "../../../src/server/config";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
describe("parseDuration", () => {
|
||||
test("解析秒", () => {
|
||||
expect(parseDuration("30s")).toBe(30000);
|
||||
expect(parseDuration("1s")).toBe(1000);
|
||||
});
|
||||
|
||||
test("解析分钟", () => {
|
||||
expect(parseDuration("5m")).toBe(300000);
|
||||
expect(parseDuration("1m")).toBe(60000);
|
||||
});
|
||||
|
||||
test("解析毫秒", () => {
|
||||
expect(parseDuration("500ms")).toBe(500);
|
||||
expect(parseDuration("100ms")).toBe(100);
|
||||
});
|
||||
|
||||
test("解析小数", () => {
|
||||
expect(parseDuration("1.5s")).toBe(1500);
|
||||
});
|
||||
|
||||
test("无效格式抛出错误", () => {
|
||||
expect(() => parseDuration("30")).toThrow("无效的时长格式");
|
||||
expect(() => parseDuration("abc")).toThrow("无效的时长格式");
|
||||
expect(() => parseDuration("30x")).toThrow("无效的时长格式");
|
||||
expect(() => parseDuration("")).toThrow("无效的时长格式");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readRuntimeConfig", () => {
|
||||
test("返回配置文件路径", () => {
|
||||
expect(readRuntimeConfig(["./probes.yaml"])).toEqual({ configPath: "./probes.yaml" });
|
||||
});
|
||||
|
||||
test("未提供参数抛出错误", () => {
|
||||
expect(() => readRuntimeConfig([])).toThrow("需要指定 YAML 配置文件路径");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadConfig", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = join(tmpdir(), `gc-test-${Date.now()}`);
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("解析完整配置", async () => {
|
||||
const configPath = join(tempDir, "full.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
dataDir: "./my-data"
|
||||
defaults:
|
||||
interval: "15s"
|
||||
timeout: "5s"
|
||||
method: "POST"
|
||||
targets:
|
||||
- name: "test"
|
||||
url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
|
||||
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.targets).toHaveLength(1);
|
||||
expect(config.targets[0]).toEqual({
|
||||
name: "test",
|
||||
url: "http://example.com",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: undefined,
|
||||
intervalMs: 15000,
|
||||
timeoutMs: 5000,
|
||||
expect: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("解析最简配置(只有 targets)", async () => {
|
||||
const configPath = join(tempDir, "minimal.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "t1"
|
||||
url: "http://a.com"
|
||||
- name: "t2"
|
||||
url: "http://b.com"
|
||||
interval: "1m"
|
||||
`,
|
||||
);
|
||||
|
||||
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.targets).toHaveLength(2);
|
||||
expect(config.targets[0]!.intervalMs).toBe(30000);
|
||||
expect(config.targets[1]!.intervalMs).toBe(60000);
|
||||
});
|
||||
|
||||
test("per-target 覆盖 defaults", async () => {
|
||||
const configPath = join(tempDir, "override.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`defaults:
|
||||
interval: "30s"
|
||||
timeout: "10s"
|
||||
method: "GET"
|
||||
headers:
|
||||
Authorization: "Bearer token"
|
||||
targets:
|
||||
- name: "override-all"
|
||||
url: "http://example.com"
|
||||
method: "POST"
|
||||
interval: "5m"
|
||||
timeout: "30s"
|
||||
headers:
|
||||
X-Custom: "value"
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
const target = config.targets[0]!;
|
||||
expect(target.method).toBe("POST");
|
||||
expect(target.intervalMs).toBe(300000);
|
||||
expect(target.timeoutMs).toBe(30000);
|
||||
expect(target.headers).toEqual({ Authorization: "Bearer token", "X-Custom": "value" });
|
||||
});
|
||||
|
||||
test("配置文件不存在抛出错误", async () => {
|
||||
await expect(loadConfig("/nonexistent/file.yaml")).rejects.toThrow("配置文件不存在");
|
||||
});
|
||||
|
||||
test("target 缺少 name 抛出错误", async () => {
|
||||
const configPath = join(tempDir, "no-name.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- url: "http://example.com"
|
||||
`,
|
||||
);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 name 字段");
|
||||
});
|
||||
|
||||
test("target 缺少 url 抛出错误", async () => {
|
||||
const configPath = join(tempDir, "no-url.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "test"
|
||||
`,
|
||||
);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("缺少 url 字段");
|
||||
});
|
||||
|
||||
test("target name 重复抛出错误", async () => {
|
||||
const configPath = join(tempDir, "dup-name.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "dup"
|
||||
url: "http://a.com"
|
||||
- name: "dup"
|
||||
url: "http://b.com"
|
||||
`,
|
||||
);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("target name 重复");
|
||||
});
|
||||
|
||||
test("targets 为空数组抛出错误", async () => {
|
||||
const configPath = join(tempDir, "empty-targets.yaml");
|
||||
await writeFile(configPath, `targets: []`);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("至少一个 target");
|
||||
});
|
||||
|
||||
test("无效端口号抛出错误", async () => {
|
||||
const configPath = join(tempDir, "bad-port.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`server:
|
||||
port: 99999
|
||||
targets:
|
||||
- name: "t"
|
||||
url: "http://a.com"
|
||||
`,
|
||||
);
|
||||
|
||||
await expect(loadConfig(configPath)).rejects.toThrow("无效端口号");
|
||||
});
|
||||
|
||||
test("解析 expect 配置", async () => {
|
||||
const configPath = join(tempDir, "expect.yaml");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`targets:
|
||||
- name: "with-expect"
|
||||
url: "http://example.com"
|
||||
expect:
|
||||
status: [200, 201]
|
||||
bodyContains: "ok"
|
||||
maxLatencyMs: 3000
|
||||
`,
|
||||
);
|
||||
|
||||
const config = await loadConfig(configPath);
|
||||
expect(config.targets[0]!.expect).toEqual({
|
||||
status: [200, 201],
|
||||
bodyContains: "ok",
|
||||
maxLatencyMs: 3000,
|
||||
});
|
||||
});
|
||||
});
|
||||
92
tests/server/checker/engine.test.ts
Normal file
92
tests/server/checker/engine.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { ProbeStore } from "../../../src/server/checker/store";
|
||||
import { ProbeEngine } from "../../../src/server/checker/engine";
|
||||
import type { ResolvedTarget } from "../../../src/server/checker/types";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
describe("ProbeEngine", () => {
|
||||
let tempDir: string;
|
||||
let store: ProbeStore;
|
||||
|
||||
const target: ResolvedTarget = {
|
||||
name: "httpbin",
|
||||
url: "https://httpbin.org/get",
|
||||
method: "GET",
|
||||
headers: {},
|
||||
intervalMs: 60000,
|
||||
timeoutMs: 10000,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = join(tmpdir(), `gc-engine-test-${Date.now()}`);
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
store = new ProbeStore(join(tempDir, "test.db"));
|
||||
store.syncTargets([target]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
store.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("groupByInterval 分组逻辑", () => {
|
||||
const targets: ResolvedTarget[] = [
|
||||
{ name: "a", url: "http://a.com", method: "GET", headers: {}, intervalMs: 30000, timeoutMs: 10000 },
|
||||
{ name: "b", url: "http://b.com", method: "GET", headers: {}, intervalMs: 30000, timeoutMs: 10000 },
|
||||
{ name: "c", url: "http://c.com", method: "GET", headers: {}, intervalMs: 60000, timeoutMs: 10000 },
|
||||
];
|
||||
|
||||
const engine = new ProbeEngine(store, targets);
|
||||
engine.start();
|
||||
engine.stop();
|
||||
|
||||
// 只要能启动和停止不出错就行
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test("engine start/stop 不抛错", () => {
|
||||
const engine = new ProbeEngine(store, [target]);
|
||||
engine.start();
|
||||
engine.stop();
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test("单次拨测写入数据库", async () => {
|
||||
const engine = new ProbeEngine(store, [target]);
|
||||
// 手动调用 probeGroup 不启动 timer
|
||||
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(engine);
|
||||
await probeGroup([target]);
|
||||
|
||||
const dbTargets = store.getTargets();
|
||||
const latest = store.getLatestCheck(dbTargets[0]!.id);
|
||||
expect(latest).not.toBeNull();
|
||||
expect(latest!.success === 1 || latest!.success === 0).toBe(true);
|
||||
});
|
||||
|
||||
test("单目标失败隔离", async () => {
|
||||
const badTarget: ResolvedTarget = {
|
||||
name: "bad-target",
|
||||
url: "http://127.0.0.1:1/impossible",
|
||||
method: "GET",
|
||||
headers: {},
|
||||
intervalMs: 60000,
|
||||
timeoutMs: 2000,
|
||||
};
|
||||
|
||||
store.syncTargets([target, badTarget]);
|
||||
|
||||
const engine = new ProbeEngine(store, [target, badTarget]);
|
||||
const probeGroup = (engine as unknown as { probeGroup: (t: ResolvedTarget[]) => Promise<void> }).probeGroup.bind(engine);
|
||||
await probeGroup([target, badTarget]);
|
||||
|
||||
const dbTargets = store.getTargets();
|
||||
const goodResult = store.getLatestCheck(dbTargets.find((t) => t.name === "httpbin")!.id);
|
||||
const badResult = store.getLatestCheck(dbTargets.find((t) => t.name === "bad-target")!.id);
|
||||
|
||||
expect(goodResult).not.toBeNull();
|
||||
expect(badResult).not.toBeNull();
|
||||
expect(badResult!.success).toBe(0);
|
||||
});
|
||||
});
|
||||
45
tests/server/checker/fetcher.test.ts
Normal file
45
tests/server/checker/fetcher.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { checkExpect } from "../../../src/server/checker/fetcher";
|
||||
|
||||
describe("checkExpect", () => {
|
||||
test("无 expect 配置时 matched 为 true", () => {
|
||||
expect(checkExpect(200, "ok", 100, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test("status 匹配", () => {
|
||||
expect(checkExpect(200, "", 100, { status: [200, 201] })).toBe(true);
|
||||
expect(checkExpect(201, "", 100, { status: [200, 201] })).toBe(true);
|
||||
expect(checkExpect(404, "", 100, { status: [200, 201] })).toBe(false);
|
||||
});
|
||||
|
||||
test("bodyContains 匹配", () => {
|
||||
expect(checkExpect(200, "hello world", 100, { bodyContains: "hello" })).toBe(true);
|
||||
expect(checkExpect(200, "hello world", 100, { bodyContains: "missing" })).toBe(false);
|
||||
});
|
||||
|
||||
test("maxLatencyMs 匹配", () => {
|
||||
expect(checkExpect(200, "", 100, { maxLatencyMs: 200 })).toBe(true);
|
||||
expect(checkExpect(200, "", 300, { maxLatencyMs: 200 })).toBe(false);
|
||||
expect(checkExpect(200, "", 200, { maxLatencyMs: 200 })).toBe(true);
|
||||
});
|
||||
|
||||
test("多条 expect 全部通过", () => {
|
||||
expect(
|
||||
checkExpect(200, "healthy", 100, {
|
||||
status: [200],
|
||||
bodyContains: "healthy",
|
||||
maxLatencyMs: 200,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("多条 expect 部分失败", () => {
|
||||
expect(
|
||||
checkExpect(200, "healthy", 500, {
|
||||
status: [200],
|
||||
bodyContains: "healthy",
|
||||
maxLatencyMs: 200,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
195
tests/server/checker/store.test.ts
Normal file
195
tests/server/checker/store.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { ProbeStore } from "../../../src/server/checker/store";
|
||||
import type { ResolvedTarget } from "../../../src/server/checker/types";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
describe("ProbeStore", () => {
|
||||
let tempDir: string;
|
||||
let store: ProbeStore;
|
||||
|
||||
const target1: ResolvedTarget = {
|
||||
name: "test-a",
|
||||
url: "http://a.com",
|
||||
method: "GET",
|
||||
headers: {},
|
||||
intervalMs: 30000,
|
||||
timeoutMs: 10000,
|
||||
};
|
||||
|
||||
const target2: ResolvedTarget = {
|
||||
name: "test-b",
|
||||
url: "http://b.com",
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: '{"ping": true}',
|
||||
intervalMs: 60000,
|
||||
timeoutMs: 5000,
|
||||
expect: { status: [200], maxLatencyMs: 3000 },
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = join(tmpdir(), `gc-store-test-${Date.now()}`);
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
store = new ProbeStore(join(tempDir, "test.db"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
store.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("初始化后无 targets", () => {
|
||||
expect(store.getTargets()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("同步新增 targets", () => {
|
||||
store.syncTargets([target1, target2]);
|
||||
const targets = store.getTargets();
|
||||
expect(targets).toHaveLength(2);
|
||||
expect(targets[0]!.name).toBe("test-a");
|
||||
expect(targets[1]!.name).toBe("test-b");
|
||||
});
|
||||
|
||||
test("同步后 target 字段正确", () => {
|
||||
const targets = store.getTargets();
|
||||
const t2 = targets.find((t) => t.name === "test-b")!;
|
||||
expect(t2.url).toBe("http://b.com");
|
||||
expect(t2.method).toBe("POST");
|
||||
expect(JSON.parse(t2.headers)).toEqual({ "Content-Type": "application/json" });
|
||||
expect(t2.body).toBe('{"ping": true}');
|
||||
expect(t2.interval_ms).toBe(60000);
|
||||
expect(t2.expect).toBe(JSON.stringify({ status: [200], maxLatencyMs: 3000 }));
|
||||
});
|
||||
|
||||
test("同步更新已有 target", () => {
|
||||
store.syncTargets([{ ...target1, url: "http://a-v2.com" }, target2]);
|
||||
const targets = store.getTargets();
|
||||
const t1 = targets.find((t) => t.name === "test-a")!;
|
||||
expect(t1.url).toBe("http://a-v2.com");
|
||||
expect(targets).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("同步删除 target", () => {
|
||||
store.syncTargets([target1]);
|
||||
const targets = store.getTargets();
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]!.name).toBe("test-a");
|
||||
});
|
||||
|
||||
test("重新同步回来", () => {
|
||||
store.syncTargets([target1, target2]);
|
||||
expect(store.getTargets()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("getTargetById", () => {
|
||||
const targets = store.getTargets();
|
||||
const found = store.getTargetById(targets[0]!.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.name).toBe("test-a");
|
||||
});
|
||||
|
||||
test("getTargetById 不存在", () => {
|
||||
expect(store.getTargetById(99999)).toBeNull();
|
||||
});
|
||||
|
||||
test("写入和查询 check result", () => {
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
store.insertCheckResult({
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
latencyMs: 150,
|
||||
error: null,
|
||||
matched: true,
|
||||
});
|
||||
|
||||
store.insertCheckResult({
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:00:30.000Z",
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
latencyMs: 300,
|
||||
error: null,
|
||||
matched: true,
|
||||
});
|
||||
|
||||
store.insertCheckResult({
|
||||
targetId: t1Id,
|
||||
timestamp: "2025-01-01T00:01:00.000Z",
|
||||
success: false,
|
||||
statusCode: null,
|
||||
latencyMs: null,
|
||||
error: "timeout",
|
||||
matched: false,
|
||||
});
|
||||
|
||||
const history = store.getHistory(t1Id, 10);
|
||||
expect(history).toHaveLength(3);
|
||||
expect(history[0]!.timestamp).toBe("2025-01-01T00:01:00.000Z");
|
||||
|
||||
const latest = store.getLatestCheck(t1Id)!;
|
||||
expect(latest.success).toBe(0);
|
||||
expect(latest.error).toBe("timeout");
|
||||
});
|
||||
|
||||
test("getHistory 默认 limit=20", () => {
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
for (let i = 0; i < 25; i++) {
|
||||
store.insertCheckResult({
|
||||
targetId: t1Id,
|
||||
timestamp: `2025-01-01T00:${String(i).padStart(2, "0")}:00.000Z`,
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
latencyMs: 100 + i,
|
||||
error: null,
|
||||
matched: true,
|
||||
});
|
||||
}
|
||||
|
||||
const history = store.getHistory(t1Id);
|
||||
expect(history).toHaveLength(20);
|
||||
});
|
||||
|
||||
test("getTargetStats 计算可用率和延迟", () => {
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
const stats = store.getTargetStats(t1Id);
|
||||
expect(stats.totalChecks).toBeGreaterThan(0);
|
||||
expect(stats.availability).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.availability).toBeLessThanOrEqual(100);
|
||||
expect(stats.avgLatencyMs).not.toBeNull();
|
||||
});
|
||||
|
||||
test("无记录目标的 stats", () => {
|
||||
const targets = store.getTargets();
|
||||
const t2Id = targets.find((t) => t.name === "test-b")!.id;
|
||||
|
||||
const stats = store.getTargetStats(t2Id);
|
||||
expect(stats.totalChecks).toBe(0);
|
||||
expect(stats.availability).toBe(0);
|
||||
expect(stats.avgLatencyMs).toBeNull();
|
||||
});
|
||||
|
||||
test("getSummary 返回总览统计", () => {
|
||||
const summary = store.getSummary();
|
||||
expect(summary.total).toBe(2);
|
||||
expect(summary.up + summary.down).toBe(2);
|
||||
expect(summary.lastCheckTime).not.toBeNull();
|
||||
});
|
||||
|
||||
test("getTrend 返回趋势数据", () => {
|
||||
const targets = store.getTargets();
|
||||
const t1Id = targets[0]!.id;
|
||||
|
||||
const trend = store.getTrend(t1Id, 24);
|
||||
expect(Array.isArray(trend)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,37 +2,11 @@ import { describe, expect, test } from "bun:test";
|
||||
import { readRuntimeConfig } from "../../src/server/config";
|
||||
|
||||
describe("runtime config", () => {
|
||||
test("默认使用 127.0.0.1:3000", () => {
|
||||
expect(readRuntimeConfig([], {})).toEqual({ host: "127.0.0.1", port: 3000 });
|
||||
test("返回配置文件路径", () => {
|
||||
expect(readRuntimeConfig(["./probes.yaml"])).toEqual({ configPath: "./probes.yaml" });
|
||||
});
|
||||
|
||||
test("CLI 参数优先于环境变量", () => {
|
||||
expect(readRuntimeConfig(["--host", "0.0.0.0", "--port", "4001"], { HOST: "127.0.0.1", PORT: "3001" })).toEqual({
|
||||
host: "0.0.0.0",
|
||||
port: 4001,
|
||||
});
|
||||
});
|
||||
|
||||
test("环境变量可以覆盖默认端口", () => {
|
||||
expect(readRuntimeConfig([], { PORT: "4100" })).toEqual({ host: "127.0.0.1", port: 4100 });
|
||||
});
|
||||
|
||||
test("支持 inline CLI 参数", () => {
|
||||
expect(readRuntimeConfig(["--host=localhost", "--port=4002"], {})).toEqual({
|
||||
host: "localhost",
|
||||
port: 4002,
|
||||
});
|
||||
});
|
||||
|
||||
test("拒绝无效端口", () => {
|
||||
expect(() => readRuntimeConfig(["--port", "invalid"], {})).toThrow("无效端口");
|
||||
expect(() => readRuntimeConfig(["--port", "3000.5"], {})).toThrow("无效端口");
|
||||
expect(() => readRuntimeConfig(["--port", "-1"], {})).toThrow("无效端口");
|
||||
expect(() => readRuntimeConfig(["--port", "65536"], {})).toThrow("无效端口");
|
||||
});
|
||||
|
||||
test("接受端口边界值", () => {
|
||||
expect(readRuntimeConfig(["--port", "0"], {})).toEqual({ host: "127.0.0.1", port: 0 });
|
||||
expect(readRuntimeConfig(["--port", "65535"], {})).toEqual({ host: "127.0.0.1", port: 65535 });
|
||||
test("未提供参数抛出错误", () => {
|
||||
expect(() => readRuntimeConfig([])).toThrow("需要指定 YAML 配置文件路径");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user