error-rule-detector-reload-queue.test.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. import { afterEach, describe, expect, test, vi } from "vitest";
  2. const mocks = vi.hoisted(() => {
  3. const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
  4. return {
  5. getActiveErrorRules: vi.fn(),
  6. subscribeCacheInvalidation: vi.fn(async () => undefined),
  7. eventEmitter: {
  8. on(event: string, handler: (...args: unknown[]) => void) {
  9. const current = listeners.get(event) ?? new Set<(...args: unknown[]) => void>();
  10. current.add(handler);
  11. listeners.set(event, current);
  12. },
  13. emit(event: string, ...args: unknown[]) {
  14. for (const handler of listeners.get(event) ?? []) {
  15. handler(...args);
  16. }
  17. },
  18. removeAllListeners() {
  19. listeners.clear();
  20. },
  21. },
  22. logger: {
  23. debug: vi.fn(),
  24. info: vi.fn(),
  25. warn: vi.fn(),
  26. trace: vi.fn(),
  27. error: vi.fn(),
  28. fatal: vi.fn(),
  29. },
  30. };
  31. });
  32. vi.mock("@/repository/error-rules", () => ({
  33. getActiveErrorRules: mocks.getActiveErrorRules,
  34. }));
  35. vi.mock("@/lib/event-emitter", () => ({
  36. eventEmitter: mocks.eventEmitter,
  37. }));
  38. vi.mock("@/lib/redis/pubsub", () => ({
  39. CHANNEL_ERROR_RULES_UPDATED: "errorRulesUpdated",
  40. subscribeCacheInvalidation: mocks.subscribeCacheInvalidation,
  41. }));
  42. vi.mock("@/lib/logger", () => ({
  43. logger: mocks.logger,
  44. }));
  45. function buildRule(overrides?: Partial<Record<string, unknown>>) {
  46. return {
  47. id: 101,
  48. pattern: "missing thinking fields",
  49. matchType: "contains" as const,
  50. category: "thinking_error",
  51. description: "YesCode missing thinking fields",
  52. overrideResponse: undefined,
  53. overrideStatusCode: 400,
  54. isEnabled: true,
  55. isDefault: false,
  56. priority: 10,
  57. createdAt: new Date("2026-04-09T00:00:00.000Z"),
  58. updatedAt: new Date("2026-04-09T00:00:00.000Z"),
  59. ...overrides,
  60. };
  61. }
  62. describe("ErrorRuleDetector reload queue", () => {
  63. afterEach(() => {
  64. vi.resetModules();
  65. vi.clearAllMocks();
  66. mocks.eventEmitter.removeAllListeners();
  67. });
  68. test("should apply a queued reload after errorRulesUpdated arrives mid-reload", async () => {
  69. let resolveFirstLoad: ((value: ReturnType<typeof buildRule>[]) => void) | undefined;
  70. mocks.getActiveErrorRules
  71. .mockImplementationOnce(
  72. () =>
  73. new Promise<ReturnType<typeof buildRule>[]>((resolve) => {
  74. resolveFirstLoad = resolve;
  75. })
  76. )
  77. .mockResolvedValueOnce([]);
  78. const { errorRuleDetector } = await import("@/lib/error-rule-detector");
  79. // 等待构造函数里的事件监听异步挂载完成
  80. await new Promise((resolve) => setTimeout(resolve, 0));
  81. const initialReload = errorRuleDetector.reload();
  82. mocks.eventEmitter.emit("errorRulesUpdated");
  83. resolveFirstLoad?.([buildRule()]);
  84. await initialReload;
  85. expect(mocks.getActiveErrorRules).toHaveBeenCalledTimes(2);
  86. expect(errorRuleDetector.detect("Your session is missing thinking fields").matched).toBe(false);
  87. });
  88. test("should restart reload when errorRulesUpdated lands after loading stops but before promise cleanup", async () => {
  89. let resolveFirstLoad: ((value: ReturnType<typeof buildRule>[]) => void) | undefined;
  90. mocks.getActiveErrorRules
  91. .mockImplementationOnce(
  92. () =>
  93. new Promise<ReturnType<typeof buildRule>[]>((resolve) => {
  94. resolveFirstLoad = (value) => {
  95. resolve(value);
  96. queueMicrotask(() => {
  97. mocks.eventEmitter.emit("errorRulesUpdated");
  98. });
  99. };
  100. })
  101. )
  102. .mockResolvedValueOnce([]);
  103. const { errorRuleDetector } = await import("@/lib/error-rule-detector");
  104. await new Promise((resolve) => setTimeout(resolve, 0));
  105. const initialReload = errorRuleDetector.reload();
  106. resolveFirstLoad?.([buildRule()]);
  107. await initialReload;
  108. expect(mocks.getActiveErrorRules).toHaveBeenCalledTimes(2);
  109. expect(errorRuleDetector.detect("Your session is missing thinking fields").matched).toBe(false);
  110. });
  111. test("should avoid hot retry loops when a queued reload hits persistent DB failure", async () => {
  112. let rejectFirstLoad: ((reason?: unknown) => void) | undefined;
  113. mocks.getActiveErrorRules
  114. .mockImplementationOnce(
  115. () =>
  116. new Promise<ReturnType<typeof buildRule>[]>((_, reject) => {
  117. rejectFirstLoad = reject;
  118. })
  119. )
  120. .mockResolvedValueOnce([]);
  121. const { errorRuleDetector } = await import("@/lib/error-rule-detector");
  122. await new Promise((resolve) => setTimeout(resolve, 0));
  123. const initialReload = errorRuleDetector.reload();
  124. mocks.eventEmitter.emit("errorRulesUpdated");
  125. rejectFirstLoad?.(new Error("DSN environment variable is not set"));
  126. await initialReload;
  127. expect(mocks.getActiveErrorRules).toHaveBeenCalledTimes(1);
  128. expect(errorRuleDetector.getStats().isLoading).toBe(false);
  129. await errorRuleDetector.reload();
  130. expect(mocks.getActiveErrorRules).toHaveBeenCalledTimes(2);
  131. });
  132. test("should let ordinary waiters reuse an in-flight reload without forcing an extra pass", async () => {
  133. let resolveFirstLoad: ((value: ReturnType<typeof buildRule>[]) => void) | undefined;
  134. mocks.getActiveErrorRules.mockImplementationOnce(
  135. () =>
  136. new Promise<ReturnType<typeof buildRule>[]>((resolve) => {
  137. resolveFirstLoad = resolve;
  138. })
  139. );
  140. const { errorRuleDetector } = await import("@/lib/error-rule-detector");
  141. await new Promise((resolve) => setTimeout(resolve, 0));
  142. const runningReload = errorRuleDetector.reload();
  143. const waiter = errorRuleDetector.ensureInitialized();
  144. resolveFirstLoad?.([buildRule()]);
  145. await Promise.all([runningReload, waiter]);
  146. expect(mocks.getActiveErrorRules).toHaveBeenCalledTimes(1);
  147. expect(errorRuleDetector.detect("Your session is missing thinking fields").matched).toBe(true);
  148. });
  149. test("should keep ensureInitialized waiting until a queued rerun finishes", async () => {
  150. let resolveFirstLoad: ((value: ReturnType<typeof buildRule>[]) => void) | undefined;
  151. let resolveSecondLoad: ((value: ReturnType<typeof buildRule>[]) => void) | undefined;
  152. mocks.getActiveErrorRules
  153. .mockImplementationOnce(
  154. () =>
  155. new Promise<ReturnType<typeof buildRule>[]>((resolve) => {
  156. resolveFirstLoad = resolve;
  157. })
  158. )
  159. .mockImplementationOnce(
  160. () =>
  161. new Promise<ReturnType<typeof buildRule>[]>((resolve) => {
  162. resolveSecondLoad = resolve;
  163. })
  164. );
  165. const { errorRuleDetector } = await import("@/lib/error-rule-detector");
  166. await new Promise((resolve) => setTimeout(resolve, 0));
  167. const runningReload = errorRuleDetector.reload();
  168. mocks.eventEmitter.emit("errorRulesUpdated");
  169. resolveFirstLoad?.([buildRule()]);
  170. await new Promise((resolve) => setTimeout(resolve, 0));
  171. let waiterSettled = false;
  172. const waiter = errorRuleDetector.ensureInitialized().then(() => {
  173. waiterSettled = true;
  174. });
  175. await new Promise((resolve) => setTimeout(resolve, 0));
  176. expect(waiterSettled).toBe(false);
  177. resolveSecondLoad?.([]);
  178. await Promise.all([runningReload, waiter]);
  179. expect(errorRuleDetector.detect("Your session is missing thinking fields").matched).toBe(false);
  180. });
  181. });