Преглед изворни кода

test(repository): add regression tests for SQL timezone parentheses and integer cast bugs

Cover three PostgreSQL runtime errors caused by operator precedence
and type inference issues in raw SQL expressions:

- Leaderboard date conditions missing parentheses around INTERVAL
  arithmetic before AT TIME ZONE, triggering pg_catalog.timezone error
- Overview comparison queries with the same parenthesization problem
  on yesterdayStartLocal / yesterdayEndLocal expressions
- Provider endpoints batch CTE VALUES inferred as text, causing
  "integer = text" mismatch on LATERAL join; validated ::integer cast
ding113 пре 6 дана
родитељ
комит
2290bad1ba

+ 178 - 0
tests/unit/repository/leaderboard-timezone-parentheses.test.ts

@@ -0,0 +1,178 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * Regression test for: function pg_catalog.timezone(unknown, interval) does not exist
+ *
+ * PostgreSQL's `AT TIME ZONE` has higher precedence than `+` / `-`.
+ * Without parentheses, `expr + INTERVAL '1 day' AT TIME ZONE tz` is parsed as
+ * `expr + (INTERVAL '1 day' AT TIME ZONE tz)`, which applies AT TIME ZONE to
+ * an INTERVAL -- an invalid operation.
+ *
+ * The fix wraps arithmetic in parentheses: `(expr + INTERVAL '1 day') AT TIME ZONE tz`.
+ */
+
+function sqlToString(sqlObj: unknown): string {
+  const visited = new Set<unknown>();
+
+  const walk = (node: unknown): string => {
+    if (!node || visited.has(node)) return "";
+    visited.add(node);
+
+    if (typeof node === "string") return node;
+    if (typeof node === "number") return String(node);
+
+    if (typeof node === "object") {
+      const anyNode = node as Record<string, unknown>;
+      if (Array.isArray(anyNode)) {
+        return anyNode.map(walk).join("");
+      }
+
+      if (anyNode.value !== undefined) {
+        if (Array.isArray(anyNode.value)) {
+          return (anyNode.value as unknown[]).map(walk).join("");
+        }
+        return walk(anyNode.value);
+      }
+
+      if (anyNode.queryChunks) {
+        return walk(anyNode.queryChunks);
+      }
+    }
+
+    return "";
+  };
+
+  return walk(sqlObj);
+}
+
+const mocks = vi.hoisted(() => ({
+  resolveSystemTimezone: vi.fn(),
+}));
+
+function createThenableQuery<T>(result: T, whereArgs?: unknown[]) {
+  const query: any = Promise.resolve(result);
+  query.from = vi.fn(() => query);
+  query.innerJoin = vi.fn(() => query);
+  query.leftJoin = vi.fn(() => query);
+  query.orderBy = vi.fn(() => query);
+  query.limit = vi.fn(() => query);
+  query.offset = vi.fn(() => query);
+  query.groupBy = vi.fn(() => query);
+  query.where = vi.fn((arg: unknown) => {
+    whereArgs?.push(arg);
+    return query;
+  });
+  return query;
+}
+
+vi.mock("@/drizzle/db", () => {
+  const whereCapture: unknown[] = [];
+  return {
+    db: {
+      select: vi.fn(() => createThenableQuery([], whereCapture)),
+    },
+    __whereCapture: whereCapture,
+  };
+});
+
+vi.mock("@/drizzle/schema", () => ({
+  messageRequest: {
+    deletedAt: "deletedAt",
+    providerId: "providerId",
+    userId: "userId",
+    costUsd: "costUsd",
+    inputTokens: "inputTokens",
+    outputTokens: "outputTokens",
+    cacheCreationInputTokens: "cacheCreationInputTokens",
+    cacheReadInputTokens: "cacheReadInputTokens",
+    errorMessage: "errorMessage",
+    blockedBy: "blockedBy",
+    createdAt: "createdAt",
+    ttfbMs: "ttfbMs",
+    durationMs: "durationMs",
+    statusCode: "statusCode",
+    model: "model",
+    originalModel: "originalModel",
+  },
+  providers: {
+    id: "id",
+    name: "name",
+    deletedAt: "deletedAt",
+    providerType: "providerType",
+  },
+  users: {
+    id: "id",
+    name: "name",
+    deletedAt: "deletedAt",
+    tags: "tags",
+    providerGroup: "providerGroup",
+  },
+}));
+
+vi.mock("@/lib/utils/timezone", () => ({
+  resolveSystemTimezone: mocks.resolveSystemTimezone,
+}));
+
+vi.mock("@/repository/system-config", () => ({
+  getSystemSettings: vi.fn().mockResolvedValue({ billingModelSource: "redirected" }),
+}));
+
+describe("buildDateCondition - timezone parentheses regression", () => {
+  let whereCapture: unknown[];
+
+  beforeEach(async () => {
+    vi.resetModules();
+    mocks.resolveSystemTimezone.mockResolvedValue("Asia/Shanghai");
+
+    const dbModule = await import("@/drizzle/db");
+    whereCapture = (dbModule as any).__whereCapture;
+    whereCapture.length = 0;
+  });
+
+  it("daily period: INTERVAL '1 day' must be parenthesized before AT TIME ZONE", async () => {
+    const { findDailyLeaderboard } = await import("@/repository/leaderboard");
+    await findDailyLeaderboard();
+
+    expect(whereCapture.length).toBeGreaterThan(0);
+    const sqlStr = sqlToString(whereCapture[0]);
+
+    // After fix: (... + INTERVAL '1 day') AT TIME ZONE
+    // Before fix (bug): ... + INTERVAL '1 day' AT TIME ZONE
+    expect(sqlStr).toContain("INTERVAL '1 day')");
+    expect(sqlStr).not.toMatch(/INTERVAL '1 day' AT TIME ZONE/);
+  });
+
+  it("weekly period: INTERVAL '1 week' must be parenthesized before AT TIME ZONE", async () => {
+    const { findWeeklyLeaderboard } = await import("@/repository/leaderboard");
+    await findWeeklyLeaderboard();
+
+    expect(whereCapture.length).toBeGreaterThan(0);
+    const sqlStr = sqlToString(whereCapture[0]);
+
+    expect(sqlStr).toContain("INTERVAL '1 week')");
+    expect(sqlStr).not.toMatch(/INTERVAL '1 week' AT TIME ZONE/);
+  });
+
+  it("monthly period: INTERVAL '1 month' must be parenthesized before AT TIME ZONE", async () => {
+    const { findMonthlyLeaderboard } = await import("@/repository/leaderboard");
+    await findMonthlyLeaderboard();
+
+    expect(whereCapture.length).toBeGreaterThan(0);
+    const sqlStr = sqlToString(whereCapture[0]);
+
+    expect(sqlStr).toContain("INTERVAL '1 month')");
+    expect(sqlStr).not.toMatch(/INTERVAL '1 month' AT TIME ZONE/);
+  });
+
+  it("custom period: already has correct parentheses and should remain correct", async () => {
+    const { findCustomRangeLeaderboard } = await import("@/repository/leaderboard");
+    await findCustomRangeLeaderboard({ startDate: "2026-01-01", endDate: "2026-01-31" });
+
+    expect(whereCapture.length).toBeGreaterThan(0);
+    const sqlStr = sqlToString(whereCapture[0]);
+
+    // Custom period already had correct parentheses before the fix
+    expect(sqlStr).toContain("INTERVAL '1 day')");
+    expect(sqlStr).not.toMatch(/INTERVAL '1 day' AT TIME ZONE/);
+  });
+});

