1
0
Files
DiAL/tests/server/app.test.ts
lanyuanxiaoyao 3fa1b3957e refactor: 项目更名为 DiAL(统一拨测平台)
将 gateway-checker/Gateway Checker 统一替换为 dial-server/DiAL
- 包名、可执行文件名、API service 标识改为 dial-server
- UI 标题改为 DiAL,副标题改为统一拨测平台
- 同步更新测试断言、构建脚本、示例配置和文档
2026-05-11 22:23:17 +08:00

287 lines
9.7 KiB
TypeScript

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 { HistoryResponse, 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>DiAL</title><div id="root"></div>'], {
type: "text/html",
}),
files: {
"/assets/app.js": new Blob(["console.log('app');"], { type: "text/javascript" }),
},
};
describe("API 路由", () => {
let tempDir: string;
let store: ProbeStore;
let fetchHandler: ReturnType<typeof createFetchHandler>;
beforeAll(async () => {
tempDir = join(tmpdir(), `dial-api-test-${Date.now()}`);
await mkdir(tempDir, { recursive: true });
store = new ProbeStore(join(tempDir, "test.db"));
store.syncTargets([
{
type: "http",
name: "test-a",
group: "default",
http: {
url: "http://a.com",
method: "GET",
headers: {},
maxBodyBytes: 104857600,
},
intervalMs: 30000,
timeoutMs: 10000,
},
{
type: "command",
name: "test-b",
group: "default",
command: {
exec: "echo",
args: ["hello"],
cwd: "/tmp",
env: {},
maxOutputBytes: 104857600,
},
intervalMs: 60000,
timeoutMs: 5000,
},
]);
const targets = store.getTargets();
store.insertCheckResult({
targetId: targets[0]!.id,
timestamp: "2025-01-01T00:00:00.000Z",
matched: true,
durationMs: 150,
statusDetail: "200 OK",
failure: null,
});
store.insertCheckResult({
targetId: targets[0]!.id,
timestamp: "2025-01-01T00:00:30.000Z",
matched: false,
durationMs: null,
statusDetail: null,
failure: {
kind: "error",
phase: "status",
path: "$.status",
expected: 200,
actual: 500,
message: "状态码不匹配",
},
});
fetchHandler = createFetchHandler({ mode: "test", staticAssets, store });
});
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()) as HealthResponse;
expect(response.status).toBe(200);
expect(body.ok).toBe(true);
expect(body.service).toBe("dial-server");
});
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);
expect(body.lastCheckTime).not.toBeNull();
});
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(body).toHaveLength(2);
const tA = body.find((t) => t.name === "test-a")!;
expect(tA.type).toBe("http");
expect(tA.target).toBe("http://a.com");
expect(tA.group).toBe("default");
expect(tA.latestCheck).not.toBeNull();
expect(tA.latestCheck!.matched).toBe(false);
expect(tA.latestCheck!.failure).not.toBeNull();
expect(tA.recentSamples).toBeDefined();
expect(Array.isArray(tA.recentSamples)).toBe(true);
expect(tA.stats.totalChecks).toBeDefined();
expect(tA.stats.availability).toBeDefined();
const tB = body.find((t) => t.name === "test-b")!;
expect(tB.type).toBe("command");
expect(tB.target).toBe("exec echo hello");
expect(tB.latestCheck).toBeNull();
});
test("/api/targets/:id/history 返回历史记录", async () => {
const targets = store.getTargets();
const from = "2024-01-01T00:00:00.000Z";
const to = "2026-12-31T23:59:59.999Z";
const response = await fetchHandler(
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}`),
);
const body = (await response.json()) as HistoryResponse;
expect(response.status).toBe(200);
expect(body.items).toHaveLength(2);
expect(body.total).toBe(2);
expect(body.page).toBe(1);
expect(body.pageSize).toBe(20);
expect(body.items[0]!.failure).not.toBeNull();
expect(body.items[0]!.failure!.kind).toBe("error");
});
test("/api/targets/:id/history 支持 page 参数", async () => {
const targets = store.getTargets();
const from = "2024-01-01T00:00:00.000Z";
const to = "2026-12-31T23:59:59.999Z";
const response = await fetchHandler(
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?from=${from}&to=${to}&pageSize=1`),
);
const body = (await response.json()) as HistoryResponse;
expect(response.status).toBe(200);
expect(body.items).toHaveLength(1);
expect(body.total).toBe(2);
});
test("/api/targets/:id/trend 返回趋势数据", async () => {
const targets = store.getTargets();
const from = "2024-01-01T00:00:00.000Z";
const to = "2026-12-31T23:59:59.999Z";
const response = await fetchHandler(
new Request(`http://localhost/api/targets/${targets[0]!.id}/trend?from=${from}&to=${to}`),
);
const body = await response.json();
expect(response.status).toBe(200);
expect(Array.isArray(body)).toBe(true);
});
test("查询不存在的目标返回 404", async () => {
const response = await fetchHandler(
new Request(
"http://localhost/api/targets/99999/history?from=2024-01-01T00:00:00.000Z&to=2026-12-31T23:59:59.999Z",
),
);
const body = await response.json();
expect(response.status).toBe(404);
expect(body.error).toBe("Target not found");
});
test("history 缺少 from/to 参数返回 400", 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(400);
expect(body.error).toContain("from and to");
});
test("trend 缺少 from/to 参数返回 400", 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(400);
expect(body.error).toContain("from and to");
});
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"));
expect(response.status).toBe(404);
});
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(body).toBe("");
});
test("不支持的 method 返回 405", async () => {
const response = await fetchHandler(new Request("http://localhost/api/summary", { method: "POST" }));
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("GET, HEAD");
});
test("生产响应包含安全 headers", async () => {
const prodHandler = createFetchHandler({ mode: "production", staticAssets, store });
const response = await prodHandler(new Request("http://localhost/api/summary"));
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
});
test("静态资源和 SPA fallback 正常工作", async () => {
const root = await fetchHandler(new Request("http://localhost/"));
expect(root.status).toBe(200);
const fallback = await fetchHandler(new Request("http://localhost/dashboard"));
expect(fallback.status).toBe(200);
const asset = await fetchHandler(new Request("http://localhost/assets/app.js"));
expect(asset.status).toBe(200);
});
test("损坏的 failure JSON 返回 null 而不崩溃", async () => {
const targets = store.getTargets();
const t1Id = targets[0]!.id;
store.insertCheckResult({
targetId: t1Id,
timestamp: "2025-06-01T00:00:00.000Z",
matched: false,
durationMs: 100,
statusDetail: "200 OK",
failure: { kind: "error", phase: "body", path: "$", message: "test" },
});
(store as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }).db
.prepare("UPDATE check_results SET failure = ? WHERE target_id = ? AND timestamp = ?")
.run("{invalid json!!!", t1Id, "2025-06-01T00:00:00.000Z");
const from = "2025-06-01T00:00:00.000Z";
const to = "2025-06-01T23:59:59.999Z";
const response = await fetchHandler(
new Request(`http://localhost/api/targets/${t1Id}/history?from=${from}&to=${to}`),
);
const body = (await response.json()) as HistoryResponse;
expect(response.status).toBe(200);
expect(body.items).toHaveLength(1);
expect(body.items[0]!.failure).toBeNull();
});
});