feat: 初始提交

This commit is contained in:
2026-05-26 18:19:42 +08:00
commit 7ebf5ee5dc
107 changed files with 9317 additions and 0 deletions

13
tests/helpers.ts Normal file
View File

@@ -0,0 +1,13 @@
import { rm } from "node:fs/promises";
export async function rmRetry(dir: string, retries = 10, delayMs = 500) {
for (let i = 0; i < retries; i++) {
try {
await rm(dir, { force: true, recursive: true });
return;
} catch (e) {
if (i === retries - 1) throw e;
await new Promise((r) => setTimeout(r, delayMs));
}
}
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test";
import { validateVersion } from "../../scripts/bump-version-logic";
describe("build 版本注入", () => {
test("validateVersion 接受有效版本", () => {
expect(() => validateVersion("0.1.0")).not.toThrow();
expect(() => validateVersion("1.2.3")).not.toThrow();
});
test("validateVersion 拒绝无效版本", () => {
expect(() => validateVersion("invalid")).toThrow();
expect(() => validateVersion("1.0.0-beta.1")).toThrow();
});
test("生成的 server-entry 包含版本字面量", () => {
const version = "0.1.0";
const serverEntryTs = [
`import { bootstrap } from "../src/server/bootstrap";`,
`import { parseRuntimeArgs } from "../src/server/config";`,
`import { staticAssets } from "./static-assets";`,
"",
`const APP_VERSION = "${version}" as const;`,
"",
`async function main() {`,
` const { configPath } = parseRuntimeArgs();`,
` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`,
`}`,
"",
`void main().catch((error) => {`,
` console.error("启动失败:", error instanceof Error ? error.message : error);`,
` process.exit(1);`,
`});`,
"",
].join("\n");
expect(serverEntryTs).toContain(`const APP_VERSION = "${version}"`);
expect(serverEntryTs).toContain("version: APP_VERSION");
});
test("版本字面量不依赖外部 package.json", () => {
const serverEntryTs = [`const APP_VERSION = "0.1.0" as const;`].join("\n");
expect(serverEntryTs).not.toContain("package.json");
expect(serverEntryTs).not.toContain("Bun.file");
expect(serverEntryTs).toContain('"0.1.0"');
});
});

View File

@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test";
import { bumpVersion, formatVersion, parseVersion, validateVersion } from "../../scripts/bump-version-logic";
describe("版本解析与校验", () => {
test("parseVersion 解析有效版本", () => {
expect(parseVersion("0.1.0")).toEqual([0, 1, 0]);
expect(parseVersion("1.2.3")).toEqual([1, 2, 3]);
expect(parseVersion("10.20.30")).toEqual([10, 20, 30]);
});
test("parseVersion 拒绝无效版本", () => {
expect(() => parseVersion("invalid")).toThrow();
expect(() => parseVersion("1.2")).toThrow();
expect(() => parseVersion("1.2.3.4")).toThrow();
expect(() => parseVersion("1.2.a")).toThrow();
});
test("validateVersion 接受有效版本", () => {
expect(() => validateVersion("0.1.0")).not.toThrow();
expect(() => validateVersion("1.2.3")).not.toThrow();
expect(() => validateVersion("10.20.30")).not.toThrow();
});
test("validateVersion 拒绝无效版本", () => {
expect(() => validateVersion("")).toThrow();
expect(() => validateVersion("invalid")).toThrow();
expect(() => validateVersion("1.2")).toThrow();
expect(() => validateVersion("1.2.3.4")).toThrow();
expect(() => validateVersion("1.0.0-beta.1")).toThrow();
expect(() => validateVersion("v1.0.0")).toThrow();
});
test("formatVersion 格式化版本", () => {
expect(formatVersion(0, 1, 0)).toBe("0.1.0");
expect(formatVersion(1, 2, 3)).toBe("1.2.3");
expect(formatVersion(10, 20, 30)).toBe("10.20.30");
});
});
describe("版本升迁逻辑", () => {
test("bumpVersion patch 升迁", () => {
expect(bumpVersion("1.2.3", "patch")).toBe("1.2.4");
expect(bumpVersion("0.1.0", "patch")).toBe("0.1.1");
expect(bumpVersion("0.0.1", "patch")).toBe("0.0.2");
});
test("bumpVersion minor 升迁", () => {
expect(bumpVersion("1.2.3", "minor")).toBe("1.3.0");
expect(bumpVersion("0.1.0", "minor")).toBe("0.2.0");
expect(bumpVersion("0.0.1", "minor")).toBe("0.1.0");
});
test("bumpVersion major 升迁", () => {
expect(bumpVersion("1.2.3", "major")).toBe("2.0.0");
expect(bumpVersion("0.1.0", "major")).toBe("1.0.0");
expect(bumpVersion("0.0.1", "major")).toBe("1.0.0");
});
test("bumpVersion set 设置版本", () => {
expect(bumpVersion("1.2.3", "set", "2.0.0")).toBe("2.0.0");
expect(bumpVersion("0.1.0", "set", "0.2.0")).toBe("0.2.0");
});
test("bumpVersion set 拒绝无效版本", () => {
expect(() => bumpVersion("1.2.3", "set", "invalid")).toThrow();
expect(() => bumpVersion("1.2.3", "set", "1.0.0-beta.1")).toThrow();
});
test("bumpVersion set 缺少目标版本报错", () => {
expect(() => bumpVersion("1.2.3", "set")).toThrow("set command requires a target version");
});
});

View File

@@ -0,0 +1,208 @@
/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/require-await */
import { describe, expect, test } from "bun:test";
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ResolvedConfig } from "../../src/server/config/types";
import type { Logger } from "../../src/server/logger";
import type { StartServerOptions } from "../../src/server/server";
import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap";
import { createMemoryLogger } from "../../src/server/logger";
function makeTempConfig(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
const base = join(tmpdir(), `bootstrap-test-${Date.now()}`);
mkdirSync(base, { recursive: true });
return {
configDir: base,
dataDir: join(base, "data"),
host: "127.0.0.1",
logging: {
consoleLevel: "info",
fileLevel: "info",
filePath: join(base, "data", "logs", "test.log"),
rotationFrequency: "daily",
rotationMaxFiles: 14,
rotationSizeBytes: 52428800,
rotationSizeRaw: "50MB",
},
port: 0,
...overrides,
};
}
describe("bootstrap", () => {
test("使用默认依赖启动", async () => {
let started = false;
let signalRegistered = false;
let loggerPassedToServer: Logger | undefined;
const cfg = makeTempConfig();
const mockLoadConfig = (async () => cfg) as unknown as BootstrapDependencies["loadConfig"];
const mockOnSignal = (_signal: string, _handler: () => void) => {
signalRegistered = true;
};
const mockStartServer = (options: StartServerOptions) => {
loggerPassedToServer = options.logger;
started = true;
return {};
};
const deps: BootstrapDependencies = {
createLogger: async () => createMemoryLogger(),
loadConfig: mockLoadConfig,
onSignal: mockOnSignal,
startServer: mockStartServer,
};
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
expect(started).toBe(true);
expect(signalRegistered).toBe(true);
expect(loggerPassedToServer).toBeDefined();
});
test("传递 version 给 startServer", async () => {
let receivedVersion: string | undefined;
let loggerCreated = false;
const cfg = makeTempConfig();
const deps: BootstrapDependencies = {
createLogger: async (_logConfig, _mode, version) => {
loggerCreated = true;
expect(version).toBe("1.2.3");
return createMemoryLogger();
},
loadConfig: async () => cfg,
onSignal: () => {},
startServer: (options: StartServerOptions) => {
receivedVersion = options.version;
return {};
},
};
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production", version: "1.2.3" }, deps);
expect(receivedVersion).toBe("1.2.3");
expect(loggerCreated).toBe(true);
});
test("logger 初始化失败时使用 fallback 并退出", async () => {
let exitCode: number | undefined;
const cfg = makeTempConfig();
const deps: BootstrapDependencies = {
createLogger: async () => {
throw new Error("pino import failed");
},
exit: (code: number) => {
exitCode = code;
throw new Error("exit called");
},
loadConfig: async () => cfg,
startServer: () => {
throw new Error("should not reach");
},
};
try {
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
} catch {
// expected - exit threw
}
expect(exitCode).toBe(1);
});
test("启动失败时调用 logger.fatal 并 flush", async () => {
let fatalCalled = false;
let flushCalled = false;
let exitCode: number | undefined;
const mockLogger = createMemoryLogger();
const origFatal = mockLogger.fatal.bind(mockLogger);
const origFlush = mockLogger.flush.bind(mockLogger);
mockLogger.fatal = (objOrMsg, msg?) => {
fatalCalled = true;
origFatal(objOrMsg, msg);
};
mockLogger.flush = () => {
flushCalled = true;
origFlush();
};
const cfg = makeTempConfig();
const deps: BootstrapDependencies = {
createLogger: async () => mockLogger,
exit: (code: number) => {
exitCode = code;
throw new Error("exit called");
},
loadConfig: async () => cfg,
startServer: () => {
throw new Error("server start failed");
},
};
try {
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
} catch {
// expected
}
expect(fatalCalled).toBe(true);
expect(flushCalled).toBe(true);
expect(exitCode).toBe(1);
});
test("数据目录创建后记录日志", async () => {
const cfg = makeTempConfig();
let infoDataDir: string | undefined;
const mockLogger = createMemoryLogger();
const origInfo = mockLogger.info.bind(mockLogger);
mockLogger.info = (objOrMsg, msg?) => {
if (typeof objOrMsg === "object" && "dataDir" in objOrMsg) {
infoDataDir = objOrMsg["dataDir"] as string;
}
origInfo(objOrMsg, msg);
};
const deps: BootstrapDependencies = {
createLogger: async () => mockLogger,
loadConfig: async () => cfg,
startServer: () => ({}),
};
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "development" }, deps);
expect(infoDataDir).toBe(cfg.dataDir);
});
test("shutdown 时 flush logger", async () => {
let flushed = false;
let shutdownHandler: (() => void) | undefined;
const mockLogger = createMemoryLogger();
mockLogger.flush = () => {
flushed = true;
};
const cfg = makeTempConfig();
const deps: BootstrapDependencies = {
createLogger: async () => mockLogger,
loadConfig: async () => cfg,
onSignal: (_signal, handler) => {
shutdownHandler = handler;
},
startServer: () => ({}),
};
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
expect(shutdownHandler).toBeDefined();
shutdownHandler!();
expect(flushed).toBe(true);
});
});