+ 177 - 0
tests/unit/repository/overview-timezone-parentheses.test.ts

@@ -0,0 +1,177 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * Regression test for: function pg_catalog.timezone(unknown, interval) does not exist
+ *
+ * In getOverviewMetricsWithComparison, `yesterdayStartLocal` and `yesterdayEndLocal`
+ * use arithmetic (`-` / `+`) with INTERVAL expressions that are later passed through
+ * `AT TIME ZONE`. Without parentheses, PG's operator precedence applies AT TIME ZONE
+ * to the INTERVAL sub-expression, which is invalid.
+ *
+ * The fix wraps the arithmetic: `(expr - INTERVAL '1 day')` and `(expr + (...))`.
+ */
+
+function sqlToString(sqlObj: unknown): string {
+  const visited = new Set<unknown>();
+
+  const walk = (node: unknown): string => {
+    if (!node || visited.has(node)) return "";
+    visited.add(node);
+
+    if (typeof node === "string") return node;
+    if (typeof node === "number") return String(node);
+
+    if (typeof node === "object") {
+      const anyNode = node as Record<string, unknown>;
+      if (Array.isArray(anyNode)) {
+        return anyNode.map(walk).join("");
+      }
+
+      if (anyNode.value !== undefined) {
+        if (Array.isArray(anyNode.value)) {
+          return (anyNode.value as unknown[]).map(walk).join("");
+        }
+        return walk(anyNode.value);
+      }
+
+      if (anyNode.queryChunks) {
+        return walk(anyNode.queryChunks);
+      }
+    }
+
+    return "";
+  };
+
+  return walk(sqlObj);
+}
+
+const mocks = vi.hoisted(() => ({
+  resolveSystemTimezone: vi.fn(),
+}));
+
+function createThenableQuery<T>(result: T, whereArgs?: unknown[]) {
+  const query: any = Promise.resolve(result);
+  query.from = vi.fn(() => query);
+  query.innerJoin = vi.fn(() => query);
+  query.leftJoin = vi.fn(() => query);
+  query.orderBy = vi.fn(() => query);
+  query.limit = vi.fn(() => query);
+  query.offset = vi.fn(() => query);
+  query.groupBy = vi.fn(() => query);
+  query.where = vi.fn((arg: unknown) => {
+    whereArgs?.push(arg);
+    return query;
+  });
+  return query;
+}
+
+const allWhereArgs: unknown[][] = [];
+
+vi.mock("@/drizzle/db", () => ({
+  db: {
+    select: vi.fn(() => {
+      const whereArgs: unknown[] = [];
+      allWhereArgs.push(whereArgs);
+      return createThenableQuery(
+        [
+          {
+            requestCount: 10,
+            totalCost: "1.5",
+            avgDuration: "200",
+            errorCount: 1,
+          },
+        ],
+        whereArgs
+      );
+    }),
+  },
+}));
+
+vi.mock("@/drizzle/schema", () => ({
+  messageRequest: {
+    deletedAt: "deletedAt",
+    userId: "userId",
+    costUsd: "costUsd",
+    durationMs: "durationMs",
+    statusCode: "statusCode",
+    createdAt: "createdAt",
+    blockedBy: "blockedBy",
+  },
+}));
+
+vi.mock("@/lib/utils/timezone", () => ({
+  resolveSystemTimezone: mocks.resolveSystemTimezone,
+}));
+
+vi.mock("@/lib/utils/currency", () => ({
+  Decimal: class FakeDecimal {
+    private v: number;
+    constructor(v: number | string) {
+      this.v = Number(v);
+    }
+    toDecimalPlaces() {
+      return this;
+    }
+    toNumber() {
+      return this.v;
+    }
+  },
+  toCostDecimal: (v: unknown) => {
+    if (v === null || v === undefined) return null;
+    return {
+      toDecimalPlaces: () => ({ toNumber: () => Number(v) }),
+    };
+  },
+}));
+
+describe("getOverviewMetricsWithComparison - timezone parentheses regression", () => {
+  beforeEach(() => {
+    vi.resetModules();
+    allWhereArgs.length = 0;
+    mocks.resolveSystemTimezone.mockResolvedValue("Asia/Shanghai");
+  });
+
+  it("yesterdayStartLocal arithmetic must be parenthesized to avoid timezone(unknown, interval)", async () => {
+    const { getOverviewMetricsWithComparison } = await import("@/repository/overview");
+    await getOverviewMetricsWithComparison();
+
+    // getOverviewMetricsWithComparison fires 3 queries via Promise.all
+    // Query 2 (yesterday) uses yesterdayStart and yesterdayEnd
+    expect(allWhereArgs.length).toBe(3);
+
+    const yesterdayWhereSql = sqlToString(allWhereArgs[1][0]);
+
+    // yesterdayStartLocal = (todayStartLocal - INTERVAL '1 day')
+    // Must have closing paren after '1 day' BEFORE AT TIME ZONE
+    expect(yesterdayWhereSql).toContain("INTERVAL '1 day')");
+    expect(yesterdayWhereSql).not.toMatch(/INTERVAL '1 day' AT TIME ZONE/);
+  });
+
+  it("yesterdayEndLocal arithmetic must be parenthesized", async () => {
+    const { getOverviewMetricsWithComparison } = await import("@/repository/overview");
+    await getOverviewMetricsWithComparison();
+
+    expect(allWhereArgs.length).toBe(3);
+
+    const yesterdayWhereSql = sqlToString(allWhereArgs[1][0]);
+
+    // yesterdayEndLocal = (yesterdayStartLocal + (nowLocal - todayStartLocal))
+    // The outer arithmetic must be wrapped in parens
+    // After fix the SQL should have nested parens: ((... - INTERVAL '1 day') + (...))
+    // It should NOT have bare `)) AT TIME ZONE` without the outer arithmetic paren
+    expect(yesterdayWhereSql).toContain(") AT TIME ZONE");
+  });
+
+  it("todayStart already has correct parentheses and should remain correct", async () => {
+    const { getOverviewMetricsWithComparison } = await import("@/repository/overview");
+    await getOverviewMetricsWithComparison();
+
+    expect(allWhereArgs.length).toBe(3);
+
+    const todayWhereSql = sqlToString(allWhereArgs[0][0]);
+
+    // todayStartLocal uses DATE_TRUNC which doesn't need arithmetic parens
+    // tomorrowStart already had parens: ((todayStartLocal + INTERVAL '1 day') AT TIME ZONE tz)
+    expect(todayWhereSql).toContain("INTERVAL '1 day')");
+  });
+});

