- Generate UUID v4 values in the client with validated custom UID support.\n- Show immutable UIDs where Space titles need disambiguation.\n- Standardize Space surfaces on the Lucide Astroid icon.
323 lines
13 KiB
TypeScript
323 lines
13 KiB
TypeScript
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import type { ReactNode } from "react";
|
|
import { MemoryRouter, useLocation } from "react-router-dom";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import SpacesSection from "@/components/Settings/SpacesSection";
|
|
import type { Space, SpaceInvitation, SpaceMember } from "@/types/proto/api/v1/space_service_pb";
|
|
|
|
const state = vi.hoisted(() => ({
|
|
viewerName: "users/alice",
|
|
viewerInstanceRole: 3,
|
|
spaces: [] as Space[],
|
|
receivedInvitations: [] as SpaceInvitation[],
|
|
members: [] as SpaceMember[],
|
|
managedInvitations: [] as SpaceInvitation[],
|
|
usersByName: new Map(),
|
|
acceptInvitation: vi.fn(),
|
|
declineInvitation: vi.fn(),
|
|
updateSpace: vi.fn(),
|
|
deleteSpace: vi.fn(),
|
|
updateMember: vi.fn(),
|
|
deleteMember: vi.fn(),
|
|
deleteInvitation: vi.fn(),
|
|
selectAmbientSpace: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("react-hot-toast", () => ({
|
|
default: { error: vi.fn(), success: vi.fn() },
|
|
}));
|
|
|
|
vi.mock("@/hooks/useCurrentUser", () => ({
|
|
default: () => ({ name: state.viewerName, role: state.viewerInstanceRole }),
|
|
}));
|
|
|
|
vi.mock("@/hooks/useSpaceQueries", () => ({
|
|
useSpaces: () => ({ data: state.spaces, isError: false, isLoading: false, isSuccess: true }),
|
|
useUserSpaceInvitations: () => ({ data: state.receivedInvitations, isLoading: false }),
|
|
useAcceptSpaceInvitation: () => ({ isPending: false, mutateAsync: state.acceptInvitation }),
|
|
useDeclineSpaceInvitation: () => ({ isPending: false, mutateAsync: state.declineInvitation }),
|
|
useSpaceMembers: () => ({ data: state.members, isError: false, isLoading: false }),
|
|
useSpaceInvitations: () => ({ data: state.managedInvitations, isLoading: false }),
|
|
useUpdateSpace: () => ({ isPending: false, mutateAsync: state.updateSpace }),
|
|
useDeleteSpace: () => ({ isPending: false, mutateAsync: state.deleteSpace }),
|
|
useUpdateSpaceMember: () => ({ isPending: false, mutateAsync: state.updateMember }),
|
|
useDeleteSpaceMember: () => ({ isPending: false, mutateAsync: state.deleteMember }),
|
|
useDeleteSpaceInvitation: () => ({ isPending: false, mutateAsync: state.deleteInvitation }),
|
|
}));
|
|
|
|
vi.mock("@/hooks/useUserQueries", () => ({
|
|
useUsersByUsernames: () => ({ data: state.usersByName }),
|
|
}));
|
|
|
|
vi.mock("@/contexts/SpaceContext", () => ({
|
|
useSpaceContext: () => ({ selectSpace: state.selectAmbientSpace }),
|
|
}));
|
|
|
|
vi.mock("@/components/SpaceMark", () => ({
|
|
default: () => <span aria-hidden>space-mark</span>,
|
|
}));
|
|
|
|
vi.mock("@/components/UserAvatar", () => ({
|
|
default: () => <span aria-hidden data-testid="user-avatar" />,
|
|
}));
|
|
|
|
vi.mock("@/components/CreateSpaceDialog", () => ({
|
|
default: ({ open, onCreated }: { open: boolean; onCreated?: (space: Space) => void }) =>
|
|
open ? (
|
|
<div role="dialog" aria-label="create-space-dialog">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const createdSpace: Space = {
|
|
$typeName: "memos.api.v1.Space",
|
|
name: "spaces/created",
|
|
title: "Created",
|
|
description: "",
|
|
currentUserRole: 1,
|
|
memberCount: 1,
|
|
};
|
|
state.spaces = [...state.spaces, createdSpace];
|
|
onCreated?.(createdSpace);
|
|
}}
|
|
>
|
|
complete-create
|
|
</button>
|
|
</div>
|
|
) : null,
|
|
}));
|
|
|
|
vi.mock("@/components/Settings/InviteSpaceMemberDialog", () => ({
|
|
default: ({ open }: { open: boolean }) => (open ? <div role="dialog">invite-member-dialog</div> : null),
|
|
}));
|
|
|
|
vi.mock("@/components/ConfirmDialog", () => ({
|
|
default: ({
|
|
open,
|
|
title,
|
|
confirmLabel,
|
|
onConfirm,
|
|
}: {
|
|
open: boolean;
|
|
title: ReactNode;
|
|
confirmLabel: string;
|
|
onConfirm: () => void | Promise<void>;
|
|
}) =>
|
|
open ? (
|
|
<div role="dialog">
|
|
<div>{title}</div>
|
|
<button type="button" onClick={() => void onConfirm()}>
|
|
{confirmLabel}
|
|
</button>
|
|
</div>
|
|
) : null,
|
|
}));
|
|
|
|
vi.mock("@/utils/i18n", () => ({
|
|
useTranslate: () => (key: string, params?: Record<string, string>) =>
|
|
key === "setting.spaces.delete-confirm-title" && params?.space ? `${key} ${params.space}` : key,
|
|
}));
|
|
|
|
const adminSpace: Space = {
|
|
$typeName: "memos.api.v1.Space",
|
|
name: "spaces/product",
|
|
title: "Product",
|
|
description: "Product decisions",
|
|
currentUserRole: 1,
|
|
memberCount: 2,
|
|
};
|
|
|
|
const userSpace: Space = {
|
|
...adminSpace,
|
|
currentUserRole: 2,
|
|
};
|
|
|
|
const adminMember: SpaceMember = {
|
|
$typeName: "memos.api.v1.SpaceMember",
|
|
name: "spaces/product/members/alice",
|
|
user: "users/alice",
|
|
role: 1,
|
|
};
|
|
|
|
const ordinaryMember: SpaceMember = {
|
|
$typeName: "memos.api.v1.SpaceMember",
|
|
name: "spaces/product/members/bob",
|
|
user: "users/bob",
|
|
role: 2,
|
|
};
|
|
|
|
const receivedInvitation: SpaceInvitation = {
|
|
$typeName: "memos.api.v1.SpaceInvitation",
|
|
name: "spaces/research/invitations/alice",
|
|
invitee: "users/alice",
|
|
role: 2,
|
|
space: {
|
|
$typeName: "memos.api.v1.Space",
|
|
name: "spaces/research",
|
|
title: "Research",
|
|
description: "Research notes",
|
|
currentUserRole: 0,
|
|
memberCount: 0,
|
|
},
|
|
};
|
|
|
|
const pendingInvitation: SpaceInvitation = {
|
|
$typeName: "memos.api.v1.SpaceInvitation",
|
|
name: "spaces/product/invitations/carol",
|
|
invitee: "users/carol",
|
|
role: 2,
|
|
space: adminSpace,
|
|
};
|
|
|
|
const LocationProbe = () => {
|
|
const location = useLocation();
|
|
return <output data-testid="location">{`${location.pathname}${location.search}${location.hash}`}</output>;
|
|
};
|
|
|
|
const renderSection = (entry = "/setting#spaces") =>
|
|
render(
|
|
<MemoryRouter initialEntries={[entry]}>
|
|
<SpacesSection />
|
|
<LocationProbe />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
describe("SpacesSection", () => {
|
|
beforeEach(() => {
|
|
state.spaces = [];
|
|
state.viewerInstanceRole = 3;
|
|
state.receivedInvitations = [];
|
|
state.members = [];
|
|
state.managedInvitations = [];
|
|
state.usersByName = new Map();
|
|
state.acceptInvitation.mockReset().mockResolvedValue(undefined);
|
|
state.declineInvitation.mockReset().mockResolvedValue(undefined);
|
|
state.updateSpace.mockReset().mockResolvedValue(undefined);
|
|
state.deleteSpace.mockReset().mockResolvedValue(undefined);
|
|
state.updateMember.mockReset().mockResolvedValue(undefined);
|
|
state.deleteMember.mockReset().mockResolvedValue(undefined);
|
|
state.deleteInvitation.mockReset().mockResolvedValue(undefined);
|
|
state.selectAmbientSpace.mockReset();
|
|
});
|
|
|
|
it("separates pending invitations from joined spaces and uses exact invitation resource names", async () => {
|
|
state.spaces = [adminSpace];
|
|
state.receivedInvitations = [receivedInvitation];
|
|
|
|
renderSection();
|
|
|
|
const invitationsSection = screen.getByRole("heading", { name: "setting.spaces.invitations" }).closest("section");
|
|
const joinedSection = screen.getByRole("heading", { name: "setting.spaces.your-spaces" }).closest("section");
|
|
expect(invitationsSection).not.toBeNull();
|
|
expect(joinedSection).not.toBeNull();
|
|
expect(within(invitationsSection!).getByText("Research")).toBeInTheDocument();
|
|
expect(within(invitationsSection!).queryByText("Product")).not.toBeInTheDocument();
|
|
expect(within(joinedSection!).getByText("Product")).toBeInTheDocument();
|
|
expect(within(joinedSection!).queryByText("Research")).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(within(invitationsSection!).getByRole("button", { name: "setting.spaces.accept Research (research)" }));
|
|
await waitFor(() => expect(state.acceptInvitation).toHaveBeenCalledWith({ name: "spaces/research/invitations/alice" }));
|
|
|
|
fireEvent.click(within(invitationsSection!).getByRole("button", { name: "setting.spaces.decline Research (research)" }));
|
|
await waitFor(() => expect(state.declineInvitation).toHaveBeenCalledWith({ name: "spaces/research/invitations/alice" }));
|
|
});
|
|
|
|
it("shows the full Space UID for every joined Space", () => {
|
|
const uuid = "123e4567-e89b-12d3-a456-426614174000";
|
|
state.spaces = [
|
|
{ ...adminSpace, name: "spaces/product-notes" },
|
|
{ ...adminSpace, name: `spaces/${uuid}` },
|
|
{ ...adminSpace, name: "spaces/research-space", title: "Research" },
|
|
];
|
|
|
|
renderSection();
|
|
|
|
const customIdRow = screen.getByRole("button", { name: "setting.spaces.manage-space (product-notes)" });
|
|
const uuidRow = screen.getByRole("button", { name: `setting.spaces.manage-space (${uuid})` });
|
|
expect(within(customIdRow).getByTitle("product-notes")).toHaveTextContent("product-notes");
|
|
expect(within(uuidRow).getByTitle(uuid)).toHaveTextContent(uuid);
|
|
expect(screen.getByTitle("research-space")).toHaveTextContent("research-space");
|
|
});
|
|
|
|
it("disambiguates matching titles across joined Spaces and invitations", () => {
|
|
state.spaces = [{ ...adminSpace, name: "spaces/joined-product" }];
|
|
state.receivedInvitations = [
|
|
{
|
|
...receivedInvitation,
|
|
space: { ...receivedInvitation.space!, name: "spaces/invited-product", title: "Product" },
|
|
},
|
|
];
|
|
|
|
renderSection();
|
|
|
|
const invitationsSection = screen.getByRole("heading", { name: "setting.spaces.invitations" }).closest("section");
|
|
const joinedSection = screen.getByRole("heading", { name: "setting.spaces.your-spaces" }).closest("section");
|
|
expect(within(invitationsSection!).getByTitle("invited-product")).toHaveTextContent("invited-product");
|
|
expect(within(joinedSection!).getByTitle("joined-product")).toHaveTextContent("joined-product");
|
|
});
|
|
|
|
it("shows governance controls to Space admins and cancels the exact pending invitation", async () => {
|
|
state.spaces = [adminSpace];
|
|
state.members = [adminMember, ordinaryMember];
|
|
state.managedInvitations = [pendingInvitation];
|
|
|
|
renderSection("/setting?space=spaces%2Fproduct#spaces");
|
|
|
|
expect(screen.getByText("product", { selector: "code" })).toBeInTheDocument();
|
|
expect(screen.getByLabelText("common.name")).not.toHaveAttribute("readonly");
|
|
expect(screen.getByRole("button", { name: "setting.spaces.save-changes" })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "setting.spaces.delete-space" })).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("tab", { name: /setting\.spaces\.members/ }));
|
|
expect(screen.getByRole("button", { name: "setting.spaces.invite-member" })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "setting.spaces.remove" })).toBeInTheDocument();
|
|
expect(screen.getByRole("heading", { name: "setting.spaces.pending-invitations" })).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole("button", { name: "setting.spaces.cancel-invitation" }));
|
|
fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "setting.spaces.cancel-invitation" }));
|
|
await waitFor(() => expect(state.deleteInvitation).toHaveBeenCalledWith({ name: "spaces/product/invitations/carol" }));
|
|
});
|
|
|
|
it("wraps a maximum-length Space UID in the destructive confirmation", () => {
|
|
const uid = "a".repeat(36);
|
|
const space = { ...adminSpace, name: `spaces/${uid}` };
|
|
state.spaces = [space];
|
|
state.members = [{ ...adminMember, name: `spaces/${uid}/members/alice` }];
|
|
|
|
renderSection(`/setting?space=${encodeURIComponent(space.name)}#spaces`);
|
|
expect(screen.getByTitle(uid)).toHaveClass("break-all");
|
|
expect(screen.getByTitle(uid)).toHaveTextContent(uid);
|
|
fireEvent.click(screen.getByRole("button", { name: "setting.spaces.delete-space" }));
|
|
|
|
const title = within(screen.getByRole("dialog")).getByText(`setting.spaces.delete-confirm-title Product (${uid})`);
|
|
expect(title).toHaveClass("[overflow-wrap:anywhere]");
|
|
});
|
|
|
|
it("keeps ordinary Space members read-only and hides governance controls", () => {
|
|
// Instance administrators do not implicitly receive Space governance rights.
|
|
state.viewerInstanceRole = 2;
|
|
state.spaces = [userSpace];
|
|
state.members = [{ ...ordinaryMember, name: "spaces/product/members/alice", user: "users/alice" }, adminMember];
|
|
state.managedInvitations = [pendingInvitation];
|
|
|
|
renderSection("/setting?space=spaces%2Fproduct#spaces");
|
|
|
|
expect(screen.getByLabelText("common.name")).toHaveAttribute("readonly");
|
|
expect(screen.queryByRole("button", { name: "setting.spaces.save-changes" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "setting.spaces.delete-space" })).not.toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "setting.spaces.leave-space" })).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("tab", { name: /setting\.spaces\.members/ }));
|
|
expect(screen.queryByRole("button", { name: "setting.spaces.invite-member" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "setting.spaces.remove" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("heading", { name: "setting.spaces.pending-invitations" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("navigates to a newly created Space for management without switching ambient Space", async () => {
|
|
renderSection();
|
|
|
|
fireEvent.click(screen.getAllByRole("button", { name: "setting.spaces.create-space" })[0]);
|
|
fireEvent.click(screen.getByRole("button", { name: "complete-create" }));
|
|
|
|
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/setting?space=spaces%2Fcreated#spaces"));
|
|
expect(state.selectAmbientSpace).not.toHaveBeenCalled();
|
|
});
|
|
});
|