import type { ReactElement, ReactNode } from "react"; import { WorkspaceMemberRole } from "@mosaic/shared"; import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { type AdminUser, deactivateUser, fetchAdminUsers, inviteUser, updateUser, type AdminUsersResponse, } from "@/lib/api/admin"; import { useAuth } from "@/lib/auth/auth-context"; import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces"; import UsersSettingsPage from "./page"; vi.mock("next/link", () => ({ default: function LinkMock({ children, href, }: { children: ReactNode; href: string; }): ReactElement { return {children}; }, })); vi.mock("@/lib/api/admin", () => ({ fetchAdminUsers: vi.fn(), inviteUser: vi.fn(), updateUser: vi.fn(), deactivateUser: vi.fn(), })); vi.mock("@/lib/api/workspaces", () => ({ fetchUserWorkspaces: vi.fn(), updateWorkspaceMemberRole: vi.fn(), })); vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn(), })); const fetchAdminUsersMock = vi.mocked(fetchAdminUsers); const inviteUserMock = vi.mocked(inviteUser); const updateUserMock = vi.mocked(updateUser); const deactivateUserMock = vi.mocked(deactivateUser); const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces); const updateWorkspaceMemberRoleMock = vi.mocked(updateWorkspaceMemberRole); const useAuthMock = vi.mocked(useAuth); function makeAdminUser(overrides?: Partial): AdminUser { return { id: "user-1", name: "Alice", email: "alice@example.com", emailVerified: true, image: null, createdAt: "2026-01-01T00:00:00.000Z", deactivatedAt: null, isLocalAuth: false, invitedAt: null, invitedBy: null, workspaceMemberships: [ { workspaceId: "workspace-1", workspaceName: "Personal Workspace", role: WorkspaceMemberRole.ADMIN, joinedAt: "2026-01-01T00:00:00.000Z", }, ], ...overrides, }; } function makeAdminUsersResponse(options?: { data?: AdminUser[]; page?: number; totalPages?: number; total?: number; limit?: number; }): AdminUsersResponse { const data = options?.data ?? [makeAdminUser()]; return { data, meta: { total: options?.total ?? data.length, page: options?.page ?? 1, limit: options?.limit ?? 50, totalPages: options?.totalPages ?? 1, }, }; } function makeAuthState(userId: string): ReturnType { return { user: { id: userId, email: `${userId}@example.com`, name: "Current User" }, isLoading: false, isAuthenticated: true, authError: null, sessionExpiring: false, sessionMinutesRemaining: 0, signOut: vi.fn(() => Promise.resolve()), refreshSession: vi.fn(() => Promise.resolve()), }; } describe("UsersSettingsPage", () => { beforeEach(() => { vi.clearAllMocks(); const adminUsersResponse = makeAdminUsersResponse(); fetchAdminUsersMock.mockResolvedValue(adminUsersResponse); fetchUserWorkspacesMock.mockResolvedValue([ { id: "workspace-1", name: "Personal Workspace", ownerId: "owner-1", role: WorkspaceMemberRole.OWNER, createdAt: "2026-01-01T00:00:00.000Z", }, ]); inviteUserMock.mockResolvedValue({ userId: "user-2", invitationToken: "token-1", email: "new@example.com", invitedAt: "2026-01-02T00:00:00.000Z", }); const firstUser = adminUsersResponse.data[0] ?? makeAdminUser(); updateUserMock.mockResolvedValue(firstUser); deactivateUserMock.mockResolvedValue(firstUser); updateWorkspaceMemberRoleMock.mockResolvedValue({ workspaceId: "workspace-1", userId: "user-1", role: WorkspaceMemberRole.ADMIN, joinedAt: "2026-01-01T00:00:00.000Z", user: { id: "user-1", email: "alice@example.com", name: "Alice", image: null, }, }); useAuthMock.mockReturnValue(makeAuthState("user-current")); }); it("shows access denied to non-admin users", async () => { fetchUserWorkspacesMock.mockResolvedValueOnce([ { id: "workspace-1", name: "Personal Workspace", ownerId: "owner-1", role: WorkspaceMemberRole.MEMBER, createdAt: "2026-01-01T00:00:00.000Z", }, ]); render(); expect(await screen.findByText("Access Denied")).toBeInTheDocument(); expect(fetchAdminUsersMock).not.toHaveBeenCalled(); }); it("invites a user with email and role from the dialog", async () => { const user = userEvent.setup(); render(); expect(await screen.findByText("User Directory")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Invite User" })); await user.type(screen.getByLabelText("Email"), "new@example.com"); await user.click(screen.getByRole("button", { name: "Send Invite" })); await waitFor(() => { expect(inviteUserMock).toHaveBeenCalledWith({ email: "new@example.com", role: WorkspaceMemberRole.MEMBER, workspaceId: "workspace-1", }); }); }); it("opens user detail dialog from row click and saves edited profile fields", async () => { const user = userEvent.setup(); render(); expect(await screen.findByText("alice@example.com")).toBeInTheDocument(); await user.click(screen.getByText("Alice")); const nameInput = await screen.findByLabelText("Name"); await user.clear(nameInput); await user.type(nameInput, "Alice Updated"); await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { expect(updateUserMock).toHaveBeenCalledWith("user-1", { name: "Alice Updated" }); }); expect(updateWorkspaceMemberRoleMock).not.toHaveBeenCalled(); }); it("caps pagination to the last valid page after deactivation shrinks the dataset", async () => { const user = userEvent.setup(); const pageOneUser = makeAdminUser({ id: "user-1", name: "Alice", email: "alice@example.com", }); const pageTwoUser = makeAdminUser({ id: "user-2", name: "Bob", email: "bob@example.com", }); fetchAdminUsersMock.mockReset(); const responses = [ { expectedPage: 1, response: makeAdminUsersResponse({ data: [pageOneUser], page: 1, totalPages: 2, total: 2, }), }, { expectedPage: 2, response: makeAdminUsersResponse({ data: [pageTwoUser], page: 2, totalPages: 2, total: 2, }), }, { expectedPage: 2, response: makeAdminUsersResponse({ data: [], page: 2, totalPages: 1, total: 1, }), }, { expectedPage: 1, response: makeAdminUsersResponse({ data: [pageOneUser], page: 1, totalPages: 1, total: 1, }), }, ]; fetchAdminUsersMock.mockImplementation((page = 1) => { const next = responses.shift(); if (!next) { throw new Error("Unexpected fetchAdminUsers call in pagination-cap test"); } expect(page).toBe(next.expectedPage); return Promise.resolve(next.response); }); render(); expect(await screen.findByText("alice@example.com")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Next" })); expect(await screen.findByText("bob@example.com")).toBeInTheDocument(); const pageTwoRow = screen.getByText("bob@example.com").closest('[role="button"]'); if (!(pageTwoRow instanceof HTMLElement)) { throw new Error("Expected Bob's row to exist"); } await user.click(within(pageTwoRow).getByRole("button", { name: "Deactivate" })); const deactivateButtons = await screen.findAllByRole("button", { name: "Deactivate" }); const confirmDeactivateButton = deactivateButtons[deactivateButtons.length - 1]; if (!confirmDeactivateButton) { throw new Error("Expected confirmation deactivate button to be rendered"); } await user.click(confirmDeactivateButton); expect(await screen.findByText("alice@example.com")).toBeInTheDocument(); expect(screen.queryByText("No Users Yet")).not.toBeInTheDocument(); expect(deactivateUserMock).toHaveBeenCalledWith("user-2"); const requestedPages = fetchAdminUsersMock.mock.calls.map(([requestedPage]) => requestedPage); expect(requestedPages.slice(-2)).toEqual([2, 1]); }); it("shows the API error state without rendering the empty-state message", async () => { fetchAdminUsersMock.mockRejectedValueOnce(new Error("Unable to load users")); render(); expect(await screen.findByText("Unable to load users")).toBeInTheDocument(); expect(screen.queryByText("No Users Yet")).not.toBeInTheDocument(); expect(screen.queryByText("Invite the first user to get started.")).not.toBeInTheDocument(); }); it("prevents the current user from deactivating their own account", async () => { useAuthMock.mockReturnValue(makeAuthState("user-1")); const selfUser = makeAdminUser({ id: "user-1", name: "Alice", email: "alice@example.com", }); const otherUser = makeAdminUser({ id: "user-2", name: "Bob", email: "bob@example.com", }); fetchAdminUsersMock.mockResolvedValueOnce( makeAdminUsersResponse({ data: [selfUser, otherUser], page: 1, totalPages: 1, total: 2, }) ); render(); expect(await screen.findByText("alice@example.com")).toBeInTheDocument(); expect(screen.getByText("bob@example.com")).toBeInTheDocument(); const selfRow = screen.getByText("alice@example.com").closest('[role="button"]'); if (!(selfRow instanceof HTMLElement)) { throw new Error("Expected current-user row to exist"); } expect(within(selfRow).queryByRole("button", { name: "Deactivate" })).not.toBeInTheDocument(); const otherRow = screen.getByText("bob@example.com").closest('[role="button"]'); if (!(otherRow instanceof HTMLElement)) { throw new Error("Expected other-user row to exist"); } expect(within(otherRow).getByRole("button", { name: "Deactivate" })).toBeInTheDocument(); expect(deactivateUserMock).not.toHaveBeenCalled(); }); });