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, SummaryResponse, TargetStatus } from "../../src/shared/api"; import { createFetchHandler, type StaticAssets } from "../../src/server/app"; import { checkerRegistry } from "../../src/server/checker/runner"; import { CommandChecker } from "../../src/server/checker/runner/command/runner"; import { HttpChecker } from "../../src/server/checker/runner/http/runner"; import { ProbeStore } from "../../src/server/checker/store"; import { rmRetry } from "../helpers"; function ensureRegistered() { if (!checkerRegistry.supportedTypes.includes("http")) { checkerRegistry.register(new HttpChecker()); checkerRegistry.register(new CommandChecker()); } } beforeAll(() => { ensureRegistered(); }); const staticAssets: StaticAssets = { files: { "/assets/app.js": new Blob(["console.log('app');"], { type: "text/javascript" }), }, indexHtml: new Blob(['DiAL
'], { type: "text/html", }), }; describe("API 路由", () => { let tempDir: string; let store: ProbeStore; let fetchHandler: ReturnType; 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: {}, maxBodyBytes: 104857600, 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", }); fetchHandler = createFetchHandler({ mode: "test", staticAssets, store }); }); afterAll(async () => { store.close(); await rmRetry(tempDir); }); test("/health 返回健康检查", async () => { const response = 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 = 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 = 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 = 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 = 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 = fetchHandler( new Request(`http://localhost/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 = 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()) as Record; expect(response.status).toBe(404); expect(body["error"]).toBe("Target not found"); }); test("history 缺少 from/to 参数返回 400", async () => { const targets = store.getTargets(); const response = fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history`)); const body = (await response.json()) as Record; expect(response.status).toBe(400); expect(body["error"]).toContain("from and to"); }); test("trend 缺少 from/to 参数返回 400", async () => { const targets = store.getTargets(); const response = fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/trend`)); const body = (await response.json()) as Record; expect(response.status).toBe(400); expect(body["error"]).toContain("from and to"); }); test("trend 无效 targetId 返回 400", async () => { const response = fetchHandler( new Request("http://localhost/api/targets/invalid/trend?from=2024-01-01T00:00:00Z&to=2024-01-02T00:00:00Z"), ); const body = (await response.json()) as Record; expect(response.status).toBe(400); expect(body["error"]).toBe("Invalid target ID"); }); test("未知 /api/* 返回 404", () => { const response = fetchHandler(new Request("http://localhost/api/missing")); expect(response.status).toBe(404); }); test("HEAD 请求返回 headers 无 body", async () => { const response = 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", () => { const response = fetchHandler(new Request("http://localhost/api/summary", { method: "POST" })); expect(response.status).toBe(405); expect(response.headers.get("allow")).toBe("GET, HEAD"); }); test("生产响应包含安全 headers", () => { const prodHandler = createFetchHandler({ mode: "production", staticAssets, store }); const response = 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 正常工作", () => { const root = fetchHandler(new Request("http://localhost/")); expect(root.status).toBe(200); const fallback = fetchHandler(new Request("http://localhost/dashboard")); expect(fallback.status).toBe(200); const asset = 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({ 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 = 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(); }); });