From df950ec11c6b8a554ef4d850596abd8d09882301 Mon Sep 17 00:00:00 2001 From: hieptl Date: Mon, 15 Dec 2025 23:19:57 +0700 Subject: [PATCH] refactor: update getme according to actual expected endpoint --- frontend/__tests__/routes/manage-org.test.tsx | 46 +++++++++++-------- .../manage-organization-members.test.tsx | 44 ++++++++++++------ .../organization-service.api.ts | 3 +- frontend/src/hooks/query/use-me.ts | 20 +++++++- frontend/src/mocks/org-handlers.ts | 41 +++++++++-------- frontend/src/routes/manage-org.tsx | 10 +--- .../routes/manage-organization-members.tsx | 11 +---- frontend/src/types/org.ts | 13 ++++++ frontend/src/utils/query-client-getters.ts | 40 ++++++++++++++++ frontend/test-utils.tsx | 35 ++++++++++++++ 10 files changed, 191 insertions(+), 72 deletions(-) diff --git a/frontend/__tests__/routes/manage-org.test.tsx b/frontend/__tests__/routes/manage-org.test.tsx index 6aa3ffb19b..3db7d863d1 100644 --- a/frontend/__tests__/routes/manage-org.test.tsx +++ b/frontend/__tests__/routes/manage-org.test.tsx @@ -3,13 +3,14 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { createRoutesStub } from "react-router"; -import { selectOrganization } from "test-utils"; +import { selectOrganization, createGetMeResponse } from "test-utils"; import ManageOrg from "#/routes/manage-org"; import { organizationService } from "#/api/organization-service/organization-service.api"; import SettingsScreen, { clientLoader } from "#/routes/settings"; import { resetOrgMockData } from "#/mocks/org-handlers"; import OptionService from "#/api/option-service/option-service.api"; import BillingService from "#/api/billing-service/billing-service.api"; +import { OrganizationMember } from "#/types/org"; function ManageOrgWithPortalRoot() { return ( @@ -78,13 +79,16 @@ describe("Manage Org Route", () => { }; // Helper function to set up user mock - const setupUserMock = (userData: { - id: string; - email: string; - role: "owner" | "admin" | "user"; - status: "active" | "invited"; - }) => { - getMeSpy.mockResolvedValue(userData); + const setupUserMock = ( + userData: { + id: string; + email: string; + role: "owner" | "admin" | "user"; + status: "active" | "invited"; + }, + orgId: string = "1", + ) => { + getMeSpy.mockResolvedValue(createGetMeResponse(userData, orgId)); }; beforeEach(() => { @@ -95,7 +99,8 @@ describe("Manage Org Route", () => { }); // Set default mock for user (owner role has all permissions) - setupUserMock(TEST_USERS.OWNER); + // Default to orgId "1" for most tests + setupUserMock(TEST_USERS.OWNER, "1"); }); afterEach(() => { @@ -574,7 +579,7 @@ describe("Manage Org Route", () => { it("should NOT allow roles other than owners to change org name", async () => { // Set admin role before rendering - setupUserMock(TEST_USERS.ADMIN); + setupUserMock(TEST_USERS.ADMIN, "3"); renderManageOrg(); await screen.findByTestId("manage-org-screen"); @@ -589,7 +594,7 @@ describe("Manage Org Route", () => { }); it("should NOT allow roles other than owners to delete an organization", async () => { - setupUserMock(TEST_USERS.ADMIN); + setupUserMock(TEST_USERS.ADMIN, "3"); const getConfigSpy = vi.spyOn(OptionService, "getConfig"); // @ts-expect-error - only return the properties we need for this test @@ -646,7 +651,7 @@ describe("Manage Org Route", () => { describe("Role-based delete organization permission behavior", () => { it("should show delete organization button when user has canDeleteOrganization permission (Owner role)", async () => { - setupUserMock(TEST_USERS.OWNER); + setupUserMock(TEST_USERS.OWNER, "1"); renderManageOrg(); await screen.findByTestId("manage-org-screen"); @@ -667,12 +672,15 @@ describe("Manage Org Route", () => { ])( "should not show delete organization button when user lacks canDeleteOrganization permission ($roleName role)", async ({ role }) => { - setupUserMock({ - id: "1", - email: "test@example.com", - role, - status: "active", - }); + setupUserMock( + { + id: "1", + email: "test@example.com", + role, + status: "active", + }, + "1", + ); renderManageOrg(); await screen.findByTestId("manage-org-screen"); @@ -688,7 +696,7 @@ describe("Manage Org Route", () => { ); it("should open delete confirmation modal when delete button is clicked (with permission)", async () => { - setupUserMock(TEST_USERS.OWNER); + setupUserMock(TEST_USERS.OWNER, "1"); renderManageOrg(); await screen.findByTestId("manage-org-screen"); diff --git a/frontend/__tests__/routes/manage-organization-members.test.tsx b/frontend/__tests__/routes/manage-organization-members.test.tsx index b00612c7ae..d5a21c7590 100644 --- a/frontend/__tests__/routes/manage-organization-members.test.tsx +++ b/frontend/__tests__/routes/manage-organization-members.test.tsx @@ -3,7 +3,7 @@ import { render, screen, within, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; import { createRoutesStub } from "react-router"; -import { selectOrganization } from "test-utils"; +import { selectOrganization, createGetMeResponse } from "test-utils"; import { organizationService } from "#/api/organization-service/organization-service.api"; import ManageOrganizationMembers from "#/routes/manage-organization-members"; import SettingsScreen, { @@ -15,6 +15,7 @@ import { resetOrgsAndMembersMockData, } from "#/mocks/org-handlers"; import OptionService from "#/api/option-service/option-service.api"; +import { OrganizationMember } from "#/types/org"; function ManageOrganizationMembersWithPortalRoot() { return ( @@ -60,12 +61,18 @@ describe("Manage Organization Members Route", () => { queryClient = new QueryClient(); // Set default mock for user (admin role has invite permission) - getMeSpy.mockResolvedValue({ - id: "1", - email: "test@example.com", - role: "admin", - status: "active", - }); + // orgIndex 0 maps to orgId "1" + getMeSpy.mockResolvedValue( + createGetMeResponse( + { + id: "1", + email: "test@example.com", + role: "admin", + status: "active", + }, + "1", + ), + ); }); afterEach(() => { @@ -145,7 +152,9 @@ describe("Manage Organization Members Route", () => { }, orgIndex: number, ) => { - getMeSpy.mockResolvedValue(userData); + // Map orgIndex to orgId: 0 -> "1", 1 -> "2", 2 -> "3" + const orgId = String(orgIndex + 1); + getMeSpy.mockResolvedValue(createGetMeResponse(userData, orgId)); renderManageOrganizationMembers(); await screen.findByTestId("manage-organization-members-settings"); await selectOrganization({ orgIndex }); @@ -478,12 +487,17 @@ describe("Manage Organization Members Route", () => { ])( "should show invite button when user has canInviteUsers permission ($roleName role)", async ({ role }) => { - getMeSpy.mockResolvedValue({ - id: "1", - email: "test@example.com", - role, - status: "active", - }); + getMeSpy.mockResolvedValue( + createGetMeResponse( + { + id: "1", + email: "test@example.com", + role, + status: "active", + }, + "1", + ), + ); await setupTestWithOrg(0); @@ -503,7 +517,7 @@ describe("Manage Organization Members Route", () => { }; // Set mock and remove cached query before rendering - getMeSpy.mockResolvedValue(userData); + getMeSpy.mockResolvedValue(createGetMeResponse(userData, "1")); // Remove any cached "me" queries so fresh data is fetched queryClient.removeQueries({ queryKey: ["organizations"] }); diff --git a/frontend/src/api/organization-service/organization-service.api.ts b/frontend/src/api/organization-service/organization-service.api.ts index 1a65ed8436..cfd7e35674 100644 --- a/frontend/src/api/organization-service/organization-service.api.ts +++ b/frontend/src/api/organization-service/organization-service.api.ts @@ -2,12 +2,13 @@ import { Organization, OrganizationMember, OrganizationUserRole, + GetMeResponse, } from "#/types/org"; import { openHands } from "../open-hands-axios"; export const organizationService = { getMe: async ({ orgId }: { orgId: string }) => { - const { data } = await openHands.get( + const { data } = await openHands.get( `/api/organizations/${orgId}/me`, ); diff --git a/frontend/src/hooks/query/use-me.ts b/frontend/src/hooks/query/use-me.ts index 0a8e60ec74..16d499e810 100644 --- a/frontend/src/hooks/query/use-me.ts +++ b/frontend/src/hooks/query/use-me.ts @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useConfig } from "./use-config"; import { organizationService } from "#/api/organization-service/organization-service.api"; import { useSelectedOrganizationId } from "#/context/use-selected-organization"; +import { OrganizationMember } from "#/types/org"; export const useMe = () => { const { data: config } = useConfig(); @@ -11,7 +12,24 @@ export const useMe = () => { return useQuery({ queryKey: ["organizations", orgId, "me"], - queryFn: () => organizationService.getMe({ orgId: orgId! }), + queryFn: async () => { + const response = await organizationService.getMe({ orgId: orgId! }); + // Find the current organization (isCurrent: true) + const currentOrg = response.orgs.find((org) => org.isCurrent); + + if (!currentOrg) { + throw new Error("Current organization not found in response"); + } + + const me: OrganizationMember = { + id: response.userId, + email: response.email, + role: currentOrg.role, + status: "active", + }; + + return me; + }, enabled: isSaas && !!orgId, }); }; diff --git a/frontend/src/mocks/org-handlers.ts b/frontend/src/mocks/org-handlers.ts index f6e33d40e1..c522eb6dcc 100644 --- a/frontend/src/mocks/org-handlers.ts +++ b/frontend/src/mocks/org-handlers.ts @@ -3,6 +3,7 @@ import { Organization, OrganizationMember, OrganizationUserRole, + GetMeResponse, } from "#/types/org"; const MOCK_ME: Omit = { @@ -134,26 +135,28 @@ export const ORG_HANDLERS = [ ); } - let role: OrganizationUserRole = "user"; - switch (orgId) { - case "1": - role = "owner"; - break; - case "2": - role = "user"; - break; - case "3": - role = "admin"; - break; - default: - role = "user"; - } - - const me: OrganizationMember = { - ...MOCK_ME, - role, + // Define user's role in each organization + const orgRoles: Record = { + "1": "owner", + "2": "user", + "3": "admin", }; - return HttpResponse.json(me); + + // Build the orgs array with all organizations the user belongs to + const userOrgs = INITIAL_MOCK_ORGS.map((org) => ({ + orgId: org.id, + orgName: org.name, + role: orgRoles[org.id] || "user", + isCurrent: org.id === orgId, + })); + + const response: GetMeResponse = { + userId: MOCK_ME.id, + email: MOCK_ME.email, + orgs: userOrgs, + }; + + return HttpResponse.json(response); }), http.get("/api/organizations/:orgId/members", ({ params }) => { diff --git a/frontend/src/routes/manage-org.tsx b/frontend/src/routes/manage-org.tsx index ff2f0b1d7d..f4d5bc60cd 100644 --- a/frontend/src/routes/manage-org.tsx +++ b/frontend/src/routes/manage-org.tsx @@ -15,9 +15,8 @@ import { useMe } from "#/hooks/query/use-me"; import { rolePermissions } from "#/utils/org/permissions"; import { getSelectedOrgFromQueryClient, - getMeFromQueryClient, + fetchAndCacheMe, } from "#/utils/query-client-getters"; -import { queryClient } from "#/query-client-config"; import { I18nKey } from "#/i18n/declaration"; import { amountIsValid } from "#/utils/amount-is-valid"; @@ -285,12 +284,7 @@ function AddCreditsModal({ onClose }: AddCreditsModalProps) { export const clientLoader = async () => { const selectedOrgId = getSelectedOrgFromQueryClient(); - let me = getMeFromQueryClient(selectedOrgId); - - if (!me && selectedOrgId) { - me = await organizationService.getMe({ orgId: selectedOrgId }); - queryClient.setQueryData(["organizations", selectedOrgId, "me"], me); - } + const me = selectedOrgId ? await fetchAndCacheMe(selectedOrgId) : null; if (!me || me.role === "user") { // if user is USER role, redirect to user settings diff --git a/frontend/src/routes/manage-organization-members.tsx b/frontend/src/routes/manage-organization-members.tsx index ecba990e3c..3f3649b9c5 100644 --- a/frontend/src/routes/manage-organization-members.tsx +++ b/frontend/src/routes/manage-organization-members.tsx @@ -12,22 +12,15 @@ import { useRemoveMember } from "#/hooks/mutation/use-remove-member"; import { useMe } from "#/hooks/query/use-me"; import { BrandButton } from "#/components/features/settings/brand-button"; import { rolePermissions } from "#/utils/org/permissions"; -import { organizationService } from "#/api/organization-service/organization-service.api"; -import { queryClient } from "#/query-client-config"; import { getSelectedOrgFromQueryClient, - getMeFromQueryClient, + fetchAndCacheMe, } from "#/utils/query-client-getters"; import { I18nKey } from "#/i18n/declaration"; export const clientLoader = async () => { const selectedOrgId = getSelectedOrgFromQueryClient(); - let me = getMeFromQueryClient(selectedOrgId); - - if (!me && selectedOrgId) { - me = await organizationService.getMe({ orgId: selectedOrgId }); - queryClient.setQueryData(["organizations", selectedOrgId, "me"], me); - } + const me = selectedOrgId ? await fetchAndCacheMe(selectedOrgId) : null; if (!me || me.role === "user") { // if user is USER role, redirect to user settings diff --git a/frontend/src/types/org.ts b/frontend/src/types/org.ts index 9640389f89..7730266060 100644 --- a/frontend/src/types/org.ts +++ b/frontend/src/types/org.ts @@ -12,3 +12,16 @@ export interface OrganizationMember { role: OrganizationUserRole; status: "active" | "invited"; } + +export interface UserOrgInfo { + orgId: string; + orgName: string; + role: OrganizationUserRole; + isCurrent: boolean; +} + +export interface GetMeResponse { + userId: string; + email: string; + orgs: UserOrgInfo[]; +} diff --git a/frontend/src/utils/query-client-getters.ts b/frontend/src/utils/query-client-getters.ts index d5cd910c4b..a52f40f774 100644 --- a/frontend/src/utils/query-client-getters.ts +++ b/frontend/src/utils/query-client-getters.ts @@ -1,8 +1,48 @@ import { queryClient } from "#/query-client-config"; import { OrganizationMember } from "#/types/org"; +import { organizationService } from "#/api/organization-service/organization-service.api"; export const getMeFromQueryClient = (orgId: string | undefined) => queryClient.getQueryData(["organizations", orgId, "me"]); export const getSelectedOrgFromQueryClient = () => queryClient.getQueryData(["selected_organization"]); + +/** + * Fetches and transforms the user's organization membership data. + * Checks cache first, then fetches from API if not cached. + * Transforms GetMeResponse to OrganizationMember format. + * + * @param orgId - The organization ID to fetch membership for + * @returns The transformed OrganizationMember, or null if not found + */ +export const fetchAndCacheMe = async ( + orgId: string, +): Promise => { + // Check cache first + let me = getMeFromQueryClient(orgId); + if (me) { + return me; + } + + // Fetch from API + const response = await organizationService.getMe({ orgId }); + const currentOrg = response.orgs.find((org) => org.isCurrent); + + if (!currentOrg) { + throw new Error("Current organization not found in response"); + } + + // Transform to OrganizationMember format + me = { + id: response.userId, + email: response.email, + role: currentOrg.role, + status: "active", + }; + + // Cache the result + queryClient.setQueryData(["organizations", orgId, "me"], me); + + return me; +}; diff --git a/frontend/test-utils.tsx b/frontend/test-utils.tsx index 41301266aa..bd4974b4db 100644 --- a/frontend/test-utils.tsx +++ b/frontend/test-utils.tsx @@ -7,6 +7,7 @@ import { expect, vi } from "vitest"; import { AxiosError } from "axios"; import userEvent from "@testing-library/user-event"; import { INITIAL_MOCK_ORGS } from "#/mocks/org-handlers"; +import { GetMeResponse, OrganizationMember } from "#/types/org"; // Mock useParams before importing components vi.mock("react-router", async () => { @@ -98,3 +99,37 @@ export const selectOrganization = async ({ const option = await screen.findByText(targetOrg.name); await userEvent.click(option); }; + +/** + * Helper function to convert OrganizationMember to GetMeResponse for testing. + * This creates a GetMeResponse structure that matches the new API format. + * + * @param userData - The user data (OrganizationMember format) + * @param orgId - The organization ID that should be marked as current + * @returns GetMeResponse with all orgs, marking the specified orgId as current + */ +export const createGetMeResponse = ( + userData: OrganizationMember, + orgId: string, +): GetMeResponse => { + // Map orgIndex to orgId: 0 -> "1", 1 -> "2", 2 -> "3" + const orgRoles: Record = { + "1": "owner", + "2": "user", + "3": "admin", + }; + + // Build orgs array with all organizations, marking the requested one as current + const orgs = INITIAL_MOCK_ORGS.map((org) => ({ + orgId: org.id, + orgName: org.name, + role: org.id === orgId ? userData.role : (orgRoles[org.id] || "user"), + isCurrent: org.id === orgId, + })); + + return { + userId: userData.id, + email: userData.email, + orgs, + }; +};