1
0

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:
2026-05-09 17:04:25 +08:00
parent 9267f6585c
commit 57d3a5cfb4
43 changed files with 2910 additions and 525 deletions

View File

@@ -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);
});
});