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 { DashboardResponse, HealthResponse, HistoryResponse, MetaResponse, TargetMetricsResponse, } from "../../src/shared/api"; import { checkerRegistry } from "../../src/server/checker/runner"; import { CommandChecker } from "../../src/server/checker/runner/cmd/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; 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([ { description: null, group: "default", http: { headers: {}, ignoreSSL: false, maxBodyBytes: 104857600, maxRedirects: 0, method: "GET", url: "http://a.com", }, id: "test-a", intervalMs: 30000, name: "test-a", timeoutMs: 10000, type: "http", }, { cmd: { args: ["hello"], cwd: "/tmp", env: {}, exec: "echo", maxOutputBytes: 104857600, }, description: null, group: "default", id: "test-b", intervalMs: 60000, name: "test-b", timeoutMs: 5000, type: "cmd", }, ]); const targets = store.getTargets(); store.insertCheckResult({ durationMs: 100, failure: null, matched: true, observation: null, 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, observation: null, targetId: targets[0]!.id, timestamp: "2025-01-01T00:10:00.000Z", }); store.insertCheckResult({ durationMs: null, failure: { actual: 500, expected: 200, kind: "error", message: "状态码不匹配", path: "$.status", phase: "status", }, matched: false, observation: null, targetId: targets[0]!.id, timestamp: "2025-01-01T00:20:00.000Z", }); store.insertCheckResult({ durationMs: 200, failure: null, matched: true, observation: null, targetId: targets[0]!.id, timestamp: "2025-01-01T00:40:00.000Z", }); store.insertCheckResult({ durationMs: 400, failure: null, matched: true, observation: null, targetId: targets[0]!.id, timestamp: "2025-01-01T01:10:00.000Z", }); const now = Date.now(); store.insertCheckResult({ durationMs: 120, failure: null, matched: true, observation: null, targetId: targets[0]!.id, timestamp: new Date(now - 90 * 60 * 1000).toISOString(), }); store.insertCheckResult({ durationMs: null, failure: { actual: 500, expected: 200, kind: "error", message: "状态码不匹配", path: "$.status", phase: "status", }, matched: false, observation: null, targetId: targets[0]!.id, timestamp: new Date(now - 60 * 60 * 1000).toISOString(), }); store.insertCheckResult({ durationMs: null, failure: { actual: 500, expected: 200, kind: "error", message: "状态码不匹配", path: "$.status", phase: "status", }, matched: false, observation: null, targetId: targets[0]!.id, timestamp: new Date(now - 30 * 60 * 1000).toISOString(), }); 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/dashboard 返回总览和目标列表", async () => { const response = await fetch(`${baseUrl}/api/dashboard?window=24h&recentLimit=2`); const body = (await response.json()) as DashboardResponse; expect(response.status).toBe(200); expect(body.summary.total).toBe(2); expect(body.summary.up).toBe(0); expect(body.summary.down).toBe(2); expect(body.summary.incidents).toBe(1); expect(body.summary.lastCheckTime).not.toBeNull(); expect(body.summary.window.label).toBe("24h"); expect(body.targets).toHaveLength(2); const tA = body.targets.find((t) => t.name === "test-a")!; expect(tA.id).toBe("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).toHaveLength(2); expect(tA.stats).toMatchObject({ availability: 33.33, downChecks: 2, totalChecks: 3, upChecks: 1 }); expect(tA.currentStreak).toEqual({ capped: true, count: 2, up: false }); const tB = body.targets.find((t) => t.name === "test-b")!; expect(tB.type).toBe("cmd"); expect(tB.target).toBe("exec echo hello"); expect(tB.latestCheck).toBeNull(); expect(tB.stats).toMatchObject({ availability: 0, downChecks: 0, totalChecks: 0, upChecks: 0 }); expect(tB.currentStreak).toBeNull(); }); test("dashboard 无效参数返回 400", async () => { const invalidWindow = await fetch(`${baseUrl}/api/dashboard?window=7d`); const invalidLimit = await fetch(`${baseUrl}/api/dashboard?recentLimit=0`); expect(invalidWindow.status).toBe(400); expect(invalidLimit.status).toBe(400); }); 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("cmd"); }); test("不支持的 method 在有 API 通配符时返回 404", async () => { const response = await fetch(`${baseUrl}/api/dashboard`, { 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 = "2025-01-02T00:00:00.000Z"; 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(5); expect(body.total).toBe(5); expect(body.page).toBe(1); expect(body.pageSize).toBe(20); const failedItem = body.items.find((item) => item.failure); expect(failedItem?.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 = "2025-01-02T00:00:00.000Z"; 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(5); }); 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; expect(response.status).toBe(400); expect(body["error"]).toBe("pageSize must not exceed 200"); }); test("/api/targets/:id/metrics 返回单目标统计和趋势", async () => { const targets = store.getTargets(); const from = "2025-01-01T00:00:00.000Z"; const to = "2025-01-01T01:59:59.999Z"; const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/metrics?from=${from}&to=${to}&bucket=1h`); const body = (await response.json()) as TargetMetricsResponse; expect(response.status).toBe(200); expect(body.targetId).toBe(targets[0]!.id); expect(body.window.bucket).toBe("1h"); expect(body.stats).toMatchObject({ availability: 60, avgDurationMs: 233.33, downChecks: 2, incidentCount: 1, longestOutage: 30 * 60 * 1000, mttr: 30 * 60 * 1000, p95DurationMs: 400, p99DurationMs: 400, totalChecks: 5, upChecks: 3, }); expect(body.stats.currentStreak).toEqual({ count: 2, up: true }); expect(body.trend[0]).toMatchObject({ availability: 50, avgDurationMs: 150, bucketStart: "2025-01-01T00:00:00.000Z", downChecks: 2, maxDurationMs: 200, minDurationMs: 100, totalChecks: 4, upChecks: 2, }); }); test("/api/targets/:id/metrics 无数据返回空指标", async () => { const targets = store.getTargets(); const target = targets.find((item) => item.name === "test-b")!; const response = await fetch( `${baseUrl}/api/targets/${target.id}/metrics?from=2025-01-01T00:00:00.000Z&to=2025-01-01T01:00:00.000Z`, ); const body = (await response.json()) as TargetMetricsResponse; expect(response.status).toBe(200); expect(body.stats).toEqual({ availability: 0, avgDurationMs: null, currentStreak: null, downChecks: 0, incidentCount: 0, longestOutage: null, mttr: null, p95DurationMs: null, p99DurationMs: null, totalChecks: 0, upChecks: 0, }); expect(body.trend).toEqual([]); }); 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; 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; expect(response.status).toBe(400); expect(body["error"]).toContain("from and to"); }); test("metrics 缺少 from/to 参数返回 400", async () => { const targets = store.getTargets(); const response = await fetch(`${baseUrl}/api/targets/${targets[0]!.id}/metrics`); const body = (await response.json()) as Record; expect(response.status).toBe(400); expect(body["error"]).toContain("from and to"); }); test("metrics 无效 target id 返回 400", async () => { const response = await fetch( `${baseUrl}/api/targets/_invalid/metrics?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("metrics 无效 bucket 和不存在目标返回错误", async () => { const targets = store.getTargets(); const invalidBucket = await fetch( `${baseUrl}/api/targets/${targets[0]!.id}/metrics?from=2025-01-01T00:00:00.000Z&to=2025-01-01T01:00:00.000Z&bucket=5m`, ); const missingTarget = await fetch( `${baseUrl}/api/targets/99999/metrics?from=2025-01-01T00:00:00.000Z&to=2025-01-01T01:00:00.000Z`, ); expect(invalidBucket.status).toBe(400); expect(missingTarget.status).toBe(404); }); 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/dashboard`); 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 originalWarn = console.warn; console.warn = () => undefined; try { const targets = store.getTargets(); const t1Id = targets[0]!.id; store.insertCheckResult({ durationMs: 100, failure: { kind: "error", message: "test", path: "$", phase: "body" }, matched: false, observation: null, 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(); } finally { console.warn = originalWarn; } }); });