Просмотр исходного кода

fix: address follow-up PR review issues

ding113 3 недель назад
Родитель
Сommit
e3746bc124

+ 104 - 0
src/app/[locale]/my-usage/_components/usage-logs-table.test.tsx

@@ -0,0 +1,104 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import type { ReactNode } from "react";
+import { describe, expect, test, vi } from "vitest";
+import type { MyUsageLogEntry } from "@/actions/my-usage";
+
+vi.mock("next-intl", () => ({
+  useTranslations: () => (key: string) => key,
+  useTimeZone: () => "UTC",
+}));
+
+vi.mock("sonner", () => ({
+  toast: {
+    success: vi.fn(),
+  },
+}));
+
+vi.mock("@/components/customs/model-vendor-icon", () => ({
+  ModelVendorIcon: () => <span data-testid="model-vendor-icon" />,
+}));
+
+vi.mock("@/components/ui/tooltip", () => ({
+  TooltipProvider: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+  Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+  TooltipTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+  TooltipContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
+}));
+
+vi.mock("@/components/ui/button", () => ({
+  Button: ({ children, ...props }: React.ComponentProps<"button">) => (
+    <button type="button" {...props}>
+      {children}
+    </button>
+  ),
+}));
+
+vi.mock("@/components/ui/badge", () => ({
+  Badge: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
+}));
+
+vi.mock("@/components/ui/skeleton", () => ({
+  Skeleton: ({ className }: { className?: string }) => <div className={className} />,
+}));
+
+vi.mock("@/hooks/use-virtualized-infinite-list", () => ({
+  useVirtualizedInfiniteList: () => ({
+    parentRef: { current: null },
+    rowVirtualizer: {
+      getTotalSize: () => 80,
+    },
+    virtualItems: [
+      {
+        index: 0,
+        start: 0,
+        size: 80,
+      },
+    ],
+    showScrollToTop: false,
+    handleScroll: vi.fn(),
+    scrollToTop: vi.fn(),
+    resetScrollPosition: vi.fn(),
+  }),
+}));
+
+import { UsageLogsTable } from "./usage-logs-table";
+
+function makeLog(overrides: Partial<MyUsageLogEntry> = {}): MyUsageLogEntry {
+  return {
+    id: 1,
+    createdAt: new Date("2026-03-21T00:00:00Z"),
+    model: "gpt-4.1",
+    billingModel: "gpt-4.1",
+    anthropicEffort: null,
+    modelRedirect: null,
+    inputTokens: 10,
+    outputTokens: 20,
+    cost: 0.01,
+    statusCode: 200,
+    duration: 50,
+    endpoint: "/v1/messages",
+    cacheCreationInputTokens: 0,
+    cacheReadInputTokens: 0,
+    cacheCreation5mInputTokens: 0,
+    cacheCreation1hInputTokens: 0,
+    cacheTtlApplied: null,
+    ...overrides,
+  };
+}
+
+describe("my-usage usage logs table", () => {
+  test("shows a non-blocking error message even when stale logs are still visible", () => {
+    const html = renderToStaticMarkup(
+      <UsageLogsTable
+        logs={[makeLog()]}
+        hasNextPage={false}
+        isFetchingNextPage={false}
+        errorMessage="load failed"
+      />
+    );
+
+    expect(html).toContain("load failed");
+    expect(html).toContain("logs.table.loadedCount");
+    expect(html).toContain("gpt-4.1");
+  });
+});

+ 2 - 0
src/app/[locale]/my-usage/_components/usage-logs-table.tsx

@@ -135,6 +135,8 @@ export function UsageLogsTable({
 
   return (
     <div className="space-y-4">
+      {errorMessage ? <div className="px-1 text-xs text-destructive">{errorMessage}</div> : null}
+
       <div className="flex items-center justify-between text-xs text-muted-foreground/70 px-1 pt-1">
         <span>{tDashboard("logs.table.loadedCount", { count: logs.length })}</span>
         {isFetchingNextPage ? (

+ 2 - 2
src/app/api/actions/[...route]/route.ts

@@ -1057,7 +1057,7 @@ const { route: getMyUsageLogsBatchRoute, handler: getMyUsageLogsBatchHandler } =
       cursor: z
         .object({
           createdAt: z.string(),
-          id: z.number(),
+          id: z.number().int(),
         })
         .optional(),
       limit: z.number().int().positive().max(100).default(20).optional(),
@@ -1086,7 +1086,7 @@ const { route: getMyUsageLogsBatchRoute, handler: getMyUsageLogsBatchHandler } =
       nextCursor: z
         .object({
           createdAt: z.string(),
-          id: z.number(),
+          id: z.number().int(),
         })
         .nullable(),
       hasMore: z.boolean(),

+ 50 - 1
tests/api/api-actions-integrity.test.ts

@@ -14,7 +14,36 @@ import { beforeAll, describe, expect, test } from "vitest";
 import { callActionsRoute } from "../test-utils";
 
 type OpenAPIDocument = {
-  paths: Record<string, Record<string, { summary?: string; tags?: string[] }>>;
+  paths: Record<
+    string,
+    Record<
+      string,
+      {
+        summary?: string;
+        tags?: string[];
+        requestBody?: {
+          content?: {
+            "application/json"?: {
+              schema?: {
+                properties?: Record<string, { type?: string; format?: string }>;
+              };
+            };
+          };
+        };
+        responses?: {
+          [status: string]: {
+            content?: {
+              "application/json"?: {
+                schema?: {
+                  properties?: Record<string, unknown>;
+                };
+              };
+            };
+          };
+        };
+      }
+    >
+  >;
 };
 
 describe("OpenAPI 端点完整性检查", () => {
@@ -129,6 +158,26 @@ describe("OpenAPI 端点完整性检查", () => {
     }
   });
 
+  test("我的用量批量日志端点应将 cursor id 声明为整数", () => {
+    const operation = openApiDoc.paths["/api/actions/my-usage/getMyUsageLogsBatch"]?.post;
+    const requestCursorId = operation?.requestBody?.content?.["application/json"]?.schema
+      ?.properties?.cursor as
+      | { properties?: Record<string, { type?: string; format?: string }> }
+      | undefined;
+    const responseData = operation?.responses?.["200"]?.content?.["application/json"]?.schema
+      ?.properties?.data as
+      | {
+          properties?: Record<
+            string,
+            { properties?: Record<string, { type?: string; format?: string }> }
+          >;
+        }
+      | undefined;
+
+    expect(requestCursorId?.properties?.id?.type).toBe("integer");
+    expect(responseData?.properties?.nextCursor?.properties?.id?.type).toBe("integer");
+  });
+
   test("概览模块的所有端点应该被注册", () => {
     const expectedPaths = ["/api/actions/overview/getOverviewData"];