292
tests/server/config.test.ts Normal file
View File

@@ -0,0 +1,292 @@
import { describe, expect, test } from "bun:test";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadServerConfig, parseRuntimeArgs, parseSize } from "../../src/server/config";
import { APP } from "../../src/shared/app";
describe("parseRuntimeArgs", () => {
test("无参数抛出需要配置文件路径错误", () => {
try {
parseRuntimeArgs([]);
expect.unreachable();
} catch (error) {
expect((error as Error).message).toContain("需要指定 YAML 配置文件路径");
}
});
test("有参数返回 configPath", () => {
const result = parseRuntimeArgs(["config.yaml"]);
expect(result).toEqual({ configPath: "config.yaml" });
});
test("--help 抛出错误", () => {
try {
parseRuntimeArgs(["--help"]);
expect.unreachable();
} catch (error) {
expect((error as Error).message).toContain("用法");
}
});
test("-h 抛出错误", () => {
try {
parseRuntimeArgs(["-h"]);
expect.unreachable();
} catch (error) {
expect((error as Error).message).toContain("用法");
}
});
});
describe("parseSize", () => {
test("解析数字字节值", () => {
expect(parseSize(1024)).toBe(1024);
});
test("解析字符串大小", () => {
expect(parseSize("1KB")).toBe(1024);
expect(parseSize("50MB")).toBe(52428800);
expect(parseSize("1GB")).toBe(1073741824);
expect(parseSize("1024B")).toBe(1024);
});
test("非法格式抛出错误", () => {
try {
parseSize("invalid");
expect.unreachable();
} catch (error) {
expect((error as Error).message).toContain("无效的 size 格式");
}
});
});
describe("loadServerConfig", () => {
test("YAML 配置文件不存在时报错", async () => {
try {
await loadServerConfig("/nonexistent/path/config.yaml");
expect.unreachable();
} catch (error) {
expect((error as Error).message).toContain("配置文件不存在");
}
});
test("最简配置解析成功", async () => {
const temp = tmpdir();
const yamlPath = join(temp, "minimal.yaml");
await writeFile(yamlPath, 'server:\n listen:\n host: "0.0.0.0"\n port: 9999\n');
try {
const result = await loadServerConfig(yamlPath);
expect(result.host).toBe("0.0.0.0");
expect(result.port).toBe(9999);
expect(result.configDir).toBe(temp);
expect(result.dataDir).toBe(join(temp, "data"));
expect(result.logging.filePath).toBe(join(temp, "data", "logs", `${APP.name}.log`));
expect(result.logging.consoleLevel).toBe("info");
expect(result.logging.fileLevel).toBe("info");
} finally {
await rm(yamlPath, { force: true });
}
});
test("旧布局 server.host/server.port 被拒绝", async () => {
const temp = tmpdir();
const yamlPath = join(temp, "test-old-layout.yaml");
const yamlContent = 'server:\n host: "0.0.0.0"\n port: 9999\n';
await writeFile(yamlPath, yamlContent);
try {
await loadServerConfig(yamlPath);
expect.unreachable();
} catch (error) {
expect((error as Error).message).toContain("未知字段");
} finally {
await rm(yamlPath, { force: true });
}
});
test("非法端口被拒绝", async () => {
const temp = tmpdir();
const yamlPath = join(temp, "test-bad-port.yaml");
await writeFile(yamlPath, "server:\n listen:\n port: 99999\n");
try {
await loadServerConfig(yamlPath);
expect.unreachable();
} catch (error) {
expect((error as Error).message).toBeTruthy();
} finally {
await rm(yamlPath, { force: true });
}
});
test("显式变量引用环境变量生效", async () => {
const prevHost = process.env["HOST"];
const prevPort = process.env["PORT"];
process.env["HOST"] = "10.0.0.1";
process.env["PORT"] = "4000";
const temp = tmpdir();
const yamlPath = join(temp, "test-env-var.yaml");
await writeFile(yamlPath, 'server:\n listen:\n host: "${HOST}"\n port: ${PORT}\n');
try {
const result = await loadServerConfig(yamlPath);
expect(result.host).toBe("10.0.0.1");
expect(result.port).toBe(4000);
} finally {
await rm(yamlPath, { force: true });
if (prevHost === undefined) delete process.env["HOST"];
else process.env["HOST"] = prevHost;
if (prevPort === undefined) delete process.env["PORT"];
else process.env["PORT"] = prevPort;
}
});
test("变量带默认值生效", async () => {
delete process.env["MY_HOST"];
const temp = tmpdir();
const yamlPath = join(temp, "test-default.yaml");
await writeFile(yamlPath, 'server:\n listen:\n host: "${MY_HOST|0.0.0.0}"\n port: ${MY_PORT|5000}\n');
try {
const result = await loadServerConfig(yamlPath);
expect(result.host).toBe("0.0.0.0");
expect(result.port).toBe(5000);
} finally {
await rm(yamlPath, { force: true });
}
});
test("绝对 dataDir 保持不变", async () => {
const temp = tmpdir();
const dataDir = join(temp, "absolute-data");
await mkdir(dataDir, { recursive: true });
const yamlPath = join(temp, "absolute-dir.yaml");
await writeFile(yamlPath, `server:\n storage:\n dataDir: ${JSON.stringify(dataDir)}\n`);
try {
const result = await loadServerConfig(yamlPath);
expect(result.dataDir).toBe(dataDir);
} finally {
await rm(yamlPath, { force: true });
}
});
test("相对 dataDir 基于 configDir", async () => {
const temp = tmpdir();
const yamlPath = join(temp, "rel-dir.yaml");
await writeFile(yamlPath, 'server:\n storage:\n dataDir: "./my-data"\n');
try {
const result = await loadServerConfig(yamlPath);
expect(result.dataDir).toBe(join(temp, "my-data"));
} finally {
await rm(yamlPath, { force: true });
}
});
test("显式相对日志路径基于 configDir", async () => {
const temp = tmpdir();
const yamlPath = join(temp, "log-path.yaml");
await writeFile(yamlPath, 'server:\n logging:\n file:\n path: "./logs/app.log"\n');
try {
const result = await loadServerConfig(yamlPath);
expect(result.logging.filePath).toBe(join(temp, "logs", "app.log"));
} finally {
await rm(yamlPath, { force: true });
}
});
test("绝对日志路径保持不变", async () => {
const temp = tmpdir();
const logPath = join(temp, "my-app.log");
const yamlPath = join(temp, "abs-log.yaml");
await writeFile(yamlPath, `server:\n logging:\n file:\n path: ${JSON.stringify(logPath)}\n`);
try {
const result = await loadServerConfig(yamlPath);
expect(result.logging.filePath).toBe(logPath);
} finally {
await rm(yamlPath, { force: true });
}
});
test("非法 logging.level 抛出错误", async () => {
const temp = tmpdir();
const yamlPath = join(temp, "bad-level.yaml");
await writeFile(yamlPath, 'server:\n logging:\n level: "invalid"\n');
try {
await loadServerConfig(yamlPath);
expect.unreachable();
} catch (error) {
expect((error as Error).message).toContain("日志等级");
} finally {
await rm(yamlPath, { force: true });
}
});
test("空白 logging.file.path 抛出错误", async () => {
const temp = tmpdir();
const yamlPath = join(temp, "blank-path.yaml");
await writeFile(yamlPath, 'server:\n logging:\n file:\n path: " "\n');
try {
await loadServerConfig(yamlPath);
expect.unreachable();
} catch (error) {
expect((error as Error).message).toContain("日志路径不能为空字符串或空白字符串");
} finally {
await rm(yamlPath, { force: true });
}
});
test("非法 rotation.size 抛出错误", async () => {
const temp = tmpdir();
const yamlPath = join(temp, "bad-size.yaml");
await writeFile(yamlPath, 'server:\n logging:\n file:\n rotation:\n size: "99XX"\n');
try {
await loadServerConfig(yamlPath);
expect.unreachable();
} catch (error) {
expect((error as Error).message).toContain("无效的 size 格式");
} finally {
await rm(yamlPath, { force: true });
}
});
test("非法 rotation.frequency 抛出错误", async () => {
const temp = tmpdir();
const yamlPath = join(temp, "bad-freq.yaml");
await writeFile(yamlPath, 'server:\n logging:\n file:\n rotation:\n frequency: "yearly"\n');
try {
await loadServerConfig(yamlPath);
expect.unreachable();
} catch (error) {
expect((error as Error).message).toContain("rotation.frequency");
} finally {
await rm(yamlPath, { force: true });
}
});
test("非法 rotation.maxFiles 抛出错误", async () => {
const temp = tmpdir();
const yamlPath = join(temp, "bad-max.yaml");
await writeFile(yamlPath, "server:\n logging:\n file:\n rotation:\n maxFiles: 0\n");
try {
await loadServerConfig(yamlPath);
expect.unreachable();
} catch (error) {
expect((error as Error).message).toContain("maxFiles");
} finally {
await rm(yamlPath, { force: true });
}
});
});

