Przeglądaj źródła

fix: address bugbot review comments for timezone and rate-limit

- drizzle/0059: add IF NOT EXISTS to prevent migration conflict
- date-input: detect timezone designator (Z/+-HH:MM) in ISO strings
  to avoid double conversion by fromZonedTime
- lease.ts: add boundary protection for calculateLeaseSlice
  (clamp percent to [0,1], ensure non-negative capUsd and result)
- placeholders.ts: wrap formatLocalTimestamp in try-catch to handle
  invalid IANA timezone gracefully
- availability-view: pass locale to formatBucketTime for i18n month names
- lease-service.test: use vi.hoisted() to fix TDZ issue with mock

Co-Authored-By: Claude Opus 4.5 <[email protected]>
ding113 2 tygodni temu
rodzic
commit
cc59f08442

+ 1 - 1
drizzle/0059_safe_xorn.sql

@@ -1,2 +1,2 @@
 ALTER TABLE "keys" ALTER COLUMN "expires_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint
-ALTER TABLE "system_settings" ADD COLUMN "timezone" varchar(64);
+ALTER TABLE "system_settings" ADD COLUMN IF NOT EXISTS "timezone" varchar(64);

+ 14 - 7
src/app/[locale]/dashboard/availability/_components/availability-view.tsx

@@ -1,8 +1,7 @@
 "use client";
 
-import { formatInTimeZone } from "date-fns-tz";
 import { Activity, CheckCircle2, HelpCircle, RefreshCw, XCircle } from "lucide-react";
-import { useTimeZone, useTranslations } from "next-intl";
+import { useLocale, useTimeZone, useTranslations } from "next-intl";
 import { useCallback, useEffect, useMemo, useState } from "react";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
@@ -21,6 +20,7 @@ import type {
   ProviderAvailabilitySummary,
   TimeBucketMetrics,
 } from "@/lib/availability";
+import { formatDate } from "@/lib/utils/date-format";
 import { cn } from "@/lib/utils";
 import { EndpointProbeHistory } from "./endpoint-probe-history";
 
