templates.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import { describe, expect, it } from "vitest";
  2. import { buildCacheHitRateAlertMessage } from "@/lib/webhook/templates/cache-hit-rate-alert";
  3. import { buildCircuitBreakerMessage } from "@/lib/webhook/templates/circuit-breaker";
  4. import { buildCostAlertMessage } from "@/lib/webhook/templates/cost-alert";
  5. import { buildDailyLeaderboardMessage } from "@/lib/webhook/templates/daily-leaderboard";
  6. import type {
  7. CacheHitRateAlertData,
  8. CircuitBreakerAlertData,
  9. CostAlertData,
  10. DailyLeaderboardData,
  11. } from "@/lib/webhook/types";
  12. describe("Message Templates", () => {
  13. describe("buildCircuitBreakerMessage", () => {
  14. it("should create structured message for circuit breaker alert", () => {
  15. const data: CircuitBreakerAlertData = {
  16. providerName: "OpenAI",
  17. providerId: 1,
  18. failureCount: 5,
  19. retryAt: "2025-01-02T12:30:00Z",
  20. lastError: "Connection timeout",
  21. };
  22. const message = buildCircuitBreakerMessage(data);
  23. expect(message.header.level).toBe("error");
  24. expect(message.header.icon).toBe("🔌");
  25. expect(message.header.title).toContain("熔断");
  26. expect(message.timestamp).toBeInstanceOf(Date);
  27. const sectionsStr = JSON.stringify(message.sections);
  28. expect(sectionsStr).toContain("OpenAI");
  29. expect(sectionsStr).toContain("5");
  30. });
  31. it("should handle missing lastError", () => {
  32. const data: CircuitBreakerAlertData = {
  33. providerName: "Anthropic",
  34. providerId: 2,
  35. failureCount: 3,
  36. retryAt: "2025-01-02T13:00:00Z",
  37. };
  38. const message = buildCircuitBreakerMessage(data);
  39. expect(message.header.level).toBe("error");
  40. });
  41. it("should default to provider source when incidentSource is not set", () => {
  42. const data: CircuitBreakerAlertData = {
  43. providerName: "OpenAI",
  44. providerId: 1,
  45. failureCount: 5,
  46. retryAt: "2025-01-02T12:30:00Z",
  47. };
  48. const message = buildCircuitBreakerMessage(data);
  49. const sectionsStr = JSON.stringify(message.sections);
  50. expect(sectionsStr).toContain("OpenAI");
  51. // Default should use provider-style title
  52. expect(message.header.title).toMatch(/供应商/);
  53. });
  54. it("should produce provider-specific message when incidentSource is provider", () => {
  55. const data: CircuitBreakerAlertData = {
  56. providerName: "Anthropic",
  57. providerId: 2,
  58. failureCount: 3,
  59. retryAt: "2025-01-02T13:00:00Z",
  60. incidentSource: "provider",
  61. };
  62. const message = buildCircuitBreakerMessage(data);
  63. expect(message.header.title).toMatch(/供应商/);
  64. const sectionsStr = JSON.stringify(message.sections);
  65. expect(sectionsStr).toContain("Anthropic");
  66. expect(sectionsStr).toContain("ID: 2");
  67. });
  68. it("should produce endpoint-specific message when incidentSource is endpoint", () => {
  69. const data: CircuitBreakerAlertData = {
  70. providerName: "OpenAI",
  71. providerId: 1,
  72. failureCount: 3,
  73. retryAt: "2025-01-02T13:00:00Z",
  74. incidentSource: "endpoint",
  75. endpointId: 42,
  76. endpointUrl: "https://api.openai.com/v1",
  77. };
  78. const message = buildCircuitBreakerMessage(data);
  79. expect(message.header.title).toMatch(/端点/);
  80. const sectionsStr = JSON.stringify(message.sections);
  81. expect(sectionsStr).toContain("42");
  82. expect(sectionsStr).toContain("https://api.openai.com/v1");
  83. });
  84. it("should include endpoint fields in details when source is endpoint", () => {
  85. const data: CircuitBreakerAlertData = {
  86. providerName: "OpenAI",
  87. providerId: 1,
  88. failureCount: 5,
  89. retryAt: "2025-01-02T12:30:00Z",
  90. lastError: "Connection refused",
  91. incidentSource: "endpoint",
  92. endpointId: 99,
  93. endpointUrl: "https://custom-proxy.example.com/v1",
  94. };
  95. const message = buildCircuitBreakerMessage(data);
  96. const sectionsStr = JSON.stringify(message.sections);
  97. // Should still include failure count and error
  98. expect(sectionsStr).toContain("5");
  99. expect(sectionsStr).toContain("Connection refused");
  100. // Should include endpoint-specific info
  101. expect(sectionsStr).toContain("99");
  102. expect(sectionsStr).toContain("https://custom-proxy.example.com/v1");
  103. });
  104. });
  105. describe("buildCostAlertMessage", () => {
  106. it("should create structured message for user cost alert", () => {
  107. const data: CostAlertData = {
  108. targetType: "user",
  109. targetName: "张三",
  110. targetId: 100,
  111. currentCost: 8.5,
  112. quotaLimit: 10,
  113. threshold: 0.8,
  114. period: "本周",
  115. };
  116. const message = buildCostAlertMessage(data);
  117. expect(message.header.level).toBe("warning");
  118. expect(message.header.icon).toBe("💰");
  119. expect(message.header.title).toContain("成本预警");
  120. const sectionsStr = JSON.stringify(message.sections);
  121. expect(sectionsStr).toContain("张三");
  122. expect(sectionsStr).toContain("8.5");
  123. expect(sectionsStr).toContain("本周");
  124. });
  125. it("should create structured message for provider cost alert", () => {
  126. const data: CostAlertData = {
  127. targetType: "provider",
  128. targetName: "GPT-4",
  129. targetId: 1,
  130. currentCost: 950,
  131. quotaLimit: 1000,
  132. threshold: 0.9,
  133. period: "本月",
  134. };
  135. const message = buildCostAlertMessage(data);
  136. expect(message.header.level).toBe("warning");
  137. const sectionsStr = JSON.stringify(message.sections);
  138. expect(sectionsStr).toContain("供应商");
  139. });
  140. });
  141. describe("buildDailyLeaderboardMessage", () => {
  142. it("should create structured message for leaderboard", () => {
  143. const data: DailyLeaderboardData = {
  144. date: "2025-01-02",
  145. entries: [
  146. { userId: 1, userName: "用户A", totalRequests: 100, totalCost: 5.0, totalTokens: 50000 },
  147. { userId: 2, userName: "用户B", totalRequests: 80, totalCost: 4.0, totalTokens: 40000 },
  148. ],
  149. totalRequests: 180,
  150. totalCost: 9.0,
  151. };
  152. const message = buildDailyLeaderboardMessage(data);
  153. expect(message.header.level).toBe("info");
  154. expect(message.header.icon).toBe("📊");
  155. expect(message.header.title).toContain("排行榜");
  156. const sectionsStr = JSON.stringify(message.sections);
  157. expect(sectionsStr).toContain("用户A");
  158. expect(sectionsStr).toContain("🥇");
  159. });
  160. it("should handle empty entries", () => {
  161. const data: DailyLeaderboardData = {
  162. date: "2025-01-02",
  163. entries: [],
  164. totalRequests: 0,
  165. totalCost: 0,
  166. };
  167. const message = buildDailyLeaderboardMessage(data);
  168. expect(message.header.level).toBe("info");
  169. const sectionsStr = JSON.stringify(message.sections);
  170. expect(sectionsStr).toContain("暂无数据");
  171. });
  172. });
  173. describe("buildCacheHitRateAlertMessage", () => {
  174. it("should create structured message for cache hit rate alert", () => {
  175. const data: CacheHitRateAlertData = {
  176. window: {
  177. mode: "5m",
  178. startTime: "2026-02-24T00:00:00.000Z",
  179. endTime: "2026-02-24T00:05:00.000Z",
  180. durationMinutes: 5,
  181. },
  182. anomalies: [
  183. {
  184. providerId: 1,
  185. providerName: "OpenAI",
  186. providerType: "openai-compatible",
  187. model: "gpt-4o",
  188. baselineSource: "historical",
  189. current: {
  190. kind: "eligible",
  191. requests: 100,
  192. denominatorTokens: 10000,
  193. hitRateTokens: 0.1,
  194. },
  195. baseline: {
  196. kind: "eligible",
  197. requests: 200,
  198. denominatorTokens: 20000,
  199. hitRateTokens: 0.5,
  200. },
  201. deltaAbs: -0.4,
  202. deltaRel: -0.8,
  203. dropAbs: 0.4,
  204. reasonCodes: ["abs_min"],
  205. },
  206. ],
  207. suppressedCount: 0,
  208. settings: {
  209. windowMode: "auto",
  210. checkIntervalMinutes: 5,
  211. historicalLookbackDays: 7,
  212. minEligibleRequests: 20,
  213. minEligibleTokens: 0,
  214. absMin: 0.05,
  215. dropRel: 0.3,
  216. dropAbs: 0.1,
  217. cooldownMinutes: 30,
  218. topN: 10,
  219. },
  220. generatedAt: "2026-02-24T00:05:00.000Z",
  221. };
  222. const message = buildCacheHitRateAlertMessage(data, "UTC");
  223. expect(message.header.level).toBe("warning");
  224. expect(message.header.icon).toBe("[CACHE]");
  225. expect(message.header.title).toContain("缓存命中率");
  226. expect(message.timestamp).toBeInstanceOf(Date);
  227. const sectionsStr = JSON.stringify(message.sections);
  228. expect(sectionsStr).toContain("OpenAI");
  229. expect(sectionsStr).toContain("gpt-4o");
  230. expect(sectionsStr).toContain("5m");
  231. expect(sectionsStr).toContain("异常列表");
  232. });
  233. it("should handle anomalies with null baseline", () => {
  234. const data: CacheHitRateAlertData = {
  235. window: {
  236. mode: "5m",
  237. startTime: "2026-02-24T00:00:00.000Z",
  238. endTime: "2026-02-24T00:05:00.000Z",
  239. durationMinutes: 5,
  240. },
  241. anomalies: [
  242. {
  243. providerId: 1,
  244. providerName: "OpenAI",
  245. providerType: "openai-compatible",
  246. model: "gpt-4o",
  247. baselineSource: null,
  248. current: {
  249. kind: "eligible",
  250. requests: 100,
  251. denominatorTokens: 10000,
  252. hitRateTokens: 0.1,
  253. },
  254. baseline: null,
  255. deltaAbs: null,
  256. deltaRel: null,
  257. dropAbs: null,
  258. reasonCodes: ["abs_min"],
  259. },
  260. ],
  261. suppressedCount: 0,
  262. settings: {
  263. windowMode: "auto",
  264. checkIntervalMinutes: 5,
  265. historicalLookbackDays: 7,
  266. minEligibleRequests: 20,
  267. minEligibleTokens: 0,
  268. absMin: 0.05,
  269. dropRel: 0.3,
  270. dropAbs: 0.1,
  271. cooldownMinutes: 30,
  272. topN: 10,
  273. },
  274. generatedAt: "2026-02-24T00:05:00.000Z",
  275. };
  276. const message = buildCacheHitRateAlertMessage(data, "UTC");
  277. expect(message.header.level).toBe("warning");
  278. expect(message.timestamp).toBeInstanceOf(Date);
  279. const sectionsStr = JSON.stringify(message.sections);
  280. expect(sectionsStr).toContain("gpt-4o");
  281. expect(sectionsStr).toContain("基线: 无");
  282. });
  283. it("should handle empty anomalies", () => {
  284. const data: CacheHitRateAlertData = {
  285. window: {
  286. mode: "30m",
  287. startTime: "2026-02-24T00:00:00.000Z",
  288. endTime: "2026-02-24T00:30:00.000Z",
  289. durationMinutes: 30,
  290. },
  291. anomalies: [],
  292. suppressedCount: 2,
  293. settings: {
  294. windowMode: "30m",
  295. checkIntervalMinutes: 5,
  296. historicalLookbackDays: 7,
  297. minEligibleRequests: 20,
  298. minEligibleTokens: 0,
  299. absMin: 0.05,
  300. dropRel: 0.3,
  301. dropAbs: 0.1,
  302. cooldownMinutes: 30,
  303. topN: 10,
  304. },
  305. generatedAt: "2026-02-24T00:30:00.000Z",
  306. };
  307. const message = buildCacheHitRateAlertMessage(data, "UTC");
  308. const sectionsStr = JSON.stringify(message.sections);
  309. expect(sectionsStr).toContain("未检测到异常");
  310. expect(sectionsStr).not.toContain("异常列表");
  311. });
  312. });
  313. });