308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
import { mkdir } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import type {
|
|
HealthResponse,
|
|
HistoryResponse,
|
|
MetaResponse,
|
|
SummaryResponse,
|
|
TargetStatus,
|
|
} from "../../src/shared/api";
|
|
|
|
import { checkerRegistry } from "../../src/server/checker/runner";
|
|
import { CommandChecker } from "../../src/server/checker/runner/command/execute";
|
|
import { HttpChecker } from "../../src/server/checker/runner/http/execute";
|
|
import { ProbeStore } from "../../src/server/checker/store";
|
|
import { startServer } from "../../src/server/server";
|
|
import { rmRetry } from "../helpers";
|
|
|
|
function ensureRegistered() {
|
|
if (!checkerRegistry.supportedTypes.includes("http")) {
|
|
checkerRegistry.register(new HttpChecker());
|
|
checkerRegistry.register(new CommandChecker());
|
|
}
|
|
}
|
|
|
|
beforeAll(() => {
|
|
ensureRegistered();
|
|
});
|
|
|
|
describe("API 路由", () => {
|
|
let tempDir: string;
|
|
let store: ProbeStore;
|
|
let server: ReturnType<typeof startServer>;
|
|
let baseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
tempDir = join(tmpdir(), `dial-api-test-${Date.now()}`);
|
|
await mkdir(tempDir, { recursive: true });
|
|
store = new ProbeStore(join(tempDir, "test.db"));
|
|
store.syncTargets([
|
|
{
|
|
group: "default",
|
|
http: {
|
|
headers: {},
|
|
ignoreSSL: false,
|
|
maxBodyBytes: 104857600,
|
|
maxRedirects: 0,
|
|
method: "GET",
|
|
url: "http://a.com",
|
|
},
|
|
intervalMs: 30000,
|
|
name: "test-a",
|
|
timeoutMs: 10000,
|
|
type: "http",
|
|
},
|
|
{
|
|
command: {
|
|
args: ["hello"],
|
|
cwd: "/tmp",
|
|
env: {},
|
|
exec: "echo",
|
|
maxOutputBytes: 104857600,
|
|
},
|
|
group: "default",
|
|
intervalMs: 60000,
|
|
name: "test-b",
|
|
timeoutMs: 5000,
|
|
type: "command",
|
|
},
|
|
]);
|
|
|
|
const targets = store.getTargets();
|
|
store.insertCheckResult({
|
|
durationMs: 150,
|
|
failure: null,
|
|
matched: true,
|
|
statusDetail: "200 OK",
|
|
targetId: targets[0]!.id,
|
|
timestamp: "2025-01-01T00:00:00.000Z",
|
|
});
|
|
store.insertCheckResult({
|
|
durationMs: null,
|
|
failure: {
|
|
actual: 500,
|
|
expected: 200,
|
|
kind: "error",
|
|
message: "状态码不匹配",
|
|
path: "$.status",
|
|
phase: "status",
|
|
},
|
|
matched: false,
|
|
statusDetail: null,
|
|
targetId: targets[0]!.id,
|
|
timestamp: "2025-01-01T00:00:30.000Z",
|
|
});
|
|
|
|
server = startServer({
|
|
config: { host: "127.0.0.1", port: 0 },
|
|
mode: "test",
|
|
store,
|
|
});
|
|
baseUrl = `http://127.0.0.1:${server.port}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.stop(true);
|
|
store.close();
|
|
await rmRetry(tempDir);
|
|
});
|
|
|
|
test("/health 返回健康检查", async () => {
|
|
const response = await fetch(`${baseUrl}/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 fetch(`${baseUrl}/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 fetch(`${baseUrl}/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/meta 返回 checker 类型列表", async () => {
|
|
const response = await fetch(`${baseUrl}/api/meta`);
|
|
const body = (await response.json()) as MetaResponse;
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body.checkerTypes).toEqual(checkerRegistry.supportedTypes);
|
|
expect(body.checkerTypes).toContain("http");
|
|
expect(body.checkerTypes).toContain("command");
|
|
});
|
|
|
|
test("不支持的 method 在有 API 通配符时返回 404", async () => {
|
|
const response = await fetch(`${baseUrl}/api/summary`, { method: "POST" });
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
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 fetch(`${baseUrl}/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 fetch(`${baseUrl}/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("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 = await fetch(`${baseUrl}/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";
|
|
const to = "2026-12-31T23:59:59.999Z";
|
|
const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/trend?from=${from}&to=${to}`);
|
|
const body = (await response.json()) as unknown[];
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(Array.isArray(body)).toBe(true);
|
|
});
|
|
|
|
test("查询不存在的目标返回 404", async () => {
|
|
const response = await fetch(
|
|
`${baseUrl}/api/targets/99999/history?from=2024-01-01T00:00:00.000Z&to=2026-12-31T23:59:59.999Z`,
|
|
);
|
|
const body = (await response.json()) as Record<string, unknown>;
|
|
|
|
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 fetch(`${baseUrl}/api/targets/${targets[0]!.id}/history`);
|
|
const body = (await response.json()) as Record<string, unknown>;
|
|
|
|
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 fetch(`${baseUrl}/api/targets/${targets[0]!.id}/trend`);
|
|
const body = (await response.json()) as Record<string, unknown>;
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(body["error"]).toContain("from and to");
|
|
});
|
|
|
|
test("trend 无效 targetId 返回 400", async () => {
|
|
const response = await fetch(
|
|
`${baseUrl}/api/targets/invalid/trend?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z`,
|
|
);
|
|
const body = (await response.json()) as Record<string, unknown>;
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(body["error"]).toBe("Invalid target ID");
|
|
});
|
|
|
|
test("未知 /api/* 返回 404", async () => {
|
|
const response = await fetch(`${baseUrl}/api/missing`);
|
|
expect(response.status).toBe(404);
|
|
});
|
|
|
|
test("生产响应包含安全 headers", async () => {
|
|
const prodServer = startServer({
|
|
config: { host: "127.0.0.1", port: 0 },
|
|
mode: "production",
|
|
store,
|
|
});
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${prodServer.port}/api/summary`);
|
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
|
expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
|
|
} finally {
|
|
await prodServer.stop(true);
|
|
}
|
|
});
|
|
|
|
test("损坏的 failure JSON 返回 null 而不崩溃", async () => {
|
|
const targets = store.getTargets();
|
|
const t1Id = targets[0]!.id;
|
|
|
|
store.insertCheckResult({
|
|
durationMs: 100,
|
|
failure: { kind: "error", message: "test", path: "$", phase: "body" },
|
|
matched: false,
|
|
statusDetail: "200 OK",
|
|
targetId: t1Id,
|
|
timestamp: "2025-06-01T00:00:00.000Z",
|
|
});
|
|
|
|
(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 fetch(`${baseUrl}/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();
|
|
});
|
|
});
|