View File

@@ -0,0 +1,171 @@
import { describe, expect, test } from "bun:test";
import { createAuthoringConfigSchema, createNormalizedConfigSchema } from "../../../src/server/config/schema/builder";
import { createConfigJsonSchema } from "../../../src/server/config/schema/export";
import {
createConfigAjv,
issuesFromAjvErrors,
validateConfigContract,
} from "../../../src/server/config/schema/validate";
describe("导出 schema 生成", () => {
test("createConfigJsonSchema 返回有效 JSON Schema", () => {
const schema = createConfigJsonSchema();
expect(schema["$schema"]).toBe("http://json-schema.org/draft-07/schema#");
expect(schema["$id"]).toBe("https://app.local/config.schema.json");
expect(schema["type"]).toBe("object");
});
});
describe("Authoring schema 校验", () => {
const ajv = createConfigAjv();
const validate = ajv.compile(createAuthoringConfigSchema());
test("接受空对象", () => {
expect(validate({})).toBe(true);
});
test("接受新布局 server.listen", () => {
expect(validate({ server: { listen: { host: "127.0.0.1", port: 3000 } } })).toBe(true);
});
test("接受变量引用语法", () => {
expect(validate({ server: { listen: { port: "${PORT|3000}" } } })).toBe(true);
});
test("接受 variables 字段", () => {
expect(validate({ variables: { HOST: "127.0.0.1" } })).toBe(true);
});
test("接受 server.storage.dataDir", () => {
expect(validate({ server: { storage: { dataDir: "./data" } } })).toBe(true);
});
test("接受 server.logging 合法配置", () => {
expect(
validate({
server: {
logging: {
console: { level: "debug" },
file: {
level: "warn",
path: "/var/log/app.log",
rotation: { frequency: "daily", maxFiles: 14, size: "50MB" },
},
level: "info",
},
},
}),
).toBe(true);
});
test("接受 server.logging.level 变量引用", () => {
expect(validate({ server: { logging: { level: "${LOG_LEVEL|info}" } } })).toBe(true);
});
test("拒绝 server.logging 中未知字段", () => {
expect(validate({ server: { logging: { unknownField: true } } })).toBe(false);
});
test("拒绝 server.logging.level 非法枚举值", () => {
expect(validate({ server: { logging: { level: "verbose" } } })).toBe(false);
});
test("拒绝 unknown 字段 server.host", () => {
expect(validate({ server: { host: "127.0.0.1" } })).toBe(false);
const issues = issuesFromAjvErrors(validate.errors ?? [], {});
expect(issues.some((i) => i.code === "unknown-field")).toBe(true);
});
test("拒绝未知字段 server.port", () => {
expect(validate({ server: { port: 3000 } })).toBe(false);
const issues = issuesFromAjvErrors(validate.errors ?? [], {});
expect(issues.some((i) => i.code === "unknown-field")).toBe(true);
});
test("拒绝非法类型 port", () => {
expect(validate({ server: { listen: { port: "not-a-number" } } })).toBe(false);
});
test("拒绝超出范围的 port", () => {
expect(validate({ server: { listen: { port: 70000 } } })).toBe(false);
});
test("拒绝负数 port", () => {
expect(validate({ server: { listen: { port: -1 } } })).toBe(false);
});
test("拒绝顶层未知字段", () => {
expect(validate({ unknown: true })).toBe(false);
});
});
describe("Normalized schema 校验", () => {
const ajv = createConfigAjv();
const validate = ajv.compile(createNormalizedConfigSchema());
test("接受新布局 server.listen", () => {
expect(validate({ server: { listen: { host: "127.0.0.1", port: 3000 } } })).toBe(true);
});
test("Normalized 不接受 variables 字段", () => {
expect(validate({ variables: { HOST: "127.0.0.1" } })).toBe(false);
});
test("Normalized 不接受变量引用语法", () => {
expect(validate({ server: { listen: { port: "${PORT|3000}" } } })).toBe(false);
});
test("接受 server.storage.dataDir", () => {
expect(validate({ server: { storage: { dataDir: "./data" } } })).toBe(true);
});
test("接受 server.logging 合法配置", () => {
expect(
validate({
server: {
logging: {
console: { level: "debug" },
file: {
level: "warn",
path: "/var/log/app.log",
rotation: { frequency: "daily", maxFiles: 14, size: "50MB" },
},
level: "info",
},
},
}),
).toBe(true);
});
test("接受空对象", () => {
expect(validate({})).toBe(true);
});
});
describe("validateConfigContract", () => {
test("有效配置通过校验", () => {
const result = validateConfigContract({ server: { listen: { host: "0.0.0.0", port: 8080 } } });
expect(result.config).not.toBeNull();
});
test("空配置通过校验", () => {
const result = validateConfigContract({});
expect(result.config).not.toBeNull();
});
test("包含未知字段的配置被拒绝", () => {
const result = validateConfigContract({ server: { host: "bad" } });
expect(result.config).toBeNull();
expect(result.issues.length).toBeGreaterThan(0);
});
});
describe("schema 同步测试", () => {
test("config.schema.json 与 createConfigJsonSchema() 输出一致", async () => {
const file = Bun.file("config.schema.json");
const existing = await file.text();
const generated = `${JSON.stringify(createConfigJsonSchema(), null, 2)}\n`;
expect(existing).toBe(generated);
});
});

