This commit is contained in:
Trevor Mack 2026-09-27 17:54:48 -04:00 • committed by GitHub
commit 673c46b2b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 264 additions and 13 deletions

View file

@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useUserInfo } from "./useUserInfo";
import { userGetInfoV2 } from "@/components/networking";
import type { UserInfoV2Response } from "@/components/networking";
vi.mock("@/components/networking", () => ({
userGetInfoV2: vi.fn(),
}));
vi.mock("../common/queryKeysFactory", () => ({
createQueryKeys: vi.fn((resource: string) => ({
all: [resource],
lists: () => [resource, "list"],
list: (params?: any) => [resource, "list", { params }],
details: () => [resource, "detail"],
detail: (uid: string) => [resource, "detail", uid],
})),
}));
const mockUseAuthorized = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => mockUseAuthorized(),
}));
const mockSelectedUser: UserInfoV2Response = {
user_id: "selected-user-id",
user_email: "selected@example.com",
user_alias: "Selected User",
user_role: "internal_user",
spend: 42.0,
max_budget: 600.0,
models: [],
budget_duration: "30d",
budget_reset_at: null,
metadata: null,
created_at: null,
updated_at: null,
sso_user_id: null,
teams: [],
};
describe("useUserInfo", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userId: "admin-user-id",
userRole: "Admin",
});
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("fetches the specified user's info by user_id (not the caller's)", async () => {
(userGetInfoV2 as any).mockResolvedValue(mockSelectedUser);
const { result } = renderHook(() => useUserInfo("selected-user-id"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockSelectedUser);
// The whole point of the fix: the target user_id is passed through.
expect(userGetInfoV2).toHaveBeenCalledWith("test-access-token", "selected-user-id");
expect(userGetInfoV2).toHaveBeenCalledTimes(1);
});
it("does not execute when userId is null (global view has no single user)", async () => {
const { result } = renderHook(() => useUserInfo(null), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(result.current.isFetched).toBe(false);
expect(userGetInfoV2).not.toHaveBeenCalled();
});
it("does not execute when accessToken is missing", async () => {
mockUseAuthorized.mockReturnValue({ accessToken: null, userId: "admin-user-id", userRole: "Admin" });
const { result } = renderHook(() => useUserInfo("selected-user-id"), { wrapper });
expect(result.current.isFetched).toBe(false);
expect(userGetInfoV2).not.toHaveBeenCalled();
});
it("surfaces the error when the lookup fails", async () => {
const err = new Error("forbidden");
(userGetInfoV2 as any).mockRejectedValue(err);
const { result } = renderHook(() => useUserInfo("selected-user-id"), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toEqual(err);
});
});

View file

@ -0,0 +1,18 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { UserInfoV2Response, userGetInfoV2 } from "@/components/networking";
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
const userKeys = createQueryKeys("users");
/** Fetch a specific user's info from `/v2/user/info?user_id=`; disabled when userId is null. */
export const useUserInfo = (userId: string | null): UseQueryResult<UserInfoV2Response> => {
const { accessToken } = useAuthorized();
return useQuery<UserInfoV2Response>({
queryKey: userKeys.detail(userId ?? ""),
queryFn: async () => {
return await userGetInfoV2(accessToken!, userId!);
},
enabled: Boolean(accessToken && userId),
});
};

View file

@ -4,6 +4,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
import { useUserInfo } from "@/app/(dashboard)/hooks/users/useUserInfo";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@ -40,7 +41,20 @@ vi.mock("@/components/activity_metrics", () => ({
}));
vi.mock("@/components/view_user_spend", () => ({
default: () => <div>View User Spend</div>,
default: ({ userMaxBudget, budgetDuration, budgetLoading }: any) => (
<div
data-testid="view-user-spend"
data-max-budget={userMaxBudget === null || userMaxBudget === undefined ? "null" : String(userMaxBudget)}
data-budget-duration={budgetDuration ?? "null"}
data-budget-loading={budgetLoading ? "true" : "false"}
>
View User Spend
</div>
),
}));
vi.mock("@/app/(dashboard)/hooks/users/useUserInfo", () => ({
useUserInfo: vi.fn(),
}));
vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
@ -166,6 +180,7 @@ describe("UsagePage", () => {
const mockUseAuthorized = vi.mocked(useAuthorized);
const mockUseCurrentUser = vi.mocked(useCurrentUser);
const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers);
const mockUseUserInfo = vi.mocked(useUserInfo);
const mockSpendData = {
results: [
@ -411,6 +426,11 @@ describe("UsagePage", () => {
isLoading: false,
error: null,
} as any);
mockUseUserInfo.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
} as any);
});
it("should render and fetch usage data on mount", async () => {
@ -1350,19 +1370,53 @@ describe("UsagePage", () => {
});
});
describe("tab navigation in global view", () => {
it("should render all expected tabs", async () => {
describe("selected-user budget wiring", () => {
const selectAlice = async () => {
const user = userEvent.setup();
await user.click(screen.getByPlaceholderText("Search users by email…"));
await user.click(await screen.findByText("Alice (user-001)"));
};
it("feeds the tile the selected user's budget and duration, not the admin's", async () => {
mockUseCurrentUser.mockReturnValue({
data: { user_id: "user-123", max_budget: 3000 },
isLoading: false,
error: null,
} as any);
mockUseUserInfo.mockReturnValue({
data: { user_id: "user-001", max_budget: 600, budget_duration: "30d" },
isLoading: false,
isError: false,
} as any);
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled());
await selectAlice();
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
expect(screen.getByTestId("view-user-spend")).toHaveAttribute("data-max-budget", "600");
});
expect(screen.getByTestId("view-user-spend")).toHaveAttribute("data-budget-duration", "30d");
expect(mockUseUserInfo).toHaveBeenCalledWith("user-001");
});
expect(screen.getByText("Cost")).toBeInTheDocument();
expect(screen.getByText("Model Activity")).toBeInTheDocument();
expect(screen.getByText("Key Activity")).toBeInTheDocument();
expect(screen.getByText("MCP Server Activity")).toBeInTheDocument();
expect(screen.getByText("Endpoint Activity")).toBeInTheDocument();
it("marks the tile loading (not unlimited) while the selected user's info is unresolved", async () => {
mockUseCurrentUser.mockReturnValue({
data: { user_id: "user-123", max_budget: 3000 },
isLoading: false,
error: null,
} as any);
mockUseUserInfo.mockReturnValue({ data: undefined, isLoading: true, isError: false } as any);
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled());
await selectAlice();
await waitFor(() => {
expect(screen.getByTestId("view-user-spend")).toHaveAttribute("data-budget-loading", "true");
});
});
});
});

