overview-timezone-parentheses.test.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. /**
  3. * Regression test for: function pg_catalog.timezone(unknown, interval) does not exist
  4. *
  5. * In getOverviewMetricsWithComparison, `yesterdayStartLocal` and `yesterdayEndLocal`
  6. * use arithmetic (`-` / `+`) with INTERVAL expressions that are later passed through
  7. * `AT TIME ZONE`. Without parentheses, PG's operator precedence applies AT TIME ZONE
  8. * to the INTERVAL sub-expression, which is invalid.
  9. *
  10. * The fix wraps the arithmetic: `(expr - INTERVAL '1 day')` and `(expr + (...))`.
  11. */
  12. function sqlToString(sqlObj: unknown): string {
  13. const visited = new Set<unknown>();
  14. const walk = (node: unknown): string => {
  15. if (!node || visited.has(node)) return "";
  16. visited.add(node);
  17. if (typeof node === "string") return node;
  18. if (typeof node === "number") return String(node);
  19. if (typeof node === "object") {
  20. const anyNode = node as Record<string, unknown>;
  21. if (Array.isArray(anyNode)) {
  22. return anyNode.map(walk).join("");
  23. }
  24. if (anyNode.value !== undefined) {
  25. if (Array.isArray(anyNode.value)) {
  26. return (anyNode.value as unknown[]).map(walk).join("");
  27. }
  28. return walk(anyNode.value);
  29. }
  30. if (anyNode.queryChunks) {
  31. return walk(anyNode.queryChunks);
  32. }
  33. }
  34. return "";
  35. };
  36. return walk(sqlObj);
  37. }
  38. const mocks = vi.hoisted(() => ({
  39. resolveSystemTimezone: vi.fn(),
  40. }));
  41. function createThenableQuery<T>(result: T, whereArgs?: unknown[]) {
  42. const query: any = Promise.resolve(result);
  43. query.from = vi.fn(() => query);
  44. query.innerJoin = vi.fn(() => query);
  45. query.leftJoin = vi.fn(() => query);
  46. query.orderBy = vi.fn(() => query);
  47. query.limit = vi.fn(() => query);
  48. query.offset = vi.fn(() => query);
  49. query.groupBy = vi.fn(() => query);
  50. query.where = vi.fn((arg: unknown) => {
  51. whereArgs?.push(arg);
  52. return query;
  53. });
  54. return query;
  55. }
  56. const allWhereArgs: unknown[][] = [];
  57. vi.mock("@/drizzle/db", () => ({
  58. db: {
  59. select: vi.fn(() => {
  60. const whereArgs: unknown[] = [];
  61. allWhereArgs.push(whereArgs);
  62. return createThenableQuery(
  63. [
  64. {
  65. requestCount: 10,
  66. totalCost: "1.5",
  67. avgDuration: "200",
  68. errorCount: 1,
  69. },
  70. ],
  71. whereArgs
  72. );
  73. }),
  74. },
  75. }));
  76. vi.mock("@/drizzle/schema", () => ({
  77. messageRequest: {
  78. deletedAt: "deletedAt",
  79. userId: "userId",
  80. costUsd: "costUsd",
  81. durationMs: "durationMs",
  82. statusCode: "statusCode",
  83. createdAt: "createdAt",
  84. blockedBy: "blockedBy",
  85. },
  86. }));
  87. vi.mock("@/lib/utils/timezone", () => ({
  88. resolveSystemTimezone: mocks.resolveSystemTimezone,
  89. }));
  90. vi.mock("@/lib/utils/currency", () => ({
  91. Decimal: class FakeDecimal {
  92. private v: number;
  93. constructor(v: number | string) {
  94. this.v = Number(v);
  95. }
  96. toDecimalPlaces() {
  97. return this;
  98. }
  99. toNumber() {
  100. return this.v;
  101. }
  102. },
  103. toCostDecimal: (v: unknown) => {
  104. if (v === null || v === undefined) return null;
  105. return {
  106. toDecimalPlaces: () => ({ toNumber: () => Number(v) }),
  107. };
  108. },
  109. }));
  110. describe("getOverviewMetricsWithComparison - timezone parentheses regression", () => {
  111. beforeEach(() => {
  112. vi.resetModules();
  113. allWhereArgs.length = 0;
  114. mocks.resolveSystemTimezone.mockResolvedValue("Asia/Shanghai");
  115. });
  116. it("yesterdayStartLocal arithmetic must be parenthesized to avoid timezone(unknown, interval)", async () => {
  117. const { getOverviewMetricsWithComparison } = await import("@/repository/overview");
  118. await getOverviewMetricsWithComparison();
  119. // getOverviewMetricsWithComparison fires 3 queries via Promise.all
  120. // Query 2 (yesterday) uses yesterdayStart and yesterdayEnd
  121. expect(allWhereArgs.length).toBe(3);
  122. const yesterdayWhereSql = sqlToString(allWhereArgs[1][0]);
  123. // yesterdayStartLocal = (todayStartLocal - INTERVAL '1 day')
  124. // Must have closing paren after '1 day' BEFORE AT TIME ZONE
  125. expect(yesterdayWhereSql).toContain("INTERVAL '1 day')");
  126. expect(yesterdayWhereSql).not.toMatch(/INTERVAL '1 day' AT TIME ZONE/);
  127. });
  128. it("yesterdayEndLocal arithmetic must be parenthesized", async () => {
  129. const { getOverviewMetricsWithComparison } = await import("@/repository/overview");
  130. await getOverviewMetricsWithComparison();
  131. expect(allWhereArgs.length).toBe(3);
  132. const yesterdayWhereSql = sqlToString(allWhereArgs[1][0]);
  133. // yesterdayEndLocal = (yesterdayStartLocal + (nowLocal - todayStartLocal))
  134. // The outer arithmetic must be wrapped in parens
  135. // After fix the SQL should have nested parens: ((... - INTERVAL '1 day') + (...))
  136. // It should NOT have bare `)) AT TIME ZONE` without the outer arithmetic paren
  137. expect(yesterdayWhereSql).toContain(") AT TIME ZONE");
  138. });
  139. it("todayStart already has correct parentheses and should remain correct", async () => {
  140. const { getOverviewMetricsWithComparison } = await import("@/repository/overview");
  141. await getOverviewMetricsWithComparison();
  142. expect(allWhereArgs.length).toBe(3);
  143. const todayWhereSql = sqlToString(allWhereArgs[0][0]);
  144. // todayStartLocal uses DATE_TRUNC which doesn't need arithmetic parens
  145. // tomorrowStart already had parens: ((todayStartLocal + INTERVAL '1 day') AT TIME ZONE tz)
  146. expect(todayWhereSql).toContain("INTERVAL '1 day')");
  147. });
  148. });