// @vitest-environment jsdom import { flushSync } from "react-dom"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { queryKeys } from "@/lib/queryKeys"; const api = vi.hoisted(() => ({ getIssue: vi.fn(), getProject: vi.fn(), constraints: vi.fn(), setVisibility: vi.fn(), members: vi.fn(), add: vi.fn(), remove: vi.fn(), directory: vi.fn() })); vi.mock("@/api/issues", () => ({ issuesApi: { get: api.getIssue, privacyConstraints: api.constraints, setVisibility: api.setVisibility } })); vi.mock("@/api/projects", () => ({ projectsApi: { get: api.getProject, listAccessMembers: api.members, addAccessMember: api.add, removeAccessMember: api.remove } })); vi.mock("@/api/access", () => ({ accessApi: { listUserDirectory: api.directory } })); vi.mock("@/api/agents", () => ({ agentsApi: { list: () => Promise.resolve([]) } })); vi.mock("@/context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) })); import { IssuePrivacyActions } from "./IssuePrivacyActions"; import { ProjectAccessMembers } from "./ProjectAccessMembers"; import type { Project } from "@paperclipai/shared"; async function settle() { for (let i = 0; i < 8; i++) { await new Promise(resolve => setTimeout(resolve, 0)); flushSync(() => {}); } } const originalScrollIntoView = Element.prototype.scrollIntoView; let root: Root; let container: HTMLDivElement; let client: QueryClient; beforeEach(() => { vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} }); Element.prototype.scrollIntoView = () => {}; vi.resetAllMocks(); api.directory.mockResolvedValue({ users: [] }); api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: false }); api.members.mockResolvedValue([]); container = document.createElement("div"); document.body.append(container); root = createRoot(container); client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); }); afterEach(() => { flushSync(() => root.unmount()); client.clear(); document.body.innerHTML = ""; vi.unstubAllGlobals(); Element.prototype.scrollIntoView = originalScrollIntoView; }); function render(node: React.ReactNode) { flushSync(() => root.render({node})); } function publicButton() { return [...document.body.querySelectorAll("button")].find(b => b.textContent?.trim() === "Make public")!; } function actions(scope: { privacyParentIssueId?: string; projectId?: string } = {}) { render( {}}>{items =>
{items}
}
); } describe("inherited privacy controls", () => { it("blocks a private parent and does not send an impossible visibility change", async () => { api.constraints.mockResolvedValue({ publicBlockedBy: "parent", leavesPersonalProject: false }); actions({ privacyParentIssueId: "parent" }); await settle(); expect(publicButton().disabled).toBe(true); publicButton().click(); expect(api.setVisibility).not.toHaveBeenCalled(); }); it("blocks a non-personal private project but permits leaving a personal project", async () => { api.constraints.mockResolvedValue({ publicBlockedBy: "project", leavesPersonalProject: false }); actions({ projectId: "project" }); await settle(); expect(publicButton().disabled).toBe(true); api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: true }); await client.invalidateQueries({ queryKey: queryKeys.issues.privacyConstraints("child") }); await settle(); expect(publicButton().disabled).toBe(false); }); it.each([ ["parent", { privacyParentIssueId: "private-parent" }], ["project", { projectId: "private-project" }], ] as const)("refreshes task privacy after moving out of a private %s", async (kind, scope) => { api.constraints.mockResolvedValue({ publicBlockedBy: kind, leavesPersonalProject: false }); actions(scope); await settle(); expect(publicButton().disabled).toBe(true); expect(api.constraints).toHaveBeenCalledTimes(1); api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: false }); // Same task ID and mounted action menu: only the surrounding scope changes. actions(); await settle(); expect(api.constraints).toHaveBeenCalledTimes(2); expect(publicButton().disabled).toBe(false); }); it("checks a parent's private project even when the parent itself is open", async () => { api.constraints.mockResolvedValue({ publicBlockedBy: "parent", leavesPersonalProject: false }); actions({ privacyParentIssueId: "parent" }); await settle(); expect(publicButton().disabled).toBe(true); }); it("permits making a child public after its parent is open", async () => { api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: false }); actions({ privacyParentIssueId: "parent" }); await settle(); expect(publicButton().disabled).toBe(false); }); it("allows publishing from an unreadable personal project without fetching that project", async () => { api.getProject.mockRejectedValue(new Error("404")); api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: true }); api.setVisibility.mockResolvedValue({}); actions({ projectId: "protected-project" }); await settle(); expect(publicButton().disabled).toBe(false); expect(api.getProject).not.toHaveBeenCalled(); flushSync(() => publicButton().click()); await settle(); expect(document.body.textContent).toContain("leave its personal project"); const confirm = [...document.body.querySelectorAll('[role="alertdialog"] button')].find(button => button.textContent === "Make public")!; flushSync(() => (confirm as HTMLButtonElement).click()); await settle(); expect(api.setVisibility).toHaveBeenCalledWith("child", "open"); }); it("explains a protected private parent without offering an impossible retry", async () => { api.getIssue.mockRejectedValue(new Error("404")); api.constraints.mockResolvedValue({ publicBlockedBy: "parent", leavesPersonalProject: false }); actions({ privacyParentIssueId: "protected-parent" }); await settle(); expect(publicButton().disabled).toBe(true); expect(api.getIssue).not.toHaveBeenCalled(); expect(document.body.textContent).not.toContain("Retry access check"); }); it("keeps unresolved and failed scope checks closed, and retries in place", async () => { let reject!: (error: Error) => void; api.constraints.mockReturnValue(new Promise((_resolve, rejectFn) => { reject = rejectFn; })); actions({ privacyParentIssueId: "parent" }); await settle(); expect(publicButton().disabled).toBe(true); reject(new Error("Unavailable")); await settle(); expect(publicButton().disabled).toBe(true); api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: false }); flushSync(() => [...document.body.querySelectorAll("button")].find(b => b.textContent === "Retry access check")!.click()); await settle(); expect(publicButton().disabled).toBe(false); }); }); it("invalidates cached task audience when project membership is removed", async () => { const key = queryKeys.issues.accessGrants("child"); client.setQueryData(key, [{ subjectId: "member", source: "project" }]); api.members.mockResolvedValue([{ id: "m1", subjectType: "user", subjectId: "member", subjectDisplayName: "Morgan" }]); api.remove.mockResolvedValue({}); render(); flushSync(() => [...document.body.querySelectorAll("button")].find(b => b.textContent?.includes("Manage access"))!.click()); await settle(); flushSync(() => (document.body.querySelector('[aria-label="Remove Morgan"]') as HTMLButtonElement).click()); await settle(); expect(api.remove).toHaveBeenCalledWith("project", "m1", "company"); expect(client.getQueryState(key)?.isInvalidated).toBe(true); });