View file

@ -23,6 +23,7 @@ import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useUserInfo } from "@/app/(dashboard)/hooks/users/useUserInfo";
import { hasCapability } from "@/utils/capabilities";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { all_admin_roles, internalUserRoles } from "@/utils/roles";
@ -141,6 +142,16 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
// For non-admins or "my-usage" view, always pass their own user_id
const effectiveUserId = usageView === "my-usage" || !isAdmin ? userID || null : selectedUserId;
const {
data: selectedUserInfo,
isLoading: isSelectedUserLoading,
isError: isSelectedUserError,
} = useUserInfo(effectiveUserId);
const isSelectedUserResolved = effectiveUserId === null || (!isSelectedUserLoading && !isSelectedUserError);
const effectiveMaxBudget =
effectiveUserId !== null ? selectedUserInfo?.max_budget ?? null : currentUser?.max_budget ?? null;
const effectiveBudgetDuration = effectiveUserId !== null ? selectedUserInfo?.budget_duration ?? null : null;
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
@ -550,7 +561,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<ViewUserSpend
userSpend={totalSpend}
selectedTeam={null}
userMaxBudget={currentUser?.max_budget || null}
userMaxBudget={effectiveMaxBudget}
budgetDuration={effectiveBudgetDuration}
budgetLoading={!isSelectedUserResolved}
/>
</div>

View file