View File

@@ -0,0 +1,171 @@
import { describe, expect, test } from "bun:test";
import { extractVariables, resolveVariables } from "../../../src/server/config/variables";
describe("extractVariables", () => {
test("空对象返回空 variables", () => {
const result = extractVariables({});
expect(result.variables.size).toBe(0);
expect(result.issues.length).toBe(0);
});
test("无 variables 字段返回空", () => {
const result = extractVariables({ server: {} });
expect(result.variables.size).toBe(0);
});
test("variables 非对象报错", () => {
const result = extractVariables({ variables: "bad" });
expect(result.issues.length).toBe(1);
expect(result.issues[0]!.code).toBe("invalid-type");
});
test("提取有效变量", () => {
const result = extractVariables({ variables: { HOST: "127.0.0.1", PORT: 3000 } });
expect(result.variables.get("HOST")).toBe("127.0.0.1");
expect(result.variables.get("PORT")).toBe(3000);
});
test("无效变量名报错", () => {
const result = extractVariables({ variables: { "123bad": "val" } });
expect(result.issues.length).toBe(1);
expect(result.issues[0]!.code).toBe("invalid-format");
});
test("null 值报错", () => {
const result = extractVariables({ variables: { KEY: null } });
expect(result.issues.length).toBe(1);
expect(result.issues[0]!.code).toBe("invalid-type");
});
test("数组值报错", () => {
const result = extractVariables({ variables: { KEY: [1, 2] } });
expect(result.issues.length).toBe(1);
});
});
describe("resolveVariables", () => {
test("${KEY} 从 variables 解析", () => {
const result = resolveVariables({
server: { listen: { host: "${MY_HOST}" } },
variables: { MY_HOST: "0.0.0.0" },
});
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
const listen = server["listen"] as Record<string, unknown>;
expect(listen["host"]).toBe("0.0.0.0");
});
test("${KEY|default} 使用默认值", () => {
const result = resolveVariables({
server: { listen: { host: "${MY_HOST|0.0.0.0}" } },
});
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
const listen = server["listen"] as Record<string, unknown>;
expect(listen["host"]).toBe("0.0.0.0");
});
test("${KEY|} 空默认值", () => {
const result = resolveVariables({
server: { listen: { host: "${MY_HOST|}" } },
});
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
const listen = server["listen"] as Record<string, unknown>;
expect(listen["host"]).toBe("");
});
test("$${KEY} 转义不解析", () => {
const result = resolveVariables({
server: { listen: { host: "$${NOT_A_VAR}" } },
});
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
const listen = server["listen"] as Record<string, unknown>;
expect(listen["host"]).toBe("${NOT_A_VAR}");
});
test("variables 优先于 process.env", () => {
const prev = process.env["TEST_PRIORITY"];
process.env["TEST_PRIORITY"] = "from-env";
try {
const result = resolveVariables({
server: { listen: { host: "${TEST_PRIORITY}" } },
variables: { TEST_PRIORITY: "from-var" },
});
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
const listen = server["listen"] as Record<string, unknown>;
expect(listen["host"]).toBe("from-var");
} finally {
if (prev === undefined) delete process.env["TEST_PRIORITY"];
else process.env["TEST_PRIORITY"] = prev;
}
});
test("process.env fallback", () => {
const prev = process.env["TEST_FALLBACK"];
process.env["TEST_FALLBACK"] = "from-env";
try {
const result = resolveVariables({
server: { listen: { host: "${TEST_FALLBACK}" } },
});
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
const listen = server["listen"] as Record<string, unknown>;
expect(listen["host"]).toBe("from-env");
} finally {
if (prev === undefined) delete process.env["TEST_FALLBACK"];
else process.env["TEST_FALLBACK"] = prev;
}
});
test("完整引用保留类型 - number", () => {
const result = resolveVariables({
server: { listen: { port: "${PORT|3000}" } },
});
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
const listen = server["listen"] as Record<string, unknown>;
expect(listen["port"]).toBe(3000);
expect(typeof listen["port"]).toBe("number");
});
test("完整引用保留类型 - boolean", () => {
const result = resolveVariables({
server: { listen: { host: "${FLAG|false}" } },
});
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
const listen = server["listen"] as Record<string, unknown>;
expect(listen["host"]).toBe(false);
});
test("部分插值转为 string", () => {
const prev = process.env["PARTIAL_HOST"];
process.env["PARTIAL_HOST"] = "192.168";
try {
const result = resolveVariables({
server: { listen: { host: "prefix-${PARTIAL_HOST}-suffix" } },
});
const server = (result.config as Record<string, unknown>)["server"] as Record<string, unknown>;
const listen = server["listen"] as Record<string, unknown>;
expect(listen["host"]).toBe("prefix-192.168-suffix");
expect(typeof listen["host"]).toBe("string");
} finally {
if (prev === undefined) delete process.env["PARTIAL_HOST"];
else process.env["PARTIAL_HOST"] = prev;
}
});
test("unresolved-variable 报错", () => {
const result = resolveVariables({
server: { listen: { host: "${UNDEFINED_VAR}" } },
});
expect(result.issues.length).toBe(1);
expect(result.issues[0]!.code).toBe("unresolved-variable");
expect(result.issues[0]!.message).toContain("UNDEFINED_VAR");
});
test("variables 段被移除", () => {
const result = resolveVariables({
server: { listen: { host: "test" } },
variables: { KEY: "val" },
});
const config = result.config as Record<string, unknown>;
expect(config["variables"]).toBeUndefined();
});
});

