cache-hit-rate-alert-integer-cast.test.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. /**
  3. * Regression test for: CASE types integer and text cannot be matched
  4. *
  5. * The ttlFallbackSecondsExpr CASE generates THEN values via parameterized $N
  6. * which PostgreSQL infers as text. The outer ttlSecondsExpr CASE mixes these
  7. * text-inferred branches with integer literals like 3600, 300, causing the
  8. * type mismatch. The fix adds explicit ::integer casts to the THEN/ELSE values.
  9. */
  10. function sqlToString(sqlObj: unknown): string {
  11. const visited = new Set<unknown>();
  12. const walk = (node: unknown): string => {
  13. if (!node || visited.has(node)) return "";
  14. visited.add(node);
  15. if (typeof node === "string") return node;
  16. if (typeof node === "number") return String(node);
  17. if (typeof node === "object") {
  18. const anyNode = node as Record<string, unknown>;
  19. if (Array.isArray(anyNode)) {
  20. return anyNode.map(walk).join("");
  21. }
  22. if (anyNode.value !== undefined) {
  23. if (Array.isArray(anyNode.value)) {
  24. return (anyNode.value as unknown[]).map(walk).join("");
  25. }
  26. return walk(anyNode.value);
  27. }
  28. if (anyNode.queryChunks) {
  29. return walk(anyNode.queryChunks);
  30. }
  31. // Walk all own values for deeply nested SQL objects
  32. const values = Object.values(anyNode);
  33. if (values.length > 0) {
  34. return values.map(walk).join("");
  35. }
  36. }
  37. return "";
  38. };
  39. return walk(sqlObj);
  40. }
  41. let capturedSelectArgs: unknown = null;
  42. vi.mock("server-only", () => ({}));
  43. vi.mock("@/drizzle/db", () => {
  44. const handler: ProxyHandler<object> = {
  45. get(_target, prop) {
  46. if (prop === "then") {
  47. return (resolve: (v: unknown[]) => void) => resolve([]);
  48. }
  49. if (prop === "select") {
  50. return (args: unknown) => {
  51. capturedSelectArgs = args;
  52. return new Proxy({}, handler);
  53. };
  54. }
  55. return (..._args: unknown[]) => new Proxy({}, handler);
  56. },
  57. };
  58. return {
  59. db: new Proxy({}, handler),
  60. };
  61. });
  62. vi.mock("@/drizzle/schema", () => ({
  63. messageRequest: {
  64. providerId: "provider_id",
  65. model: "model",
  66. originalModel: "original_model",
  67. sessionId: "session_id",
  68. requestSequence: "request_sequence",
  69. createdAt: "created_at",
  70. deletedAt: "deleted_at",
  71. blockedBy: "blocked_by",
  72. statusCode: "status_code",
  73. inputTokens: "input_tokens",
  74. cacheCreationInputTokens: "cache_creation_input_tokens",
  75. cacheReadInputTokens: "cache_read_input_tokens",
  76. cacheCreation5mInputTokens: "cache_creation_5m_input_tokens",
  77. cacheCreation1hInputTokens: "cache_creation_1h_input_tokens",
  78. cacheTtlApplied: "cache_ttl_applied",
  79. swapCacheTtlApplied: "swap_cache_ttl_applied",
  80. },
  81. providers: {
  82. id: "id",
  83. providerType: "provider_type",
  84. deletedAt: "deleted_at",
  85. },
  86. }));
  87. vi.mock("@/repository/system-config", () => ({
  88. getSystemSettings: vi.fn(() => Promise.resolve({ billingModelSource: "original" })),
  89. }));
  90. vi.mock("@/repository/_shared/message-request-conditions", () => ({
  91. EXCLUDE_WARMUP_CONDITION: "1=1",
  92. }));
  93. vi.mock("drizzle-orm/pg-core", async () => {
  94. const actual = await vi.importActual("drizzle-orm/pg-core");
  95. return {
  96. ...(actual as object),
  97. alias: (table: Record<string, unknown>) => ({ ...table }),
  98. };
  99. });
  100. vi.mock("@/lib/logger", () => ({
  101. logger: {
  102. trace: vi.fn(),
  103. debug: vi.fn(),
  104. info: vi.fn(),
  105. warn: vi.fn(),
  106. error: vi.fn(),
  107. },
  108. }));
  109. describe("cache-hit-rate-alert - integer cast regression", () => {
  110. beforeEach(() => {
  111. capturedSelectArgs = null;
  112. });
  113. it("ttlFallbackSecondsExpr CASE must cast THEN/ELSE values to ::integer", async () => {
  114. const { findProviderModelCacheHitRateMetricsForAlert } = await import(
  115. "@/repository/cache-hit-rate-alert"
  116. );
  117. const now = new Date();
  118. const oneHourAgo = new Date(now.getTime() - 3600_000);
  119. await findProviderModelCacheHitRateMetricsForAlert({
  120. start: oneHourAgo,
  121. end: now,
  122. });
  123. expect(capturedSelectArgs).toBeTruthy();
  124. const sqlStr = sqlToString(capturedSelectArgs);
  125. // The ttlFallbackSecondsExpr CASE has N provider-type WHEN clauses + 1 ELSE,
  126. // each requiring ::integer cast. The AST walker may merge some fragments, but
  127. // we must see at least 2 distinct ::integer casts (THEN + ELSE branches).
  128. const integerCastCount = (sqlStr.match(/::integer/g) || []).length;
  129. expect(integerCastCount).toBeGreaterThanOrEqual(2);
  130. });
  131. });