@@ -64,19 +64,24 @@ function getAvailabilityColor(score: number, hasData: boolean): string {
 /**
  * Format bucket time for display in tooltip
  */
-function formatBucketTime(isoString: string, bucketSizeMinutes: number, timeZone?: string): string {
+function formatBucketTime(
+  isoString: string,
+  bucketSizeMinutes: number,
+  locale: string,
+  timeZone?: string
+): string {
   const date = new Date(isoString);
   const tz = timeZone ?? "UTC";
   if (bucketSizeMinutes >= 1440) {
-    return formatInTimeZone(date, tz, "MMM d");
+    return formatDate(date, "MMM d", locale, tz);
   }
   if (bucketSizeMinutes >= 60) {
-    return formatInTimeZone(date, tz, "MMM d HH:mm");
+    return formatDate(date, "MMM d HH:mm", locale, tz);
   }
   if (bucketSizeMinutes < 1) {
-    return formatInTimeZone(date, tz, "HH:mm:ss");
+    return formatDate(date, "HH:mm:ss", locale, tz);
   }
-  return formatInTimeZone(date, tz, "HH:mm");
+  return formatDate(date, "HH:mm", locale, tz);
 }
 
 /**
@@ -97,6 +102,7 @@ function _formatBucketSizeDisplay(minutes: number): string {
 export function AvailabilityView() {
   const t = useTranslations("dashboard.availability");
   const timeZone = useTimeZone() ?? "UTC";
+  const locale = useLocale();
   const [data, setData] = useState<AvailabilityQueryResult | null>(null);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
@@ -448,6 +454,7 @@ export function AvailabilityView() {
                                     {formatBucketTime(
                                       bucketStart,
                                       data?.bucketSizeMinutes ?? 5,
+                                      locale,
                                       timeZone
                                     )}
                                   </div>

+ 9 - 7
src/lib/rate-limit/lease.ts

@@ -101,23 +101,25 @@ export interface CalculateLeaseSliceParams {
 export function calculateLeaseSlice(params: CalculateLeaseSliceParams): number {
   const { limitAmount, currentUsage, percent, capUsd } = params;
 
-  const remaining = limitAmount - currentUsage;
-  if (remaining <= 0) {
+  const remaining = Math.max(0, limitAmount - currentUsage);
+  if (remaining === 0) {
     return 0;
   }
 
-  let slice = limitAmount * percent;
+  // Clamp percent to valid range [0, 1]
+  const safePercent = Math.min(1, Math.max(0, percent));
+  let slice = limitAmount * safePercent;
 
   // Cap by remaining budget
   slice = Math.min(slice, remaining);
 
-  // Cap by USD limit if provided
+  // Cap by USD limit if provided (ensure non-negative)
   if (capUsd !== undefined) {
-    slice = Math.min(slice, capUsd);
+    slice = Math.min(slice, Math.max(0, capUsd));
   }
 
-  // Round to 4 decimal places
-  return Math.round(slice * 10000) / 10000;
+  // Round to 4 decimal places, ensure non-negative
+  return Math.max(0, Math.round(slice * 10000) / 10000);
 }
 
 /**

+ 12 - 1
src/lib/utils/date-input.ts

@@ -40,7 +40,18 @@ export function parseDateInputAsTimezone(input: string, timezone: string): Date
     return fromZonedTime(localDateTime, timezone);
   }
 
-  // ISO datetime or other formats: parse and treat as timezone local time
+  // Check if input has timezone designator (Z or +-HH:MM offset)
+  // If so, parse directly as it already represents an absolute instant
+  const hasTimezoneDesignator = /([zZ]|[+-]\d{2}:?\d{2})$/.test(input);
+  if (hasTimezoneDesignator) {
+    const directDate = new Date(input);
+    if (Number.isNaN(directDate.getTime())) {
+      throw new Error(`Invalid date input: ${input}`);
+    }
+    return directDate;
+  }
+
+  // ISO datetime without timezone: parse and treat as timezone local time
   const localDate = new Date(input);
 
   if (Number.isNaN(localDate.getTime())) {

+ 24 - 10
src/lib/webhook/templates/placeholders.ts

@@ -131,16 +131,30 @@ function safeJsonStringify(value: unknown): string {
 }
 
 function formatLocalTimestamp(date: Date, timezone?: string): string {
-  return date.toLocaleString("zh-CN", {
-    timeZone: timezone || "UTC",
-    year: "numeric",
-    month: "2-digit",
-    day: "2-digit",
-    hour: "2-digit",
-    minute: "2-digit",
-    second: "2-digit",
-    hour12: false,
-  });
+  try {
+    return date.toLocaleString("zh-CN", {
+      timeZone: timezone || "UTC",
+      year: "numeric",
+      month: "2-digit",
+      day: "2-digit",
+      hour: "2-digit",
+      minute: "2-digit",
+      second: "2-digit",
+      hour12: false,
+    });
+  } catch {
+    // Fallback to UTC if timezone is invalid
+    return date.toLocaleString("zh-CN", {
+      timeZone: "UTC",
+      year: "numeric",
+      month: "2-digit",
+      day: "2-digit",
+      hour: "2-digit",
+      minute: "2-digit",
+      second: "2-digit",
+      hour12: false,
+    });
+  }
 }
 
 function renderMessageSections(message: StructuredMessage): string {

+ 17 - 17
tests/unit/lib/rate-limit/lease-service.test.ts

@@ -7,6 +7,23 @@
 
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 
+// Mock Redis client using vi.hoisted to avoid TDZ issues
+const mockRedis = vi.hoisted(() => ({
+  status: "ready",
+  get: vi.fn(),
+  set: vi.fn(),
+  setex: vi.fn(),
+  eval: vi.fn(),
+  exists: vi.fn(),
+  del: vi.fn(),
+  pipeline: vi.fn(() => ({
+    get: vi.fn().mockReturnThis(),
+    set: vi.fn().mockReturnThis(),
+    expire: vi.fn().mockReturnThis(),
+    exec: vi.fn().mockResolvedValue([]),
+  })),
+}));
+
 // Mock dependencies
 vi.mock("@/lib/config", () => ({
   getEnvConfig: () => ({ TZ: "Asia/Shanghai" }),
@@ -38,23 +55,6 @@ vi.mock("@/lib/config/system-settings-cache", () => ({
   getCachedSystemSettings: vi.fn(),
 }));
 
-// Mock Redis client
-const mockRedis = {
-  status: "ready",
-  get: vi.fn(),
-  set: vi.fn(),
-  setex: vi.fn(),
-  eval: vi.fn(),
-  exists: vi.fn(),
-  del: vi.fn(),
-  pipeline: vi.fn(() => ({
-    get: vi.fn().mockReturnThis(),
-    set: vi.fn().mockReturnThis(),
-    expire: vi.fn().mockReturnThis(),
-    exec: vi.fn().mockResolvedValue([]),
-  })),
-};
-
 describe("LeaseService", () => {
   const nowMs = 1706400000000; // 2024-01-28 00:00:00 UTC