feat: 引入运行时日志体系和存储配置,配置文件改为必填
- 新增 pino/pino-pretty/pino-roll 依赖,实现结构化日志(console pretty + file JSONL rolling) - 新增 Logger 接口及 PinoLoggerWrapper/ConsoleFallbackLogger/NoopLogger/MemoryLogger 实现 - 新增 src/pino-roll.d.ts 类型声明 - 新增 server.storage.dataDir 配置(默认 ./data,相对路径基于配置文件目录) - 新增 server.logging 配置(level/console/file/rotation,支持变量引用) - 配置文件从可选改为必填,parseRuntimeArgs 无参数时抛错 - bootstrap 创建 logger、确保 dataDir、shutdown flush、失败路径 fallback - startServer 接收 logger 并输出结构化监听日志 - ESLint 新增 no-restricted-syntax 禁止 src/server 直接 console.*(排除 logger.ts) - 更新 config.example.yaml、README.md、DEVELOPMENT.md 同步配置和日志文档 - 完善测试覆盖:logger、config、schema、bootstrap 共 150 个测试通过
This commit is contained in:
@@ -1,50 +1,80 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, @typescript-eslint/require-await, @typescript-eslint/unbound-method */
|
||||
/* 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";
|
||||
|
||||
const origExit = process.exit;
|
||||
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 mockLoadConfig = (async () => ({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
})) as unknown as BootstrapDependencies["loadConfig"];
|
||||
const mockLogError = () => {};
|
||||
const cfg = makeTempConfig();
|
||||
const mockLoadConfig = (async () => cfg) as unknown as BootstrapDependencies["loadConfig"];
|
||||
const mockOnSignal = (_signal: string, _handler: () => void) => {
|
||||
signalRegistered = true;
|
||||
};
|
||||
const mockStartServer = (_options: StartServerOptions) => {
|
||||
expect(_options.version).toBeUndefined();
|
||||
const mockStartServer = (options: StartServerOptions) => {
|
||||
loggerPassedToServer = options.logger;
|
||||
started = true;
|
||||
return {};
|
||||
};
|
||||
|
||||
const deps: BootstrapDependencies = {
|
||||
createLogger: async () => createMemoryLogger(),
|
||||
loadConfig: mockLoadConfig,
|
||||
logError: mockLogError,
|
||||
onSignal: mockOnSignal,
|
||||
startServer: mockStartServer,
|
||||
};
|
||||
|
||||
await bootstrap({ mode: "production" }, deps);
|
||||
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 = {
|
||||
loadConfig: async () => ({ host: "127.0.0.1", port: 0 }),
|
||||
logError: () => {},
|
||||
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;
|
||||
@@ -52,39 +82,127 @@ describe("bootstrap", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await bootstrap({ mode: "production", version: "1.2.3" }, deps);
|
||||
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("启动失败时调用 logError", async () => {
|
||||
let errorLogged = false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
process.exit = ((code?: number) => {
|
||||
throw new Error("process.exit called");
|
||||
}) as unknown as typeof process.exit;
|
||||
test("logger 初始化失败时使用 fallback 并退出", async () => {
|
||||
let exitCode: number | undefined;
|
||||
|
||||
const cfg = makeTempConfig();
|
||||
const deps: BootstrapDependencies = {
|
||||
loadConfig: async () => {
|
||||
throw new Error("test config error");
|
||||
createLogger: async () => {
|
||||
throw new Error("pino import failed");
|
||||
},
|
||||
logError: () => {
|
||||
errorLogged = true;
|
||||
exit: (code: number) => {
|
||||
exitCode = code;
|
||||
throw new Error("exit called");
|
||||
},
|
||||
loadConfig: async () => cfg,
|
||||
startServer: () => {
|
||||
throw new Error("should not reach");
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await bootstrap({ mode: "production" }, deps);
|
||||
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
|
||||
} catch {
|
||||
// process.exit throws to interrupt flow
|
||||
// expected - exit threw
|
||||
}
|
||||
|
||||
process.exit = origExit;
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
expect(errorLogged).toBe(true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { loadServerConfig, parseRuntimeArgs } from "../../src/server/config";
|
||||
import { loadServerConfig, parseRuntimeArgs, parseSize } from "../../src/server/config";
|
||||
import { APP } from "../../src/shared/app";
|
||||
|
||||
describe("parseRuntimeArgs", () => {
|
||||
test("无参数返回空对象", () => {
|
||||
const result = parseRuntimeArgs([]);
|
||||
expect(result).toEqual({});
|
||||
test("无参数抛出需要配置文件路径错误", () => {
|
||||
try {
|
||||
parseRuntimeArgs([]);
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain("需要指定 YAML 配置文件路径");
|
||||
}
|
||||
});
|
||||
|
||||
test("有参数返回 configPath", () => {
|
||||
@@ -16,96 +21,48 @@ describe("parseRuntimeArgs", () => {
|
||||
expect(result).toEqual({ configPath: "config.yaml" });
|
||||
});
|
||||
|
||||
test("--help 输出用法并退出", () => {
|
||||
const logs: string[] = [];
|
||||
let exitCode: number | undefined;
|
||||
const originalLog = console.log;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
Object.defineProperty(process, "exit", {
|
||||
configurable: true,
|
||||
value: (code: number) => {
|
||||
exitCode = code;
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
test("--help 抛出错误", () => {
|
||||
try {
|
||||
parseRuntimeArgs(["--help"]);
|
||||
} finally {
|
||||
Object.defineProperty(process, "exit", {
|
||||
configurable: true,
|
||||
value: process.exit.bind(process),
|
||||
writable: true,
|
||||
});
|
||||
console.log = originalLog;
|
||||
expect.unreachable();
|
||||
} catch (error) {
|
||||
expect((error as Error).message).toContain("用法");
|
||||
}
|
||||
expect(exitCode).toBe(0);
|
||||
expect(logs.some((l) => l.includes("用法"))).toBe(true);
|
||||
});
|
||||
|
||||
test("-h 输出用法并退出", () => {
|
||||
const logs: string[] = [];
|
||||
let exitCode: number | undefined;
|
||||
const originalLog = console.log;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
Object.defineProperty(process, "exit", {
|
||||
configurable: true,
|
||||
value: (code: number) => {
|
||||
exitCode = code;
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
test("-h 抛出错误", () => {
|
||||
try {
|
||||
parseRuntimeArgs(["-h"]);
|
||||
} finally {
|
||||
Object.defineProperty(process, "exit", {
|
||||
configurable: true,
|
||||
value: process.exit.bind(process),
|
||||
writable: true,
|
||||
});
|
||||
console.log = originalLog;
|
||||
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 格式");
|
||||
}
|
||||
expect(exitCode).toBe(0);
|
||||
expect(logs.some((l) => l.includes("用法"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadServerConfig", () => {
|
||||
test("无 configPath 使用默认值", async () => {
|
||||
const config = await loadServerConfig();
|
||||
expect(config.host).toBe("127.0.0.1");
|
||||
expect(config.port).toBe(3000);
|
||||
});
|
||||
|
||||
test("环境变量 HOST 不影响默认值(无隐式覆盖)", async () => {
|
||||
const prev = process.env["HOST"];
|
||||
process.env["HOST"] = "0.0.0.0";
|
||||
try {
|
||||
const config = await loadServerConfig();
|
||||
expect(config.host).toBe("127.0.0.1");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env["HOST"];
|
||||
} else {
|
||||
process.env["HOST"] = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("环境变量 PORT 不影响默认值(无隐式覆盖)", async () => {
|
||||
const prev = process.env["PORT"];
|
||||
process.env["PORT"] = "8080";
|
||||
try {
|
||||
const config = await loadServerConfig();
|
||||
expect(config.port).toBe(3000);
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env["PORT"];
|
||||
} else {
|
||||
process.env["PORT"] = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("YAML 配置文件不存在时报错", async () => {
|
||||
try {
|
||||
await loadServerConfig("/nonexistent/path/config.yaml");
|
||||
@@ -115,16 +72,20 @@ describe("loadServerConfig", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("新布局 server.listen 加载成功", async () => {
|
||||
test("最简配置解析成功", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "test-listen.yaml");
|
||||
const yamlContent = 'server:\n listen:\n host: "0.0.0.0"\n port: 9999\n';
|
||||
await writeFile(yamlPath, yamlContent);
|
||||
const yamlPath = join(temp, "minimal.yaml");
|
||||
await writeFile(yamlPath, 'server:\n listen:\n host: "0.0.0.0"\n port: 9999\n');
|
||||
|
||||
try {
|
||||
const config = await loadServerConfig(yamlPath);
|
||||
expect(config.host).toBe("0.0.0.0");
|
||||
expect(config.port).toBe(9999);
|
||||
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 });
|
||||
}
|
||||
@@ -149,8 +110,7 @@ describe("loadServerConfig", () => {
|
||||
test("非法端口被拒绝", async () => {
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "test-bad-port.yaml");
|
||||
const yamlContent = "server:\n listen:\n port: 99999\n";
|
||||
await writeFile(yamlPath, yamlContent);
|
||||
await writeFile(yamlPath, "server:\n listen:\n port: 99999\n");
|
||||
|
||||
try {
|
||||
await loadServerConfig(yamlPath);
|
||||
@@ -170,13 +130,12 @@ describe("loadServerConfig", () => {
|
||||
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "test-env-var.yaml");
|
||||
const yamlContent = 'server:\n listen:\n host: "${HOST}"\n port: ${PORT}\n';
|
||||
await writeFile(yamlPath, yamlContent);
|
||||
await writeFile(yamlPath, 'server:\n listen:\n host: "${HOST}"\n port: ${PORT}\n');
|
||||
|
||||
try {
|
||||
const config = await loadServerConfig(yamlPath);
|
||||
expect(config.host).toBe("10.0.0.1");
|
||||
expect(config.port).toBe(4000);
|
||||
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"];
|
||||
@@ -190,13 +149,142 @@ describe("loadServerConfig", () => {
|
||||
delete process.env["MY_HOST"];
|
||||
const temp = tmpdir();
|
||||
const yamlPath = join(temp, "test-default.yaml");
|
||||
const yamlContent = 'server:\n listen:\n host: "${MY_HOST|0.0.0.0}"\n port: ${MY_PORT|5000}\n';
|
||||
await writeFile(yamlPath, yamlContent);
|
||||
await writeFile(yamlPath, 'server:\n listen:\n host: "${MY_HOST|0.0.0.0}"\n port: ${MY_PORT|5000}\n');
|
||||
|
||||
try {
|
||||
const config = await loadServerConfig(yamlPath);
|
||||
expect(config.host).toBe("0.0.0.0");
|
||||
expect(config.port).toBe(5000);
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -37,7 +37,41 @@ describe("Authoring schema 校验", () => {
|
||||
expect(validate({ variables: { HOST: "127.0.0.1" } })).toBe(true);
|
||||
});
|
||||
|
||||
test("拒绝未知字段 server.host", () => {
|
||||
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);
|
||||
@@ -82,6 +116,28 @@ describe("Normalized schema 校验", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
117
tests/server/logger.test.ts
Normal file
117
tests/server/logger.test.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user