117
tests/server/logger.test.ts Normal file
View File

@@ -0,0 +1,117 @@
import { describe, expect, test } from "bun:test";
import type { Logger } from "../../src/server/logger";
import { createConsoleFallback, createMemoryLogger, createNoopLogger, REDACT_PATHS } from "../../src/server/logger";
describe("NoopLogger", () => {
test("所有方法不抛异常", () => {
const logger = createNoopLogger();
logger.trace("trace");
logger.debug("debug");
logger.info("info");
logger.warn("warn");
logger.error("error");
logger.fatal("fatal");
logger.flush();
const child = logger.child({ component: "test" });
expect(child).toBeDefined();
});
});
describe("MemoryLogger", () => {
test("记录所有等级日志", () => {
const logger = createMemoryLogger();
logger.trace("trace-msg");
logger.debug("debug-msg");
logger.info("info-msg");
logger.warn("warn-msg");
logger.error("error-msg");
logger.fatal("fatal-msg");
expect(logger.entries).toHaveLength(6);
expect(logger.entries[0]).toEqual({ level: "trace", msg: "trace-msg" });
expect(logger.entries[5]).toEqual({ level: "fatal", msg: "fatal-msg" });
});
test("记录结构化日志", () => {
const logger = createMemoryLogger();
logger.info({ matched: true, targetId: "abc" }, "check complete");
expect(logger.entries).toHaveLength(1);
expect(logger.entries[0]!.level).toBe("info");
expect(logger.entries[0]!.msg).toBe("check complete");
expect(logger.entries[0]!.obj).toEqual({ matched: true, targetId: "abc" });
});
test("child 返回自身", () => {
const logger = createMemoryLogger();
const child = logger.child({ component: "test" });
child.info("child-msg");
expect(logger.entries).toHaveLength(1);
});
test("flush 不抛异常", () => {
const logger = createMemoryLogger();
logger.flush();
});
});
describe("ConsoleFallbackLogger", () => {
test("不抛异常", () => {
const logger = createConsoleFallback();
logger.trace("trace");
logger.debug("debug");
logger.info("info");
logger.warn("warn");
logger.error("error");
logger.fatal("fatal");
logger.flush();
const child = logger.child({ component: "test" });
expect(child).toBeDefined();
});
});
describe("Logger 接口契约", () => {
function assertLogger(logger: Logger): void {
logger.trace("trace");
logger.debug("debug");
logger.info("info");
logger.warn("warn");
logger.error("error");
logger.fatal("fatal");
logger.info({ key: "value" }, "structured");
logger.child({ component: "test" }).info("child");
logger.flush();
}
test("NoopLogger 满足 Logger 接口", () => {
expect(() => assertLogger(createNoopLogger())).not.toThrow();
});
test("MemoryLogger 满足 Logger 接口", () => {
expect(() => assertLogger(createMemoryLogger())).not.toThrow();
});
test("ConsoleFallbackLogger 满足 Logger 接口", () => {
expect(() => assertLogger(createConsoleFallback())).not.toThrow();
});
});
describe("redaction 敏感信息保护", () => {
test("MemoryLogger 不做 redaction测试用途仅 Pino 运行时 redact", () => {
const logger = createMemoryLogger();
logger.info({ authorization: "Bearer secret", password: "hunter2" }, "test");
const entry = logger.entries[0]!;
expect(entry.obj!["authorization"]).toBe("Bearer secret");
expect(entry.obj!["password"]).toBe("hunter2");
});
test("REDACT_PATHS 覆盖所有敏感字段键名", () => {
const sensitiveKeys = ["authorization", "cookie", "set-cookie", "authToken", "key", "password", "token", "apiKey"];
for (const key of sensitiveKeys) {
expect(REDACT_PATHS).toContain(key);
expect(REDACT_PATHS).toContain(`*.${key}`);
}
});
});

View File

