overview-timezone-parentheses.test.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  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. usageLedger: {
  78. blockedBy: "blockedBy",
  79. userId: "userId",
  80. costUsd: "costUsd",
  81. durationMs: "durationMs",
  82. statusCode: "statusCode",
  83. isSuccess: "isSuccess",
  84. createdAt: "createdAt",
  85. },
  86. messageRequest: {
  87. deletedAt: "deletedAt",
  88. userId: "userId",
  89. costUsd: "costUsd",
  90. durationMs: "durationMs",
  91. statusCode: "statusCode",
  92. createdAt: "createdAt",
  93. blockedBy: "blockedBy",
  94. },
  95. }));
  96. vi.mock("@/lib/utils/timezone", () => ({
  97. resolveSystemTimezone: mocks.resolveSystemTimezone,
  98. }));
  99. vi.mock("@/lib/utils/currency", () => ({
  100. Decimal: class FakeDecimal {
  101. private v: number;
  102. constructor(v: number | string) {
  103. this.v = Number(v);
  104. }
  105. toDecimalPlaces() {
  106. return this;
  107. }
  108. toNumber() {
  109. return this.v;
  110. }
  111. },
  112. toCostDecimal: (v: unknown) => {
  113. if (v === null || v === undefined) return null;
  114. return {
  115. toDecimalPlaces: () => ({ toNumber: () => Number(v) }),
  116. };
  117. },
  118. }));
  119. describe("getOverviewMetricsWithComparison - timezone parentheses regression", () => {
  120. beforeEach(() => {
  121. vi.resetModules();
  122. allWhereArgs.length = 0;
  123. mocks.resolveSystemTimezone.mockResolvedValue("Asia/Shanghai");
  124. });
  125. it("yesterdayStartLocal arithmetic must be parenthesized to avoid timezone(unknown, interval)", async () => {
  126. const { getOverviewMetricsWithComparison } = await import("@/repository/overview");
  127. await getOverviewMetricsWithComparison();
  128. // getOverviewMetricsWithComparison fires 3 queries via Promise.all
  129. // Query 2 (yesterday) uses yesterdayStart and yesterdayEnd
  130. expect(allWhereArgs.length).toBe(3);
  131. const yesterdayWhereSql = sqlToString(allWhereArgs[1][0]);
  132. // yesterdayStartLocal = (todayStartLocal - INTERVAL '1 day')
  133. // Must have closing paren after '1 day' BEFORE AT TIME ZONE
  134. expect(yesterdayWhereSql).toContain("INTERVAL '1 day')");
  135. expect(yesterdayWhereSql).not.toMatch(/INTERVAL '1 day' AT TIME ZONE/);
  136. });
  137. it("yesterdayEndLocal arithmetic must be parenthesized", async () => {
  138. const { getOverviewMetricsWithComparison } = await import("@/repository/overview");
  139. await getOverviewMetricsWithComparison();
  140. expect(allWhereArgs.length).toBe(3);
  141. const yesterdayWhereSql = sqlToString(allWhereArgs[1][0]);
  142. // yesterdayEndLocal = (yesterdayStartLocal + (nowLocal - todayStartLocal))
  143. // The outer arithmetic must be wrapped in parens
  144. // After fix the SQL should have nested parens: ((... - INTERVAL '1 day') + (...))
  145. // It should NOT have bare `)) AT TIME ZONE` without the outer arithmetic paren
  146. expect(yesterdayWhereSql).toContain(") AT TIME ZONE");
  147. });
  148. it("todayStart already has correct parentheses and should remain correct", async () => {
  149. const { getOverviewMetricsWithComparison } = await import("@/repository/overview");
  150. await getOverviewMetricsWithComparison();
  151. expect(allWhereArgs.length).toBe(3);
  152. const todayWhereSql = sqlToString(allWhereArgs[0][0]);
  153. // todayStartLocal uses DATE_TRUNC which doesn't need arithmetic parens
  154. // tomorrowStart already had parens: ((todayStartLocal + INTERVAL '1 day') AT TIME ZONE tz)
  155. expect(todayWhereSql).toContain("INTERVAL '1 day')");
  156. });
  157. });