Files
PaperClipAI/ui/src/components/ShellConfigurationGate.test.tsx
T
DottaandPaperclip adb49a4434 test(ui): match access fixtures to signed-in accounts (#18799)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - The board checks that access data belongs to the signed-in account.
> - The startup changes in #18790 added this account check.
> - Two existing test fixtures did not supply the account identity.
> - Cloud readiness now rejects those fixtures before their intended
assertions.
> - This pull request adds the matching user ID to each fixture.
> - The tests can then check invitations and pending shell settings with
the same access boundary as the app.

## Linked Issues or Issue Description

Refs #18790, #18770.

Cloud readiness [run
41665172584](https://github.com/paperclipai/paperclip/actions/runs/41665172584)
at `f7ad1dc7cacedf7153a45e854ddfdd544aa87abd` failed both UI shards. The
private invitation case showed an access error. Both pending-shell cases
made two session reads. All three failures reproduce unchanged in the
focused local run.

The access query compares the response user ID with the current session.
These fixtures omitted that field. The mismatch correctly starts
account-change handling and prevents the tests from reaching their
intended state.

## What Changed

- Give the invitation fixture the same user ID as its signed-in invitee.
- Give the pending-shell fixture the same user ID as its signed-in user.
- Keep all assertions, account-change rejection, and production behavior
unchanged.

## Verification

- Before the repair: the focused App and ShellConfigurationGate run
reproduced all three CI failures; 24 other cases passed.
- After the repair: all 76 cases pass across App,
ShellConfigurationGate, CloudAccessGate, board-access-query, and
CompanyContext. These include account mismatch and revoked-access
coverage.
- `pnpm check:token-gates` and `git diff --check` pass.
- Full `pnpm build` and `pnpm -r typecheck` pass on
`fa4ec98859d573e4197bb022cedc76f36fad1cf8`. Current-head Greptile
reviewed this commit at 5/5 with no findings. CI is still running.
Normal squash auto-merge is enabled for this exact head and will wait
for the required checks.
- The full local workspace test suite is not repeated for this two-line
fixture repair. The earlier full-suite chat flakiness and cleanup
timeout remain recorded in #18770. This PR does not claim that those
failures are repaired.
- No API, visual, or operator documentation changes are needed for these
fixture values.

## Risks

Low risk. The change only adds existing response fields to two test
fixtures. It does not relax account validation or change test
expectations. Cloud readiness must still pass on the resulting master
revision before rollout.

## Model Used

OpenAI GPT-6 Codex, with reasoning, tool use, and code execution. The
exact runtime model identifier and context-window size are not exposed
in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-03 16:20:11 +00:00

103 lines
5.6 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, expect, it, vi } from "vitest";
import { CloudAccessGate } from "./CloudAccessGate";
import { ShellConfigurationGate } from "./ShellConfigurationGate";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
vi.mock("@/lib/router", async () => await import("react-router-dom"));
vi.mock("./AnimatedPaperclipIcon", () => ({ PaperclipLoading: () => <div>Loading shell</div> }));
const { health, session, access, settings } = vi.hoisted(() => ({ health: vi.fn(), session: vi.fn(), access: vi.fn(), settings: vi.fn() }));
vi.mock("../api/health", () => ({ healthApi: { get: health } }));
vi.mock("../api/auth", () => ({ authApi: { getSession: session } }));
vi.mock("../api/access", () => ({ accessApi: { getCurrentBoardAccess: access } }));
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: settings } }));
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => vi.resetAllMocks());
it.each([
["/PAP/issues/PAP-1", true],
["/PAP/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", true],
["/PAP/issues/all", false],
["/PAP/issues", false],
["/PAP/chats/agent", false],
["/PAP/apps", false],
])("prepares only guarded task data behind the loading view while preferences load: %s", async (path, permitted) => {
const container = document.createElement("div");
const root = createRoot(container);
try {
await act(async () => root.render(<MemoryRouter initialEntries={[path]}><Routes>
<Route element={<ShellConfigurationGate loaded={false} allowPendingTaskDetail />}>
<Route path="*" element={<div>Guarded task content</div>} />
</Route>
</Routes></MemoryRouter>));
expect(container.textContent).toContain("Loading shell");
expect(container.textContent?.includes("Guarded task content")).toBe(permitted);
const prepared = container.querySelector<HTMLElement>('[data-testid="shell-prepared-outlet"]');
expect(prepared?.hidden ?? null).toBe(permitted ? true : null);
if (prepared) expect(prepared.hasAttribute("inert")).toBe(true);
} finally { await act(async () => root.unmount()); }
});
it.each([false, true])("runs access checks while shell settings are pending (membership=%s)", async allowMembershipRequest => {
let resolveSettings!: (value: unknown) => void;
health.mockResolvedValue({ status: "ok", deploymentMode: "authenticated", bootstrapStatus: "ready" });
session.mockResolvedValue({ user: { id: "user" } });
access.mockResolvedValue({ userId: "user", companyIds: ["company"] });
settings.mockReturnValue(new Promise(resolve => { resolveSettings = resolve; }));
function RoutesUnderTest() {
const { loaded, enabled } = useStreamlinedUiEnabled();
return <Routes><Route element={<CloudAccessGate allowMembershipRequest={allowMembershipRequest} />}>
<Route element={<ShellConfigurationGate loaded={loaded} />}>
<Route path="apps" element={<div>{enabled ? "Streamlined shell" : "Legacy shell"}</div>} />
</Route>
</Route></Routes>;
}
const container = document.createElement("div");
const root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
try {
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={["/apps"]}><RoutesUnderTest /></MemoryRouter></QueryClientProvider>));
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
expect(health).toHaveBeenCalledTimes(1);
expect(session).toHaveBeenCalledTimes(1);
expect(access).toHaveBeenCalledTimes(1);
expect(container.textContent).not.toContain("Streamlined shell");
expect(container.textContent).not.toContain("Legacy shell");
await act(async () => { resolveSettings({ enableStreamlinedUi: false }); });
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
expect(container.textContent).toBe("Legacy shell");
} finally {
await act(async () => root.unmount());
client.clear();
}
});
it("keeps prepared task content mounted without taking existing focus when the shell becomes visible", async () => {
const container = document.createElement("div");
const root = createRoot(container);
const input = document.createElement("input");
document.body.append(container, input);
const view = (loaded: boolean) => <MemoryRouter initialEntries={["/PAP/issues/PAP-1"]}><Routes>
<Route element={<ShellConfigurationGate loaded={loaded} allowPendingTaskDetail />}>
<Route path="*" element={<main id="main-content" tabIndex={-1} data-testid="prepared-task">Task data</main>} />
</Route>
</Routes></MemoryRouter>;
try {
await act(async () => root.render(view(false)));
const task = container.querySelector('[data-testid="prepared-task"]');
expect(task).not.toBeNull();
input.focus();
await act(async () => root.render(view(true)));
await act(async () => { await new Promise(resolve => requestAnimationFrame(resolve)); });
expect(document.activeElement).toBe(input);
expect(container.querySelector('[data-testid="prepared-task"]')).toBe(task);
expect(container.querySelector<HTMLElement>('[data-testid="shell-prepared-outlet"]')?.hidden).toBe(false);
expect(container.querySelector('[data-testid="shell-configuration-loading"]')).toBeNull();
} finally { await act(async () => root.unmount()); container.remove(); input.remove(); }
});