@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test";
import { validateIdParam, validatePagination, validateTimeRange } from "../../src/server/middleware";
describe("validateIdParam", () => {
test("有效的 ID 返回字符串", () => {
const result = validateIdParam("api-health_01", "production");
expect(result).not.toHaveProperty("status");
expect((result as { id: string }).id).toBe("api-health_01");
});
test("无效的 ID 返回 400", () => {
const invalid = ["-1", "_abc", "has space", "1.5", ""];
for (const id of invalid) {
const result = validateIdParam(id, "production");
expect(result).toHaveProperty("status", 400);
}
});
});
describe("validateTimeRange", () => {
test("有效的 from/to 返回 ISO 字符串", () => {
const result = validateTimeRange("2024-01-01T00:00:00.000Z", "2024-01-02T00:00:00.000Z", "production");
expect(result).not.toHaveProperty("status");
expect((result as { from: string; to: string }).from).toBe("2024-01-01T00:00:00.000Z");
expect((result as { from: string; to: string }).to).toBe("2024-01-02T00:00:00.000Z");
});
test("缺失 from 或 to 返回 400", () => {
const missingFrom = validateTimeRange(null, "2024-01-02T00:00:00.000Z", "production");
const missingTo = validateTimeRange("2024-01-01T00:00:00.000Z", null, "production");
const missingBoth = validateTimeRange(null, null, "production");
expect(missingFrom).toHaveProperty("status", 400);
expect(missingTo).toHaveProperty("status", 400);
expect(missingBoth).toHaveProperty("status", 400);
});
test("空字符串 from 或 to 返回 400", () => {
const emptyFrom = validateTimeRange("", "2024-01-02T00:00:00.000Z", "production");
const emptyTo = validateTimeRange("2024-01-01T00:00:00.000Z", "", "production");
expect(emptyFrom).toHaveProperty("status", 400);
expect(emptyTo).toHaveProperty("status", 400);
});
test("无效的日期格式返回 400", () => {
const result = validateTimeRange("invalid-date", "2024-01-02T00:00:00.000Z", "production");
expect(result).toHaveProperty("status", 400);
});
test("from 晚于 to 返回 400", () => {
const result = validateTimeRange("2024-01-02T00:00:00.000Z", "2024-01-01T00:00:00.000Z", "production");
expect(result).toHaveProperty("status", 400);
});
});
describe("validatePagination", () => {
test("默认值page=1, pageSize=20", () => {
const result = validatePagination(null, null, "production");
expect(result).toEqual({ page: 1, pageSize: 20 });
});
test("有效的 page 和 pageSize 参数", () => {
const result = validatePagination("2", "50", "production");
expect(result).toEqual({ page: 2, pageSize: 50 });
});
test("无效的 page 参数返回 400", () => {
const invalidPage = ["0", "-1", "abc", "1.5"];
for (const page of invalidPage) {
const result = validatePagination(page, "20", "production");
expect(result).toHaveProperty("status", 400);
}
});
test("无效的 pageSize 参数返回 400", () => {
const invalidPageSize = ["0", "-1", "abc", "1.5"];
for (const pageSize of invalidPageSize) {
const result = validatePagination("1", pageSize, "production");
expect(result).toHaveProperty("status", 400);
}
});
test("pageSize 超过上限返回 400", () => {
const result = validatePagination("1", "201", "production");
expect(result).toHaveProperty("status", 400);
});
test("pageSize 等于上限 200 返回成功", () => {
const result = validatePagination("1", "200", "production");
expect(result).toEqual({ page: 1, pageSize: 200 });
});
});

127
tests/server/static.test.ts Normal file
View File

