fix(ui): guard null organization fields in search and inject test auth state

The organizations search matcher now tolerates a null organization_alias or organization_id instead of throwing mid-render, covered by a test where the server hands back a row without an alias. The panel test injects the auth state and the fake server list per render through the mocks instead of reassigning a module-level variable, so cases are no longer order-sensitive

Claude-Session: https://claude.ai/code/session_018yW93iDaEMhoQUXcYjus7D
This commit is contained in:
ryan-crabbe-berri 2026-09-03 12:09:04 -07:00
parent 6a52d9f74c
commit 16d639982b
2 changed files with 39 additions and 20 deletions

View file

@ -18,12 +18,13 @@ vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({
type AuthState = { accessToken: string | null; userId: string | null; userRole: string | null };
const SIGNED_OUT: AuthState = { accessToken: null, userId: null, userRole: null };
const SIGNED_IN: AuthState = { accessToken: "sk-test", userId: "user-1", userRole: "Admin" };
let authState: AuthState = SIGNED_OUT;
const mockUseAuthorized = vi.fn<() => AuthState>(() => SIGNED_OUT);
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => authState,
default: () => mockUseAuthorized(),
}));
const makeOrganization = (organization_id: string, organization_alias: string): Organization => ({
type ServerOrganization = Omit<Organization, "organization_alias"> & { organization_alias: string | null };
const makeOrganization = (organization_id: string, organization_alias: string | null): ServerOrganization => ({
organization_id,
organization_alias,
budget_id: "",
@ -42,24 +43,28 @@ const makeOrganization = (organization_id: string, organization_alias: string):
});
const ALPHA_ORG_ID = "ff9eb074-3f07-4e13-91fb-5337fb10d760";
const BETA_ORG_ID = "0a1b2c3d-1111-4222-8333-444455556666";
const SERVER_ORGANIZATIONS: Organization[] = [
const NO_ALIAS_ORG_ID = "7b6a5f4e-0000-4aaa-8bbb-cccddd111222";
const SERVER_ORGANIZATIONS: readonly ServerOrganization[] = [
makeOrganization(ALPHA_ORG_ID, "Alpha Org"),
makeOrganization(BETA_ORG_ID, "Beta Org"),
makeOrganization("9f8e7d6c-9999-4888-8777-666655554444", "Gamma Org"),
];
const matchesServerFilters = (organization: Organization, orgId: string | null, orgAlias: string | null) => {
const matchesServerFilters = (organization: ServerOrganization, orgId: string | null, orgAlias: string | null) => {
const idMatches = !orgId || organization.organization_id === orgId;
const aliasMatches = !orgAlias || organization.organization_alias.toLowerCase().includes(orgAlias.toLowerCase());
const aliasMatches =
!orgAlias || (organization.organization_alias?.toLowerCase().includes(orgAlias.toLowerCase()) ?? false);
return idMatches && aliasMatches;
};
const fakeOrganizationList =
(organizations: readonly ServerOrganization[]) =>
(_accessToken: string, orgId: string | null = null, orgAlias: string | null = null) =>
Promise.resolve(organizations.filter((organization) => matchesServerFilters(organization, orgId, orgAlias)));
const mockOrganizationListCall = vi.fn(fakeOrganizationList(SERVER_ORGANIZATIONS));
vi.mock("@/components/networking", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/components/networking")>();
return {
...actual,
organizationListCall: (_accessToken: string, orgId: string | null = null, orgAlias: string | null = null) =>
Promise.resolve(
SERVER_ORGANIZATIONS.filter((organization) => matchesServerFilters(organization, orgId, orgAlias)),
),
organizationListCall: (...args: Parameters<typeof mockOrganizationListCall>) => mockOrganizationListCall(...args),
modelAvailableCall: () => Promise.resolve({ data: [] }),
};
});
@ -90,9 +95,18 @@ const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
interface RenderPanelOptions {
premiumUser?: boolean;
searchParams?: string;
auth?: AuthState;
organizations?: readonly ServerOrganization[];
}
const renderPanel = ({ premiumUser = true, searchParams = "" }: RenderPanelOptions = {}) => {
const renderPanel = ({
premiumUser = true,
searchParams = "",
auth = SIGNED_OUT,
organizations = SERVER_ORGANIZATIONS,
}: RenderPanelOptions = {}) => {
mockUseAuthorized.mockReturnValue(auth);
mockOrganizationListCall.mockImplementation(fakeOrganizationList(organizations));
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
@ -122,7 +136,6 @@ const expectQueryString = (queryString: string) =>
waitFor(() => expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString })));
beforeEach(() => {
authState = SIGNED_OUT;
capturedTableProps = null;
mockOrgInfoView.mockClear();
onUrlUpdate.mockClear();
@ -213,10 +226,9 @@ describe("OrganizationsPanel - org detail deep link (?org=)", () => {
});
describe("OrganizationsPanel - search by organization name or ID", () => {
const renderSignedInPanel = async () => {
authState = SIGNED_IN;
renderPanel();
await waitFor(() => expect(capturedTableProps?.organizations).toHaveLength(SERVER_ORGANIZATIONS.length));
const renderSignedInPanel = async (organizations: readonly ServerOrganization[] = SERVER_ORGANIZATIONS) => {
renderPanel({ auth: SIGNED_IN, organizations });
await waitFor(() => expect(capturedTableProps?.organizations).toHaveLength(organizations.length));
};
const search = (value: string) =>
@ -245,4 +257,12 @@ describe("OrganizationsPanel - search by organization name or ID", () => {
await waitFor(() => expect(capturedTableProps?.organizations).toEqual([]));
expect(capturedTableProps?.searchActive).toBe(true);
});
it("still finds a row by id when the server hands back a null alias", async () => {
await renderSignedInPanel([...SERVER_ORGANIZATIONS, makeOrganization(NO_ALIAS_ORG_ID, null)]);
search(NO_ALIAS_ORG_ID);
await waitFor(() => expect(visibleOrganizationIds()).toEqual([NO_ALIAS_ORG_ID]));
});
});

View file

@ -21,10 +21,9 @@ interface OrganizationsPanelProps {
const matchesOrganizationSearch = (organization: Organization, search: string): boolean => {
const needle = search.trim().toLowerCase();
return (
needle === "" ||
organization.organization_alias.toLowerCase().includes(needle) ||
organization.organization_id.toLowerCase().includes(needle)
if (needle === "") return true;
return [organization.organization_alias, organization.organization_id].some((field) =>
field?.toLowerCase().includes(needle),
);
};