All checks were successful
ci/woodpecker/push/ci Pipeline was successful
fix(web): QA fixes on users settings page (MS21-UI-001-QA) Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
353 lines
10 KiB
TypeScript
353 lines
10 KiB
TypeScript
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 <a href={href}>{children}</a>;
|
|
},
|
|
}));
|
|
|
|
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>): 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<typeof useAuth> {
|
|
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(<UsersSettingsPage />);
|
|
|
|
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(<UsersSettingsPage />);
|
|
|
|
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(<UsersSettingsPage />);
|
|
|
|
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(<UsersSettingsPage />);
|
|
|
|
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(<UsersSettingsPage />);
|
|
|
|
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(<UsersSettingsPage />);
|
|
|
|
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();
|
|
});
|
|
});
|