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: () => space-mark, })); vi.mock("@/components/UserAvatar", () => ({ default: () => , })); vi.mock("@/components/CreateSpaceDialog", () => ({ default: ({ open, onCreated }: { open: boolean; onCreated?: (space: Space) => void }) => open ? (
) : null, })); vi.mock("@/components/Settings/InviteSpaceMemberDialog", () => ({ default: ({ open }: { open: boolean }) => (open ?
invite-member-dialog
: null), })); vi.mock("@/components/ConfirmDialog", () => ({ default: ({ open, title, confirmLabel, onConfirm, }: { open: boolean; title: ReactNode; confirmLabel: string; onConfirm: () => void | Promise; }) => open ? (
{title}
) : null, })); vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string, params?: Record) => 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 {`${location.pathname}${location.search}${location.hash}`}; }; const renderSection = (entry = "/setting#spaces") => render( , ); 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("saves the icon through the icon update mask", async () => { state.spaces = [adminSpace]; state.members = [adminMember]; renderSection("/setting?space=spaces%2Fproduct#spaces"); fireEvent.click(screen.getByRole("button", { name: "space.icon.change" })); fireEvent.click(await screen.findByRole("button", { name: "Leaf" })); expect(state.updateSpace).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "setting.spaces.save-changes" })); await waitFor(() => expect(state.updateSpace).toHaveBeenCalledWith({ space: expect.objectContaining({ name: adminSpace.name, icon: expect.objectContaining({ value: { case: "lucide", value: "leaf" } }), }), updateMask: ["title", "description", "icon"], }), ); }); it("clears a saved icon with an explicit update mask", async () => { state.spaces = [{ ...adminSpace, icon: { $typeName: "memos.api.v1.Space.Icon", value: { case: "emoji", value: "🌱" } } }]; state.members = [adminMember]; renderSection("/setting?space=spaces%2Fproduct#spaces"); fireEvent.click(screen.getByRole("button", { name: "space.icon.change" })); fireEvent.click(await screen.findByRole("button", { name: "space.icon.reset" })); fireEvent.click(screen.getByRole("button", { name: "setting.spaces.save-changes" })); await waitFor(() => expect(state.updateSpace).toHaveBeenCalledWith({ space: expect.objectContaining({ name: adminSpace.name, icon: undefined }), updateMask: ["title", "description", "icon"], }), ); }); 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: "space.icon.change" })).not.toBeInTheDocument(); 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(); }); });