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 { ResolvedCommandTarget } from "../../../src/server/checker/runner/cmd/types"; import type { ResolvedHttpTarget } from "../../../src/server/checker/runner/http/types"; import type { CheckFailure } from "../../../src/server/checker/types"; 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 { rmRetry } from "../../helpers"; function ensureRegistered() { if (!checkerRegistry.supportedTypes.includes("http")) { checkerRegistry.register(new HttpChecker()); checkerRegistry.register(new CommandChecker()); } } beforeAll(() => { ensureRegistered(); }); function targetId(store: ProbeStore, name: string): string { return store.getTargets().find((target) => target.name === name)!.id; } const httpTarget: ResolvedHttpTarget = { expect: { maxDurationMs: 3000, status: [200] }, group: "default", http: { headers: { Accept: "application/json" }, ignoreSSL: false, maxBodyBytes: 104857600, maxRedirects: 0, method: "GET", url: "https://example.com/health", }, id: "test-http", intervalMs: 30000, name: "test-http", timeoutMs: 10000, type: "http", }; const commandTarget: ResolvedCommandTarget = { cmd: { args: ["-c", "1", "localhost"], cwd: "/tmp", env: {}, exec: "ping", maxOutputBytes: 104857600, }, group: "default", id: "test-cmd", intervalMs: 60000, name: "test-cmd", timeoutMs: 5000, type: "cmd", }; describe("ProbeStore", () => { let tempDir: string; let store: ProbeStore; beforeAll(async () => { tempDir = join(tmpdir(), `gc-store-test-${Date.now()}`); await mkdir(tempDir, { recursive: true }); store = new ProbeStore(join(tempDir, "test.db")); }); afterAll(async () => { store.close(); await rmRetry(tempDir); }); test("初始化后无 targets", () => { expect(store.getTargets()).toHaveLength(0); }); test("同步 http 和 cmd targets", () => { store.syncTargets([httpTarget, commandTarget]); const targets = store.getTargets(); expect(targets).toHaveLength(2); expect(targets.map((target) => target.name).sort()).toEqual(["test-cmd", "test-http"]); }); test("http target 字段正确", () => { const t = store.getTargets().find((t) => t.name === "test-http")!; expect(t.type).toBe("http"); expect(t.target).toBe("https://example.com/health"); const config = JSON.parse(t.config) as { headers: Record; ignoreSSL: boolean; maxBodyBytes: number; maxRedirects: number; method: string; url: string; }; expect(config.url).toBe("https://example.com/health"); expect(config.method).toBe("GET"); expect(config.headers).toEqual({ Accept: "application/json" }); expect(config.ignoreSSL).toBe(false); expect(config.maxBodyBytes).toBe(104857600); expect(config.maxRedirects).toBe(0); expect(t.interval_ms).toBe(30000); expect(t.timeout_ms).toBe(10000); expect(JSON.parse(t.expect!)).toEqual({ maxDurationMs: 3000, status: [200] }); }); test("cmd target 字段正确", () => { const t = store.getTargets().find((t) => t.name === "test-cmd")!; expect(t.type).toBe("cmd"); expect(t.target).toBe("exec ping -c 1 localhost"); const config = JSON.parse(t.config) as { args: string[]; cwd: string; exec: string; maxOutputBytes: number }; expect(config.exec).toBe("ping"); expect(config.args).toEqual(["-c", "1", "localhost"]); expect(config.cwd).toBe("/tmp"); expect(config.maxOutputBytes).toBe(104857600); expect(t.interval_ms).toBe(60000); expect(t.timeout_ms).toBe(5000); expect(t.expect).toBeNull(); }); test("同步更新已有 target", () => { const updated: ResolvedHttpTarget = { ...httpTarget, http: { ...httpTarget.http, url: "https://example.com/v2" }, }; store.syncTargets([updated, commandTarget]); const t = store.getTargets().find((t) => t.name === "test-http")!; expect(t.target).toBe("https://example.com/v2"); expect(store.getTargets()).toHaveLength(2); }); test("同步删除 target", () => { store.syncTargets([httpTarget]); const targets = store.getTargets(); expect(targets).toHaveLength(1); expect(targets[0]!.name).toBe("test-http"); }); test("重新同步回来", () => { store.syncTargets([httpTarget, commandTarget]); expect(store.getTargets()).toHaveLength(2); }); test("getTargetById", () => { const found = store.getTargetById(targetId(store, "test-http")); expect(found).toBeDefined(); expect(found!.name).toBe("test-http"); }); test("getTargetById 不存在", () => { expect(store.getTargetById("missing-target")).toBeNull(); }); test("写入 check result 并查询", () => { store.syncTargets([httpTarget, commandTarget]); const t1Id = targetId(store, "test-http"); store.insertCheckResult({ durationMs: 150.5, failure: null, matched: true, statusDetail: "200 OK", targetId: t1Id, timestamp: "2025-01-01T00:00:00.000Z", }); store.insertCheckResult({ durationMs: 300, failure: null, matched: true, statusDetail: "200 OK", targetId: t1Id, timestamp: "2025-01-01T00:00:30.000Z", }); const failure: CheckFailure = { actual: 5000, expected: 3000, kind: "error", message: "请求耗时 5000ms 超过限制 3000ms", path: "$.maxDurationMs", phase: "duration", }; store.insertCheckResult({ durationMs: null, failure, matched: false, statusDetail: null, targetId: t1Id, timestamp: "2025-01-01T00:01:00.000Z", }); const history = store.getHistory(t1Id, "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z", 1, 10); expect(history.items).toHaveLength(3); expect(history.items[0]!.timestamp).toBe("2025-01-01T00:01:00.000Z"); const latest = store.getLatestCheck(t1Id)!; expect(latest.matched).toBe(0); expect(latest.failure).not.toBeNull(); const parsedFailure = JSON.parse(latest.failure!) as CheckFailure; expect(parsedFailure.kind).toBe("error"); expect(parsedFailure.phase).toBe("duration"); expect(parsedFailure.message).toBe("请求耗时 5000ms 超过限制 3000ms"); }); test("getHistory 默认 limit=20", () => { const t1Id = targetId(store, "test-http"); for (let i = 0; i < 25; i++) { store.insertCheckResult({ durationMs: 100 + i, failure: null, matched: true, statusDetail: "200 OK", targetId: t1Id, timestamp: `2025-01-01T01:${String(i).padStart(2, "0")}:00.000Z`, }); } const history = store.getHistory(t1Id, "2025-01-01T00:00:00.000Z", "2025-12-31T23:59:59.999Z"); expect(history.items).toHaveLength(20); }); test("getTargetWindowStats 按时间窗口计算基础计数", () => { const t1Id = targetId(store, "test-http"); const stats = store.getTargetWindowStats(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z"); expect(stats.totalChecks).toBeGreaterThan(0); expect(stats.upChecks + stats.downChecks).toBe(stats.totalChecks); expect(stats.availability).toBeGreaterThanOrEqual(0); expect(stats.availability).toBeLessThanOrEqual(100); }); test("无记录目标的窗口 stats", () => { const t2Id = targetId(store, "test-cmd"); const stats = store.getTargetWindowStats(t2Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z"); expect(stats.totalChecks).toBe(0); expect(stats.upChecks).toBe(0); expect(stats.downChecks).toBe(0); expect(stats.availability).toBe(0); }); test("getLatestChecksMap 支持 Dashboard 组装当前状态", () => { const latestChecksMap = store.getLatestChecksMap(); const latest = latestChecksMap.get(targetId(store, "test-http")); expect(latest).toBeDefined(); expect(latest!.timestamp).toBe("2025-01-01T01:24:00.000Z"); }); test("getTargetCheckpoints 返回窗口内升序检查点", () => { const t1Id = targetId(store, "test-http"); const checkpoints = store.getTargetCheckpoints(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z"); expect(checkpoints).toEqual([ { duration_ms: 150.5, matched: 1, timestamp: "2025-01-01T00:00:00.000Z" }, { duration_ms: 300, matched: 1, timestamp: "2025-01-01T00:00:30.000Z" }, { duration_ms: null, matched: 0, timestamp: "2025-01-01T00:01:00.000Z" }, ]); }); test("getTargetDurations 返回成功检查耗时升序数组", () => { const t1Id = targetId(store, "test-http"); const durations = store.getTargetDurations(t1Id, "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z"); expect(durations).toEqual([150.5, 300]); }); test("getRecentSamples 返回最近采样数据", () => { const t1Id = targetId(store, "test-http"); const samples = store.getRecentSamples(t1Id, 10); expect(Array.isArray(samples)).toBe(true); expect(samples.length).toBeGreaterThan(0); for (const sample of samples) { expect(typeof sample.timestamp).toBe("string"); expect(typeof sample.matched).toBe("number"); } }); test("getAllRecentSamples 返回每个 target 的最近采样数据", () => { const sampleStore = new ProbeStore(join(tempDir, "all-samples.db")); const httpA: ResolvedHttpTarget = { ...httpTarget, id: "sample-http-a", name: "sample-http-a" }; const httpB: ResolvedHttpTarget = { ...httpTarget, http: { ...httpTarget.http, url: "https://example.com/other" }, id: "sample-http-b", name: "sample-http-b", }; const httpEmpty: ResolvedHttpTarget = { ...httpTarget, http: { ...httpTarget.http, url: "https://example.com/empty" }, id: "sample-http-empty", name: "sample-http-empty", }; sampleStore.syncTargets([httpA, httpB, httpEmpty]); const targets = sampleStore.getTargets(); const targetAId = targets.find((t) => t.name === "sample-http-a")!.id; const targetBId = targets.find((t) => t.name === "sample-http-b")!.id; const emptyTargetId = targets.find((t) => t.name === "sample-http-empty")!.id; for (const [index, timestamp] of [ "2025-01-01T00:00:00.000Z", "2025-01-01T00:01:00.000Z", "2025-01-01T00:02:00.000Z", ].entries()) { sampleStore.insertCheckResult({ durationMs: 100 + index, failure: null, matched: index !== 1, statusDetail: "200 OK", targetId: targetAId, timestamp, }); } sampleStore.insertCheckResult({ durationMs: 200, failure: null, matched: true, statusDetail: "200 OK", targetId: targetBId, timestamp: "2025-01-01T00:03:00.000Z", }); sampleStore.insertCheckResult({ durationMs: null, failure: { kind: "error", message: "fail", path: "request", phase: "request" }, matched: false, statusDetail: null, targetId: targetBId, timestamp: "2025-01-01T00:04:00.000Z", }); const samples = sampleStore.getAllRecentSamples(2); expect(samples.get(targetAId)).toEqual([ { duration_ms: 102, matched: 1, timestamp: "2025-01-01T00:02:00.000Z" }, { duration_ms: 101, matched: 0, timestamp: "2025-01-01T00:01:00.000Z" }, ]); expect(samples.get(targetBId)).toEqual([ { duration_ms: null, matched: 0, timestamp: "2025-01-01T00:04:00.000Z" }, { duration_ms: 200, matched: 1, timestamp: "2025-01-01T00:03:00.000Z" }, ]); expect(samples.has(emptyTargetId)).toBe(false); sampleStore.close(); }); test("关闭后操作不报错", () => { const closedStore = new ProbeStore(join(tempDir, "closed.db")); closedStore.close(); expect(closedStore.getTargets()).toHaveLength(0); expect(closedStore.getTargetById("closed-target")).toBeNull(); }); test("删除 target 级联删除 check_results", () => { const cascadeStore = new ProbeStore(join(tempDir, "cascade.db")); const cascadeTarget: ResolvedHttpTarget = { group: "default", http: { headers: {}, ignoreSSL: false, maxBodyBytes: 104857600, maxRedirects: 0, method: "GET", url: "http://cascade.test", }, id: "cascade-test", intervalMs: 30000, name: "cascade-test", timeoutMs: 10000, type: "http", }; cascadeStore.syncTargets([cascadeTarget]); const t = cascadeStore.getTargets()[0]!; cascadeStore.insertCheckResult({ durationMs: 100, failure: null, matched: true, statusDetail: "200 OK", targetId: t.id, timestamp: "2025-01-01T00:00:00.000Z", }); cascadeStore.insertCheckResult({ durationMs: null, failure: { kind: "error", message: "fail", path: "$", phase: "status" }, matched: false, statusDetail: null, targetId: t.id, timestamp: "2025-01-01T00:01:00.000Z", }); expect(cascadeStore.getLatestCheck(t.id)).not.toBeNull(); cascadeStore.syncTargets([]); expect(cascadeStore.getTargets()).toHaveLength(0); expect(cascadeStore.getLatestCheck(t.id)).toBeNull(); cascadeStore.close(); }); test("getLatestChecksMap 返回所有 target 的最新 check", () => { const targets = store.getTargets(); const map = store.getLatestChecksMap(); expect(map).toBeInstanceOf(Map); for (const target of targets) { const latest = map.get(target.id); if (latest) { expect(latest.target_id).toBe(target.id); } } }); test("getLatestChecksMap 对无记录的 target 不包含 key", () => { const freshStore = new ProbeStore(join(tempDir, "fresh-map.db")); freshStore.syncTargets([ { group: "default", http: { headers: {}, ignoreSSL: false, maxBodyBytes: 104857600, maxRedirects: 0, method: "GET", url: "http://no.records", }, id: "no-records", intervalMs: 30000, name: "no-records", timeoutMs: 10000, type: "http", }, ]); const map = freshStore.getLatestChecksMap(); expect(map.size).toBe(0); freshStore.close(); }); test("getAllTargetWindowStats 返回所有 target 的窗口聚合统计", () => { const t1Id = targetId(store, "test-http"); const t2Id = targetId(store, "test-cmd"); const stats = store.getAllTargetWindowStats("2024-01-01T00:00:00.000Z", "2026-12-31T23:59:59.999Z"); expect(stats).toBeInstanceOf(Map); const stats1 = stats.get(t1Id); expect(stats1).toBeDefined(); expect(stats1!.totalChecks).toBeGreaterThan(0); expect(stats1!.upChecks + stats1!.downChecks).toBe(stats1!.totalChecks); expect(stats1!.availability).toBeGreaterThanOrEqual(0); const stats2 = stats.get(t2Id); if (stats2) { expect(stats2.totalChecks).toBe(0); expect(stats2.availability).toBe(0); } }); test("getAllTargetWindowStats 对无记录的 target 不包含 key", () => { const freshStore = new ProbeStore(join(tempDir, "fresh-stats.db")); freshStore.syncTargets([ { group: "default", http: { headers: {}, ignoreSSL: false, maxBodyBytes: 104857600, maxRedirects: 0, method: "GET", url: "http://no.stats", }, id: "no-stats", intervalMs: 30000, name: "no-stats", timeoutMs: 10000, type: "http", }, ]); const stats = freshStore.getAllTargetWindowStats("2025-01-01T00:00:00.000Z", "2025-01-02T00:00:00.000Z"); expect(stats.size).toBe(0); freshStore.close(); }); test("getAllTargetWindowStats 与 getTargetWindowStats 的 availability 精度一致", () => { const statsStore = new ProbeStore(join(tempDir, "stats-precision.db")); const target: ResolvedHttpTarget = { ...httpTarget, id: "stats-precision", name: "stats-precision" }; statsStore.syncTargets([target]); const targetId = statsStore.getTargets()[0]!.id; for (const [index, matched] of [true, true, false].entries()) { statsStore.insertCheckResult({ durationMs: 100, failure: null, matched, statusDetail: matched ? "200 OK" : "500 ERROR", targetId, timestamp: `2025-01-01T00:0${index}:00.000Z`, }); } const targetStats = statsStore.getTargetWindowStats( targetId, "2025-01-01T00:00:00.000Z", "2025-01-01T00:02:00.000Z", ); const allStats = statsStore .getAllTargetWindowStats("2025-01-01T00:00:00.000Z", "2025-01-01T00:02:00.000Z") .get(targetId)!; expect(targetStats.availability).toBe(66.67); expect(targetStats.upChecks).toBe(2); expect(targetStats.downChecks).toBe(1); expect(allStats.availability).toBe(66.67); expect(allStats.availability).toBe(targetStats.availability); statsStore.close(); }); test("getDashboardIncidentStates 返回按 target 和 timestamp 升序排列的状态序列", () => { const incidentStore = new ProbeStore(join(tempDir, "dashboard-incidents.db")); const httpA: ResolvedHttpTarget = { ...httpTarget, id: "incident-http-a", name: "incident-http-a" }; const httpB: ResolvedHttpTarget = { ...httpTarget, http: { ...httpTarget.http, url: "https://example.com/incident-b" }, id: "incident-http-b", name: "incident-http-b", }; incidentStore.syncTargets([httpA, httpB]); const targets = incidentStore.getTargets(); const targetAId = targets.find((target) => target.name === "incident-http-a")!.id; const targetBId = targets.find((target) => target.name === "incident-http-b")!.id; incidentStore.insertCheckResult({ durationMs: 100, failure: null, matched: false, statusDetail: null, targetId: targetBId, timestamp: "2025-01-01T00:03:00.000Z", }); incidentStore.insertCheckResult({ durationMs: 100, failure: null, matched: true, statusDetail: "200 OK", targetId: targetAId, timestamp: "2025-01-01T00:02:00.000Z", }); incidentStore.insertCheckResult({ durationMs: 100, failure: null, matched: false, statusDetail: null, targetId: targetAId, timestamp: "2025-01-01T00:01:00.000Z", }); expect(incidentStore.getDashboardIncidentStates("2025-01-01T00:00:00.000Z", "2025-01-01T00:03:00.000Z")).toEqual([ { matched: 0, target_id: targetAId, timestamp: "2025-01-01T00:01:00.000Z" }, { matched: 1, target_id: targetAId, timestamp: "2025-01-01T00:02:00.000Z" }, { matched: 0, target_id: targetBId, timestamp: "2025-01-01T00:03:00.000Z" }, ]); incidentStore.close(); }); test("prune 删除过期数据", () => { const pruneStore = new ProbeStore(join(tempDir, "prune.db")); pruneStore.syncTargets([httpTarget]); const t = pruneStore.getTargets()[0]!; pruneStore.insertCheckResult({ durationMs: 100, failure: null, matched: true, statusDetail: "200 OK", targetId: t.id, timestamp: "2020-01-01T00:00:00.000Z", }); pruneStore.insertCheckResult({ durationMs: 100, failure: null, matched: true, statusDetail: "200 OK", targetId: t.id, timestamp: new Date().toISOString(), }); const deleted = pruneStore.prune(86400000); expect(deleted).toBe(1); const history = pruneStore.getHistory(t.id, "2000-01-01T00:00:00.000Z", "2099-12-31T23:59:59.999Z"); expect(history.total).toBe(1); pruneStore.close(); }); test("prune 无过期数据返回 0", () => { const pruneStore = new ProbeStore(join(tempDir, "prune-none.db")); pruneStore.syncTargets([httpTarget]); const t = pruneStore.getTargets()[0]!; pruneStore.insertCheckResult({ durationMs: 100, failure: null, matched: true, statusDetail: "200 OK", targetId: t.id, timestamp: new Date().toISOString(), }); const deleted = pruneStore.prune(86400000); expect(deleted).toBe(0); pruneStore.close(); }); test("prune 不影响保留期内数据", () => { const pruneStore = new ProbeStore(join(tempDir, "prune-keep.db")); pruneStore.syncTargets([httpTarget]); const t = pruneStore.getTargets()[0]!; const now = Date.now(); pruneStore.insertCheckResult({ durationMs: 100, failure: null, matched: true, statusDetail: "200 OK", targetId: t.id, timestamp: new Date(now - 3600000).toISOString(), }); pruneStore.insertCheckResult({ durationMs: 200, failure: null, matched: true, statusDetail: "200 OK", targetId: t.id, timestamp: new Date(now).toISOString(), }); const deleted = pruneStore.prune(7200000); expect(deleted).toBe(0); const history = pruneStore.getHistory(t.id, "2000-01-01T00:00:00.000Z", "2099-12-31T23:59:59.999Z"); expect(history.total).toBe(2); pruneStore.close(); }); });