@ -0,0 +1,40 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";
import ViewUserSpend from "./view_user_spend";
vi.mock("./networking", () => ({
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "tok", userRole: "Admin", userId: "admin-id" }),
}));
describe("ViewUserSpend — Max Budget tile", () => {
beforeEach(() => vi.clearAllMocks());
it("shows a finite cap with its reset period when budgetDuration is set", () => {
render(<ViewUserSpend userSpend={10} userMaxBudget={600} selectedTeam={null} budgetDuration="30d" />);
expect(screen.getByText(/\$600\.0000 limit/)).toBeInTheDocument();
expect(screen.getByText(/over monthly/)).toBeInTheDocument();
});
it('shows "No limit" for an unlimited (null) budget and no period', () => {
render(<ViewUserSpend userSpend={10} userMaxBudget={null} selectedTeam={null} budgetDuration="30d" />);
expect(screen.getByText("No limit")).toBeInTheDocument();
expect(screen.queryByText(/over/)).not.toBeInTheDocument();
});
it("shows a finite cap with no period when budgetDuration is absent", () => {
render(<ViewUserSpend userSpend={10} userMaxBudget={300} selectedTeam={null} />);
expect(screen.getByText(/\$300\.0000 limit/)).toBeInTheDocument();
expect(screen.queryByText(/over/)).not.toBeInTheDocument();
});
it('does not render "No limit" while the budget is loading', () => {
render(<ViewUserSpend userSpend={10} userMaxBudget={null} selectedTeam={null} budgetLoading={true} />);
expect(screen.queryByText("No limit")).not.toBeInTheDocument();
expect(screen.getByText("—")).toBeInTheDocument();
});
});

View file

@ -2,6 +2,7 @@
import React, { useEffect, useState } from "react";
import { modelAvailableCall } from "./networking";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
// Define the props type
@ -9,8 +10,16 @@ interface ViewUserSpendProps {
userSpend: number | null;
userMaxBudget: number | null;
selectedTeam: any | null;
budgetDuration?: string | null;
budgetLoading?: boolean;
}
const ViewUserSpend: React.FC<ViewUserSpendProps> = ({ userSpend, userMaxBudget, selectedTeam }) => {
const ViewUserSpend: React.FC<ViewUserSpendProps> = ({
userSpend,
userMaxBudget,
selectedTeam,
budgetDuration = null,
budgetLoading = false,
}) => {
const { accessToken, userRole, userId: userID } = useAuthorized();
let [spend, setSpend] = useState(userSpend !== null ? userSpend : 0.0);
const [maxBudget, setMaxBudget] = useState(
@ -107,7 +116,18 @@ const ViewUserSpend: React.FC<ViewUserSpendProps> = ({ userSpend, userMaxBudget,
modelsToDisplay = userModels;
}
const displayMaxBudget = maxBudget !== null ? `$${formatNumberWithCommas(Number(maxBudget), 4)} limit` : "No limit";
let displayMaxBudget: string;
if (budgetLoading) {
displayMaxBudget = "—";
} else if (maxBudget !== null) {
displayMaxBudget = `$${formatNumberWithCommas(Number(maxBudget), 4)} limit`;
} else {
displayMaxBudget = "No limit";
}
const durationLabel = maxBudget !== null && budgetDuration ? getBudgetDurationLabel(budgetDuration) : null;
const budgetPeriodSuffix =
!budgetLoading && durationLabel && durationLabel !== "Not set" ? ` over ${durationLabel}` : "";
const roundedSpend = spend !== undefined ? formatNumberWithCommas(spend, 4) : null;
@ -120,7 +140,12 @@ const ViewUserSpend: React.FC<ViewUserSpendProps> = ({ userSpend, userMaxBudget,
</div>
<div>
<p className="text-sm text-muted-foreground">Max Budget</p>
<p className="text-2xl font-semibold text-foreground">{displayMaxBudget}</p>
<p className="text-2xl font-semibold text-foreground">
{displayMaxBudget}
{budgetPeriodSuffix && (
<span className="text-sm font-normal text-muted-foreground">{budgetPeriodSuffix}</span>
)}
</p>
</div>
</div>
{/* <div className="ml-auto">