@@ -0,0 +1,127 @@
import { describe, expect, test } from "bun:test";
import {
contentTypeFor,
hasFileExtension,
htmlResponse,
serveStaticAsset,
type StaticAssets,
} from "../../src/server/static";
function createTestAssets(): StaticAssets {
return {
files: {
"/assets/index-a1b2c3.css": new Blob([".app{}"], { type: "text/css" }),
"/assets/index-a1b2c3.js": new Blob(["console.log(1)"], { type: "text/javascript" }),
"/assets/vendor-react-x9y8z7.js": new Blob(["react"], { type: "text/javascript" }),
"/favicon.svg": new Blob(["<svg/>"], { type: "image/svg+xml" }),
},
indexHtml: new Blob(["<!doctype html><html></html>"], { type: "text/html" }),
};
}
describe("contentTypeFor", () => {
test("JavaScript 文件", () => {
expect(contentTypeFor("/assets/index-a1b2c3.js")).toBe("text/javascript; charset=utf-8");
});
test("mjs 文件", () => {
expect(contentTypeFor("/assets/chunk.mjs")).toBe("text/javascript; charset=utf-8");
});
test("CSS 文件", () => {
expect(contentTypeFor("/assets/style.css")).toBe("text/css; charset=utf-8");
});
test("SVG 文件", () => {
expect(contentTypeFor("/icon.svg")).toBe("image/svg+xml");
});
test("未知扩展名返回 octet-stream", () => {
expect(contentTypeFor("/file.xyz")).toBe("application/octet-stream");
});
test("无扩展名返回 octet-stream", () => {
expect(contentTypeFor("/noext")).toBe("application/octet-stream");
});
});
describe("hasFileExtension", () => {
test("有扩展名", () => {
expect(hasFileExtension("/assets/index.js")).toBe(true);
expect(hasFileExtension("/favicon.svg")).toBe(true);
});
test("无扩展名", () => {
expect(hasFileExtension("/dashboard")).toBe(false);
expect(hasFileExtension("/")).toBe(false);
expect(hasFileExtension("/api/targets")).toBe(false);
});
});
describe("htmlResponse", () => {
test("返回 HTML 响应带正确 headers", async () => {
const blob = new Blob(["<html></html>"]);
const response = htmlResponse(blob);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
expect(response.headers.get("Cache-Control")).toBe("no-cache");
expect(await response.text()).toBe("<html></html>");
});
});
describe("serveStaticAsset", () => {
test("根路径返回 indexHtml", async () => {
const assets = createTestAssets();
const response = serveStaticAsset("/", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
expect(response.headers.get("Cache-Control")).toBe("no-cache");
expect(await response.text()).toBe("<!doctype html><html></html>");
});
test("已知资源返回对应文件和 immutable 缓存", async () => {
const assets = createTestAssets();
const response = serveStaticAsset("/assets/index-a1b2c3.js", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/javascript; charset=utf-8");
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
expect(await response.text()).toBe("console.log(1)");
});
test("未知带扩展名路径返回 404", () => {
const assets = createTestAssets();
const response = serveStaticAsset("/assets/missing.js", assets);
expect(response.status).toBe(404);
});
test("SPA fallback — 无扩展名路径返回 indexHtml", async () => {
const assets = createTestAssets();
const response = serveStaticAsset("/dashboard", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
expect(response.headers.get("Cache-Control")).toBe("no-cache");
expect(await response.text()).toBe("<!doctype html><html></html>");
});
test("SVG 资源返回正确 Content-Type", () => {
const assets = createTestAssets();
const response = serveStaticAsset("/favicon.svg", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/svg+xml");
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
});
test("CSS 资源返回正确 Content-Type", () => {
const assets = createTestAssets();
const response = serveStaticAsset("/assets/index-a1b2c3.css", assets);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("text/css; charset=utf-8");
});
});

106
tests/setup.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* 全局测试配置
* 主要为后端测试提供基础环境
* 组件测试使用各自的 test-utils.tsx
*/
/* eslint-disable @typescript-eslint/no-empty-function */
// Set up jsdom for ALL tests (both backend and frontend)
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
pretendToBeVisual: true,
url: "http://localhost",
});
globalThis.document = dom.window.document;
globalThis.window = dom.window as unknown as typeof globalThis & Window;
globalThis.navigator = dom.window.navigator;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Element = dom.window.Element;
globalThis.getComputedStyle = dom.window.getComputedStyle;
// Ensure document.body exists
if (!globalThis.document.body) {
const body = globalThis.document.createElement("body");
globalThis.document.documentElement.appendChild(body);
}
// CRITICAL: Set up polyfills BEFORE any other imports
// This ensures @testing-library/react sees these when it loads
// IE-style event handling polyfill (React fallback)
const nodeProto = dom.window.Node.prototype;
const elementProto = dom.window.Element.prototype;
const htmlElementProto = dom.window.HTMLElement.prototype;
const attachEventFn = () => {};
const detachEventFn = () => {};
Object.defineProperty(nodeProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
Object.defineProperty(nodeProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
Object.defineProperty(elementProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
Object.defineProperty(elementProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
Object.defineProperty(htmlElementProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
Object.defineProperty(htmlElementProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
// Other polyfills
globalThis.ResizeObserver = class {
disconnect() {}
observe() {}
unobserve() {}
};
globalThis.MutationObserver = class {
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
};
globalThis.IntersectionObserver = class {
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
} as unknown as typeof IntersectionObserver;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(cb, 16);
globalThis.cancelAnimationFrame = (id: number) => clearTimeout(id);
Object.defineProperty(dom.window, "matchMedia", {
value: (query: string) => ({
addEventListener: () => {},
addListener: () => {},
dispatchEvent: () => true,
matches: false,
media: query,
onchange: null,
removeEventListener: () => {},
removeListener: () => {},
}),
writable: true,
});
dom.window.Element.prototype.scrollTo = () => {};
dom.window.Element.prototype.scrollIntoView = () => {};
Object.defineProperty(dom.window, "customElements", {
value: {
define: () => {},
get: () => undefined,
},
writable: true,
});
globalThis.customElements = dom.window.customElements;
import { afterEach } from "bun:test";
afterEach(() => {
document.body.innerHTML = "";
});

64
tests/web/App.test.tsx Normal file
View File

@@ -0,0 +1,64 @@
/* eslint-disable @typescript-eslint/require-await */
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { APP } from "../../src/shared/app";
import { App } from "../../src/web/app";
import { renderWithProviders } from "./test-utils";
describe("App", () => {
test("渲染 Layout 骨架和品牌名", () => {
window.fetch = (async () => {
return new Response(
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
{
headers: { "Content-Type": "application/json" },
status: 200,
},
);
}) as unknown as typeof fetch;
renderWithProviders(createElement(App));
expect(screen.getByText(APP.title)).not.toBeNull();
expect(screen.getByText("系统")).not.toBeNull();
expect(screen.getByText("明亮")).not.toBeNull();
expect(screen.getByText("黑暗")).not.toBeNull();
});
test("渲染侧边栏菜单项", () => {
window.fetch = (async () => {
return new Response(
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
{
headers: { "Content-Type": "application/json" },
status: 200,
},
);
}) as unknown as typeof fetch;
renderWithProviders(createElement(App));
expect(screen.getAllByText("仪表盘").length).toBeGreaterThan(0);
expect(screen.getAllByText("用户管理").length).toBeGreaterThan(0);
expect(screen.getAllByText("系统设置").length).toBeGreaterThan(0);
});
test("Header 不包含侧边栏折叠按钮", () => {
window.fetch = (async () => {
return new Response(
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
{
headers: { "Content-Type": "application/json" },
status: 200,
},
);
}) as unknown as typeof fetch;
renderWithProviders(createElement(App));
const toggleButtons = document.querySelectorAll(".app-sidebar-toggle");
expect(toggleButtons.length).toBe(0);
});
});

View File

@@ -0,0 +1,68 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { Sidebar } from "../../../../src/web/components/Sidebar";
import { renderWithProviders } from "../../test-utils";
describe("Sidebar", () => {
test("渲染菜单项", () => {
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }));
expect(screen.getByText("仪表盘")).not.toBeNull();
expect(screen.getByText("用户管理")).not.toBeNull();
expect(screen.getByText("系统设置")).not.toBeNull();
});
test("折叠状态下仍渲染菜单项", () => {
renderWithProviders(createElement(Sidebar, { collapsed: true, onToggleCollapsed: () => {} }));
expect(screen.getByText("仪表盘")).not.toBeNull();
expect(screen.getByText("用户管理")).not.toBeNull();
expect(screen.getByText("系统设置")).not.toBeNull();
});
test("高亮当前路由对应的菜单项", () => {
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }), {
initialRoute: "/users",
});
const activeItem = document.querySelector(".t-is-active");
expect(activeItem).not.toBeNull();
expect(activeItem?.textContent).toContain("用户管理");
});
test("展开态底部渲染折叠按钮", () => {
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }));
const collapseBtn = document.querySelector(".app-sidebar-collapse-btn");
expect(collapseBtn).not.toBeNull();
});
test("点击底部按钮调用 onToggleCollapsed", () => {
let called = false;
const onToggle = () => {
called = true;
};
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: onToggle }));
const btn = document.querySelector<HTMLButtonElement>(".app-sidebar-collapse-btn");
expect(btn).not.toBeNull();
btn!.click();
expect(called).toBe(true);
});
test("折叠态底部按钮仍渲染且菜单项高亮不变", () => {
renderWithProviders(createElement(Sidebar, { collapsed: true, onToggleCollapsed: () => {} }), {
initialRoute: "/users",
});
const collapseBtn = document.querySelector(".app-sidebar-collapse-btn");
expect(collapseBtn).not.toBeNull();
const activeItem = document.querySelector(".t-is-active");
expect(activeItem).not.toBeNull();
expect(activeItem?.textContent).toContain("用户管理");
});
});

View File

@@ -0,0 +1,24 @@
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { NotFoundPage } from "../../../src/web/pages/404";
import { renderWithProviders } from "../test-utils";
describe("NotFoundPage", () => {
test("渲染 404 页面", () => {
renderWithProviders(createElement(NotFoundPage));
expect(screen.getByText("404")).not.toBeNull();
expect(screen.getByText("您访问的页面不存在")).not.toBeNull();
expect(screen.getByText("返回首页")).not.toBeNull();
});
test("返回首页按钮存在且可点击", () => {
renderWithProviders(createElement(NotFoundPage));
const button = screen.getByText("返回首页");
expect(button).not.toBeNull();
expect(button.closest("button")).not.toBeNull();
});
});

