- 新增 body 分组校验:contains、regex、json(JSONPath)、css(CSS选择器)、xpath - 新增操作符系统:equals、contains、match、empty、exists、gte、lte、gt、lt - 新增 headers 响应头校验 - 引入 cheerio、xpath、@xmldom/xmldom 依赖 - BREAKING: expect.bodyContains 迁移至 expect.body.contains
201 lines
6.8 KiB
TypeScript
201 lines
6.8 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 { 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>Gateway Checker</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(), `gc-api-test-${Date.now()}`);
|
|
await mkdir(tempDir, { recursive: true });
|
|
store = new ProbeStore(join(tempDir, "test.db"));
|
|
store.syncTargets([
|
|
{
|
|
name: "test-a",
|
|
url: "http://a.com",
|
|
method: "GET",
|
|
headers: {},
|
|
intervalMs: 30000,
|
|
timeoutMs: 10000,
|
|
},
|
|
{
|
|
name: "test-b",
|
|
url: "http://b.com",
|
|
method: "POST",
|
|
headers: {},
|
|
intervalMs: 60000,
|
|
timeoutMs: 5000,
|
|
},
|
|
]);
|
|
|
|
const targets = store.getTargets();
|
|
store.insertCheckResult({
|
|
targetId: targets[0]!.id,
|
|
timestamp: "2025-01-01T00:00:00.000Z",
|
|
success: true,
|
|
statusCode: 200,
|
|
latencyMs: 150,
|
|
error: null,
|
|
matched: true,
|
|
});
|
|
store.insertCheckResult({
|
|
targetId: targets[0]!.id,
|
|
timestamp: "2025-01-01T00:00:30.000Z",
|
|
success: false,
|
|
statusCode: null,
|
|
latencyMs: null,
|
|
error: "timeout",
|
|
matched: false,
|
|
});
|
|
|
|
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("gateway-checker");
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
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);
|
|
expect(body[0]!.name).toBe("test-a");
|
|
expect(body[0]!.latestCheck).not.toBeNull();
|
|
expect(body[0]!.latestCheck!.success).toBe(false);
|
|
expect(body[0]!.sparkline).toBeDefined();
|
|
expect(Array.isArray(body[0]!.sparkline)).toBe(true);
|
|
expect(body[1]!.latestCheck).toBeNull();
|
|
});
|
|
|
|
test("/api/targets/:id/history 返回历史记录", 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(200);
|
|
expect(body).toHaveLength(2);
|
|
});
|
|
|
|
test("/api/targets/:id/history 支持 limit 参数", async () => {
|
|
const targets = store.getTargets();
|
|
const response = await fetchHandler(new Request(`http://localhost/api/targets/${targets[0]!.id}/history?limit=1`));
|
|
const body = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body).toHaveLength(1);
|
|
});
|
|
|
|
test("/api/targets/:id/trend 返回趋势数据", 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(200);
|
|
expect(Array.isArray(body)).toBe(true);
|
|
});
|
|
|
|
test("查询不存在的目标返回 404", async () => {
|
|
const response = await fetchHandler(new Request("http://localhost/api/targets/99999/history"));
|
|
const body = await response.json();
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(body.error).toBe("Target not found");
|
|
});
|
|
|
|
test("无效 limit 参数返回 400", async () => {
|
|
const targets = store.getTargets();
|
|
const response = await fetchHandler(
|
|
new Request(`http://localhost/api/targets/${targets[0]!.id}/history?limit=abc`),
|
|
);
|
|
const body = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(body.error).toBe("Invalid limit parameter");
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|