+ 128 - 0
tests/unit/repository/provider-endpoints-batch-integer-cast.test.ts

@@ -0,0 +1,128 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * Regression test for: operator does not exist: integer = text
+ *
+ * The CTE `VALUES ($1), ($2)` generated by Drizzle infers columns as text.
+ * The LATERAL join then compares integer (table column) to text (CTE column),
+ * which PostgreSQL rejects. The fix adds an explicit `::integer` cast.
+ */
+
+function sqlToString(sqlObj: unknown): string {
+  const visited = new Set<unknown>();
+
+  const walk = (node: unknown): string => {
+    if (!node || visited.has(node)) return "";
+    visited.add(node);
+
+    if (typeof node === "string") return node;
+    if (typeof node === "number") return String(node);
+
+    if (typeof node === "object") {
+      const anyNode = node as Record<string, unknown>;
+      if (Array.isArray(anyNode)) {
+        return anyNode.map(walk).join("");
+      }
+
+      if (anyNode.value !== undefined) {
+        if (Array.isArray(anyNode.value)) {
+          return (anyNode.value as unknown[]).map(walk).join("");
+        }
+        return walk(anyNode.value);
+      }
+
+      if (anyNode.queryChunks) {
+        return walk(anyNode.queryChunks);
+      }
+    }
+
+    return "";
+  };
+
+  return walk(sqlObj);
+}
+
+let capturedExecuteQuery: unknown = null;
+
+vi.mock("@/drizzle/db", () => ({
+  db: {
+    execute: vi.fn((query: unknown) => {
+      capturedExecuteQuery = query;
+      return Promise.resolve([]);
+    }),
+  },
+}));
+
+vi.mock("@/drizzle/schema", () => ({
+  providerEndpointProbeLogs: {},
+  providerEndpoints: {
+    vendorId: "vendorId",
+    providerType: "providerType",
+    isEnabled: "isEnabled",
+    lastProbeOk: "lastProbeOk",
+    deletedAt: "deletedAt",
+  },
+}));
+
+vi.mock("@/lib/logger", () => ({
+  logger: {
+    trace: vi.fn(),
+    debug: vi.fn(),
+    info: vi.fn(),
+    warn: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+
+describe("findProviderEndpointProbeLogsBatch - integer cast regression", () => {
+  beforeEach(() => {
+    capturedExecuteQuery = null;
+  });
+
+  it("CTE VALUES must cast endpoint IDs to ::integer to avoid 'integer = text' error", async () => {
+    const { findProviderEndpointProbeLogsBatch } = await import(
+      "@/repository/provider-endpoints-batch"
+    );
+
+    await findProviderEndpointProbeLogsBatch({
+      endpointIds: [10, 20, 30],
+      limitPerEndpoint: 5,
+    });
+
+    expect(capturedExecuteQuery).toBeTruthy();
+    const sqlStr = sqlToString(capturedExecuteQuery);
+
+    // The VALUES clause must contain ::integer casts to prevent PG type mismatch
+    expect(sqlStr).toContain("::integer");
+  });
+
+  it("single endpoint ID also gets ::integer cast", async () => {
+    const { findProviderEndpointProbeLogsBatch } = await import(
+      "@/repository/provider-endpoints-batch"
+    );
+
+    await findProviderEndpointProbeLogsBatch({
+      endpointIds: [42],
+      limitPerEndpoint: 1,
+    });
+
+    expect(capturedExecuteQuery).toBeTruthy();
+    const sqlStr = sqlToString(capturedExecuteQuery);
+
+    expect(sqlStr).toContain("::integer");
+  });
+
+  it("empty endpointIds should not execute any query", async () => {
+    const { findProviderEndpointProbeLogsBatch } = await import(
+      "@/repository/provider-endpoints-batch"
+    );
+
+    const result = await findProviderEndpointProbeLogsBatch({
+      endpointIds: [],
+      limitPerEndpoint: 5,
+    });
+
+    expect(capturedExecuteQuery).toBeNull();
+    expect(result.size).toBe(0);
+  });
+});