View File

@@ -0,0 +1,25 @@
/* eslint-disable @typescript-eslint/require-await */
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { DashboardPage } from "../../../src/web/pages/dashboard";
import { renderWithProviders } from "../test-utils";
describe("DashboardPage", () => {
test("渲染欢迎信息", () => {
window.fetch = (async () => {
return new Response(
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
{
headers: { "Content-Type": "application/json" },
status: 200,
},
);
}) as unknown as typeof fetch;
renderWithProviders(createElement(DashboardPage));
expect(screen.getByText(/欢迎使用/)).not.toBeNull();
});
});

View File

@@ -0,0 +1,15 @@
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { SettingsPage } from "../../../src/web/pages/settings";
import { renderWithProviders } from "../test-utils";
describe("SettingsPage", () => {
test("渲染系统设置页面", () => {
renderWithProviders(createElement(SettingsPage));
expect(screen.getByText("系统设置")).not.toBeNull();
expect(screen.getByText("页面建设中...")).not.toBeNull();
});
});

View File

@@ -0,0 +1,15 @@
import { screen } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { UsersPage } from "../../../src/web/pages/users";
import { renderWithProviders } from "../test-utils";
describe("UsersPage", () => {
test("渲染用户管理页面", () => {
renderWithProviders(createElement(UsersPage));
expect(screen.getByText("用户管理")).not.toBeNull();
expect(screen.getByText("页面建设中...")).not.toBeNull();
});
});

90
tests/web/test-utils.tsx Normal file
View File

@@ -0,0 +1,90 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { mock } from "bun:test";
import { createElement, StrictMode } from "react";
import { MemoryRouter } from "react-router";
import { ErrorBoundary } from "../../src/web/components/ErrorBoundary";
// Mock recharts BEFORE any component imports
void mock.module("recharts", () => ({
Area: () => null,
CartesianGrid: () => null,
Line: () => null,
LineChart: ({ children }: { children: unknown }) => children,
ResponsiveContainer: ({ children }: { children: unknown }) => children,
Tooltip: () => null,
XAxis: () => null,
YAxis: () => null,
}));
export interface RenderWithProvidersOptions {
initialRoute?: string;
}
export function renderWithProviders(ui: React.ReactElement, options?: RenderWithProvidersOptions) {
const queryClient = createTestQueryClient();
const initialRoute = options?.initialRoute ?? "/";
return render(
createElement(
StrictMode,
null,
createElement(
ErrorBoundary,
null,
createElement(
QueryClientProvider,
{ client: queryClient },
createElement(MemoryRouter, { initialEntries: [initialRoute] }, ui),
),
),
),
);
}
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 0,
},
},
});
}
// Custom test helpers (替代 jest-dom matchers)
export const testHelpers = {
toBeInTheDocument: (element: Element | null) => {
const pass = element !== null && document.contains(element);
return {
message: () => (pass ? "Expected element not to be in document" : "Expected element to be in document"),
pass,
};
},
toHaveAttribute: (element: Element | null, attr: string, value?: string) => {
const pass = value === undefined ? (element?.hasAttribute(attr) ?? false) : element?.getAttribute(attr) === value;
return {
message: () =>
pass ? `Expected element not to have attribute "${attr}"` : `Expected element to have attribute "${attr}"`,
pass,
};
},
toHaveClass: (element: Element | null, className: string) => {
const pass = element?.classList.contains(className) ?? false;
return {
message: () =>
pass ? `Expected element not to have class "${className}"` : `Expected element to have class "${className}"`,
pass,
};
},
toHaveTextContent: (element: Element | null, text: RegExp | string) => {
const content = element?.textContent ?? "";
const pass = element !== null && (typeof text === "string" ? content.includes(text) : text.test(content));
return {
message: () => (pass ? `Expected element not to have text "${text}"` : `Expected element to have text "${text}"`),
pass,
};
},
};

View File

@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import {
formatCountdown,
formatDurationUnit,
formatRelativeTime,
isOlderThan,
subtractHours,
} from "../../../src/web/utils/time";
describe("subtractHours", () => {
test("正常扣减小时", () => {
const result = subtractHours(new Date("2025-01-15T12:00:00.000Z"), 3);
expect(result.toISOString()).toBe("2025-01-15T09:00:00.000Z");
});
test("跨天扣减", () => {
const result = subtractHours(new Date("2025-01-15T02:00:00.000Z"), 6);
expect(result.toISOString()).toBe("2025-01-14T20:00:00.000Z");
});
test("跨月扣减", () => {
const result = subtractHours(new Date("2025-03-01T01:00:00.000Z"), 2);
expect(result.toISOString()).toBe("2025-02-28T23:00:00.000Z");
});
test("扣减 0 小时返回相同时间", () => {
const result = subtractHours(new Date("2025-01-15T12:00:00.000Z"), 0);
expect(result.toISOString()).toBe("2025-01-15T12:00:00.000Z");
});
});
describe("formatRelativeTime", () => {
const now = new Date("2025-01-01T00:02:00.000Z");
test("格式化秒和分钟", () => {
expect(formatRelativeTime("2025-01-01T00:01:45.000Z", now)).toBe("15秒前");
expect(formatRelativeTime("2025-01-01T00:00:00.000Z", now)).toBe("2分钟前");
});
test("无时间返回占位", () => {
expect(formatRelativeTime(null, now)).toBe("尚无检查数据");
expect(formatRelativeTime("invalid", now)).toBe("尚无检查数据");
});
});
describe("formatDurationUnit", () => {
test("按秒、分钟、小时动态格式化", () => {
expect(formatDurationUnit(1500)).toEqual({ suffix: "秒", value: 1.5 });
expect(formatDurationUnit(120000)).toEqual({ suffix: "分钟", value: 2 });
expect(formatDurationUnit(5400000)).toEqual({ suffix: "小时", value: 1.5 });
});
test("空时长返回占位", () => {
expect(formatDurationUnit(null)).toEqual({ suffix: "", value: 0 });
});
});
describe("formatCountdown", () => {
test("格式化秒级和分钟级倒计时", () => {
expect(formatCountdown(0)).toBe("0秒");
expect(formatCountdown(59)).toBe("59秒");
expect(formatCountdown(60)).toBe("1分0秒");
expect(formatCountdown(299)).toBe("4分59秒");
});
});
describe("isOlderThan", () => {
test("判断时间是否超过阈值", () => {
const now = new Date("2025-01-01T00:02:00.000Z");
expect(isOlderThan("2025-01-01T00:00:59.000Z", 60000, now)).toBe(true);
expect(isOlderThan("2025-01-01T00:01:30.000Z", 60000, now)).toBe(false);
});
});