| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230 |
- import { afterEach, describe, expect, test, vi } from "vitest";
- const mocks = vi.hoisted(() => {
- const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
- return {
- getActiveErrorRules: vi.fn(),
- subscribeCacheInvalidation: vi.fn(async () => undefined),
- eventEmitter: {
- on(event: string, handler: (...args: unknown[]) => void) {
- const current = listeners.get(event) ?? new Set<(...args: unknown[]) => void>();
- current.add(handler);
- listeners.set(event, current);
- },
- emit(event: string, ...args: unknown[]) {
- for (const handler of listeners.get(event) ?? []) {
- handler(...args);
- }
- },
- removeAllListeners() {
- listeners.clear();
- },
- },
- logger: {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- trace: vi.fn(),
- error: vi.fn(),
- fatal: vi.fn(),
- },
- };
- });
- vi.mock("@/repository/error-rules", () => ({
- getActiveErrorRules: mocks.getActiveErrorRules,
- }));
- vi.mock("@/lib/event-emitter", () => ({
- eventEmitter: mocks.eventEmitter,
- }));
- vi.mock("@/lib/redis/pubsub", () => ({
- CHANNEL_ERROR_RULES_UPDATED: "errorRulesUpdated",
- subscribeCacheInvalidation: mocks.subscribeCacheInvalidation,
- }));
- vi.mock("@/lib/logger", () => ({
- logger: mocks.logger,
- }));
- function buildRule(overrides?: Partial<Record<string, unknown>>) {
- return {
- id: 101,
- pattern: "missing thinking fields",
- matchType: "contains" as const,
- category: "thinking_error",
- description: "YesCode missing thinking fields",
- overrideResponse: undefined,
- overrideStatusCode: 400,
- isEnabled: true,
- isDefault: false,
- priority: 10,
- createdAt: new Date("2026-04-09T00:00:00.000Z"),
- updatedAt: new Date("2026-04-09T00:00:00.000Z"),
- ...overrides,
- };
- }
- describe("ErrorRuleDetector reload queue", () => {
- afterEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- mocks.eventEmitter.removeAllListeners();
- });
- test("should apply a queued reload after errorRulesUpdated arrives mid-reload", async () => {
- let resolveFirstLoad: ((value: ReturnType<typeof buildRule>[]) => void) | undefined;
- mocks.getActiveErrorRules
- .mockImplementationOnce(
- () =>
- new Promise<ReturnType<typeof buildRule>[]>((resolve) => {
- resolveFirstLoad = resolve;
- })
- )
- .mockResolvedValueOnce([]);
- const { errorRuleDetector } = await import("@/lib/error-rule-detector");
- // 等待构造函数里的事件监听异步挂载完成
- await new Promise((resolve) => setTimeout(resolve, 0));
- const initialReload = errorRuleDetector.reload();
- mocks.eventEmitter.emit("errorRulesUpdated");
- resolveFirstLoad?.([buildRule()]);
- await initialReload;
- expect(mocks.getActiveErrorRules).toHaveBeenCalledTimes(2);
- expect(errorRuleDetector.detect("Your session is missing thinking fields").matched).toBe(false);
- });
- test("should restart reload when errorRulesUpdated lands after loading stops but before promise cleanup", async () => {
- let resolveFirstLoad: ((value: ReturnType<typeof buildRule>[]) => void) | undefined;
- mocks.getActiveErrorRules
- .mockImplementationOnce(
- () =>
- new Promise<ReturnType<typeof buildRule>[]>((resolve) => {
- resolveFirstLoad = (value) => {
- resolve(value);
- queueMicrotask(() => {
- mocks.eventEmitter.emit("errorRulesUpdated");
- });
- };
- })
- )
- .mockResolvedValueOnce([]);
- const { errorRuleDetector } = await import("@/lib/error-rule-detector");
- await new Promise((resolve) => setTimeout(resolve, 0));
- const initialReload = errorRuleDetector.reload();
- resolveFirstLoad?.([buildRule()]);
- await initialReload;
- expect(mocks.getActiveErrorRules).toHaveBeenCalledTimes(2);
- expect(errorRuleDetector.detect("Your session is missing thinking fields").matched).toBe(false);
- });
- test("should avoid hot retry loops when a queued reload hits persistent DB failure", async () => {
- let rejectFirstLoad: ((reason?: unknown) => void) | undefined;
- mocks.getActiveErrorRules
- .mockImplementationOnce(
- () =>
- new Promise<ReturnType<typeof buildRule>[]>((_, reject) => {
- rejectFirstLoad = reject;
- })
- )
- .mockResolvedValueOnce([]);
- const { errorRuleDetector } = await import("@/lib/error-rule-detector");
- await new Promise((resolve) => setTimeout(resolve, 0));
- const initialReload = errorRuleDetector.reload();
- mocks.eventEmitter.emit("errorRulesUpdated");
- rejectFirstLoad?.(new Error("DSN environment variable is not set"));
- await initialReload;
- expect(mocks.getActiveErrorRules).toHaveBeenCalledTimes(1);
- expect(errorRuleDetector.getStats().isLoading).toBe(false);
- await errorRuleDetector.reload();
- expect(mocks.getActiveErrorRules).toHaveBeenCalledTimes(2);
- });
- test("should let ordinary waiters reuse an in-flight reload without forcing an extra pass", async () => {
- let resolveFirstLoad: ((value: ReturnType<typeof buildRule>[]) => void) | undefined;
- mocks.getActiveErrorRules.mockImplementationOnce(
- () =>
- new Promise<ReturnType<typeof buildRule>[]>((resolve) => {
- resolveFirstLoad = resolve;
- })
- );
- const { errorRuleDetector } = await import("@/lib/error-rule-detector");
- await new Promise((resolve) => setTimeout(resolve, 0));
- const runningReload = errorRuleDetector.reload();
- const waiter = errorRuleDetector.ensureInitialized();
- resolveFirstLoad?.([buildRule()]);
- await Promise.all([runningReload, waiter]);
- expect(mocks.getActiveErrorRules).toHaveBeenCalledTimes(1);
- expect(errorRuleDetector.detect("Your session is missing thinking fields").matched).toBe(true);
- });
- test("should keep ensureInitialized waiting until a queued rerun finishes", async () => {
- let resolveFirstLoad: ((value: ReturnType<typeof buildRule>[]) => void) | undefined;
- let resolveSecondLoad: ((value: ReturnType<typeof buildRule>[]) => void) | undefined;
- mocks.getActiveErrorRules
- .mockImplementationOnce(
- () =>
- new Promise<ReturnType<typeof buildRule>[]>((resolve) => {
- resolveFirstLoad = resolve;
- })
- )
- .mockImplementationOnce(
- () =>
- new Promise<ReturnType<typeof buildRule>[]>((resolve) => {
- resolveSecondLoad = resolve;
- })
- );
- const { errorRuleDetector } = await import("@/lib/error-rule-detector");
- await new Promise((resolve) => setTimeout(resolve, 0));
- const runningReload = errorRuleDetector.reload();
- mocks.eventEmitter.emit("errorRulesUpdated");
- resolveFirstLoad?.([buildRule()]);
- await new Promise((resolve) => setTimeout(resolve, 0));
- let waiterSettled = false;
- const waiter = errorRuleDetector.ensureInitialized().then(() => {
- waiterSettled = true;
- });
- await new Promise((resolve) => setTimeout(resolve, 0));
- expect(waiterSettled).toBe(false);
- resolveSecondLoad?.([]);
- await Promise.all([runningReload, waiter]);
- expect(errorRuleDetector.detect("Your session is missing thinking fields").matched).toBe(false);
- });
- });
|