Files
PaperClipAI/ui/src/context/CompanyContext.test.tsx
T
Devin Foley bc1a21564f Remove the company brand color and per-company attachment limit (#12291)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A company is the top-level container, and the company General page
holds its settings
> - Two of those settings did almost nothing: the brand color only
tinted the generated company icon, and the attachment size limit sat
under the deployment-level `PAPERCLIP_ATTACHMENT_MAX_BYTES` cap that
already bounded every upload
> - A setting that changes one icon hue, and a setting that can only
lower a limit the operator already set, are not worth the page space or
the code that carries them
> - This pull request deletes both settings from the UI, the validators,
the API contract, the server, and the database
> - With the deployment cap as the only limit left, the message a person
sees when an upload is rejected has to name that limit in terms they can
act on, so the raw byte count becomes a human-readable size
> - The benefit is a shorter company General page for every deployment,
one attachment limit instead of two, and less code between an upload and
its ceiling

## Linked Issues or Issue Description

No existing issue. The description below follows
`.github/ISSUE_TEMPLATE/enhancement.yml`.

**What existing behavior does this improve?**

The company General page (`/company/settings`), the `PATCH
/api/companies/{companyId}` and `PATCH
/api/companies/{companyId}/branding` request contracts, and the
attachment upload limit on task, case, and company-import uploads.

**Subsystem affected**

Cross-cutting: `ui/`, `server/`, `packages/shared`, `packages/db`.

**Current behavior**

The company General page shows an "Appearance" section with three
controls: Logo, Brand color, and Attachment size limit. The brand color
is a hex value that feeds one thing — the hue of the generated company
pattern icon. Companies that never set one already get a hue derived
from the company name. The attachment size limit is a per-company byte
count stored on `companies.attachment_max_bytes`. Every upload path
clamps it against the deployment-level `PAPERCLIP_ATTACHMENT_MAX_BYTES`
cap, so the per-company value can only lower a limit the operator
already chose.

**Proposed behavior**

The Appearance section keeps the Logo control only. The company pattern
icon always derives its hue from the company name. Every attachment path
reads the deployment cap directly, so `PAPERCLIP_ATTACHMENT_MAX_BYTES`
is the single limit. An upload rejected by that limit says so in human
units — "File is larger than the 10 MB limit" rather than a raw byte
count. The `companies.brand_color` and `companies.attachment_max_bytes`
columns are dropped, and both fields leave the company API contract.

**Reason and benefit**

Both settings ask an operator to make a decision that changes almost
nothing. The brand color moves one icon hue on a page that also lets you
upload a real logo, which overrides the icon entirely. The attachment
limit reads as a real control but cannot raise anything, so it is a
second place to look when an upload is rejected. Removing both shortens
the page every deployment sees, removes a company-scoped read from the
task attachment upload path, and leaves one attachment limit to reason
about instead of two.

**Breaking changes**

The company API responses no longer include `brandColor` or
`attachmentMaxBytes`, and `GET /api/invites/{token}` no longer includes
`companyBrandColor`. `PATCH /api/companies/{companyId}/branding` is
strict, so a request that sends `brandColor` now returns 400; the
non-strict `PATCH /api/companies/{companyId}` schema strips it. Company
packages exported by older versions still import: the portability
company manifest schema is non-strict, so the retired keys are stripped
and ignored rather than rejected. Companies that stored a brand color
lose it — their icon reverts to the name-derived hue that every company
without a color already used.

## What Changed

- Removed the "Brand color" and "Attachment size limit" fields from the
company General page, along with their state, dirty checks, save
payload, and Save-button gating.
- Removed `brandColor` and `attachmentMaxBytes` from
`createCompanySchema`, `updateCompanySchema`, and
`updateCompanyBrandingSchema`, and deleted the now-orphaned
`DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES` and
`MAX_COMPANY_ATTACHMENT_MAX_BYTES` constants.
- Removed both fields from the `Company` type, the portability manifest
type and schema, and the `companiesApi.update` payload allowlist.
- Dropped `brandColor` from `CompanyPatternIcon` and its callers, so the
icon hue always comes from the company name. Deleted the now-unused
`hexToHue` helper and the now-unused `pickTextColorForSolidBg` export.
- Stopped emitting `brandColor` from the company service selection and
from the invite-summary and invite-branding payloads in
`server/src/routes/access.ts`.
- Replaced `normalizeIssueAttachmentMaxBytes` with the deployment cap:
task attachments, case attachments, and company import now use
`MAX_ATTACHMENT_BYTES` directly. The helper is deleted.
- Added `formatAttachmentSize()` next to `MAX_ATTACHMENT_BYTES` and
routed every over-limit message through it, so a rejected upload names
the limit in human units instead of raw bytes: `Image exceeds 10485760
bytes` becomes `Image is larger than the 10 MB limit`. Enforcement is
unchanged — the same single cap, the same multer limits, the same status
codes and response shapes.
- Added migration
`0229_drop_company_brand_color_and_attachment_max_bytes.sql` and removed
both columns from the Drizzle `companies` schema.
- Kept legacy imports working: the portability company manifest schema
is non-strict, so older packages carrying the retired keys still import
with the keys ignored.
- Updated the skill API reference and the implementation spec, and
pruned the token-extraction allowlist entries that the removed code made
stale.

## Verification

Commands run from the repository root:

- `pnpm --filter @paperclipai/shared typecheck` — pass
- `pnpm --filter @paperclipai/db typecheck` — pass (includes
`check:migrations`, which validates the new migration number and journal
entry)
- `pnpm --filter @paperclipai/ui typecheck` — pass
- server typecheck via `node_modules/.bin/tsc --noEmit` in `server/` —
pass. `pnpm --filter @paperclipai/server typecheck` could not run
locally because it builds the Rust runner first and `cargo` is not
installed on this machine; the TypeScript step it wraps is the command
above.
- `npx vitest run packages/shared/src/validators/company.test.ts` — 6
passed
- `npx vitest run server/src/__tests__/company-portability.test.ts` — 90
passed
- `npx vitest run server/src/__tests__/attachment-types.test.ts
server/src/__tests__/assets.test.ts
server/src/__tests__/issue-attachment-routes.test.ts
server/src/__tests__/company-portability.test.ts
server/src/__tests__/cases-routes.test.ts` — 165 passed (the
human-readable limit messages)
- `npx vitest run server/src/__tests__/company-branding-route.test.ts
server/src/__tests__/issue-attachment-routes.test.ts
server/src/__tests__/invite-summary-route.test.ts
server/src/__tests__/openclaw-invite-prompt-route.test.ts
server/src/__tests__/companies-route-cross-company-authz.test.ts` — all
passed
- `npx vitest run cli/src/__tests__/company.test.ts
cli/src/__tests__/company-delete.test.ts` — 27 passed
- `npx vitest run` in `ui/` — 4425 passed, 1 pre-existing failure
unrelated to this change (`OnboardingWizard.test.tsx` "renders instead
of throwing when the browser denies storage access", which also fails on
`master`)
- `npx vitest run` in `server/` — see the note below
- `node scripts/check-token-gates.mjs` — no new violations; the only
reported violations are the pre-existing `PillGuy.tsx` ones present on
`master`

New tests added:

- `packages/shared/src/validators/company.test.ts` — the create and
update schemas strip the retired keys, the strict branding schema
rejects `brandColor`, and the portability manifest schema accepts a
legacy entry carrying both keys and drops them.
- `server/src/__tests__/company-branding-route.test.ts` — `PATCH
/api/companies/{companyId}/branding` returns 400 for `brandColor` and
does not call the company service.
- `server/src/__tests__/company-portability.test.ts` — a legacy package
that declares `brandColor` and `attachmentMaxBytes` imports
successfully, and neither key reaches `companies.create`.
- `server/src/__tests__/issue-attachment-routes.test.ts` — the effective
task attachment limit is the deployment cap, and the route no longer
loads the company to size an upload.
- `server/src/__tests__/attachment-types.test.ts` —
`formatAttachmentSize()` renders the default cap as `10 MB`, keeps one
decimal place for fractional sizes and drops a trailing `.0`, falls back
to KB and bytes for small caps, steps up to GB, and never emits `NaN`
for a degenerate input.
- `server/src/__tests__/assets.test.ts` — the asset-image and
company-logo routes both return the human-readable limit message on an
over-cap upload.

## Merge with master

`master` moved while this was open, and the merge needed two
resolutions:

- **`ui/src/pages/CompanySettings.tsx`.** #12243 reworded the
user-facing
copy from "company" to "organization", and that rewording landed inside
  the "Brand color" and "Attachment size limit" hints — the two fields
this change deletes. Both fields are removed, so the conflicted block is
dropped whole. The Logo field and every other copy change from #12243
are
  kept.
- **Migration renumbered 0228 -> 0229.** #12307 landed
  `0228_nasty_grim_reaper`, so this migration is now
`0229_drop_company_brand_color_and_attachment_max_bytes`. Its snapshot
is
rebuilt from master's `0228_snapshot.json` with only the two `companies`
  columns removed, and `meta/_journal.json` is master's journal plus a
single `idx: 229` entry. `pnpm --filter @paperclipai/db
check:migrations`
  passes.

The snapshot was rebuilt by hand rather than taken from `drizzle-kit
generate`, because master's `0228_snapshot.json` has drifted from
master's
own schema: `issue_question_response_deliveries.error_count` is created
by
master's 0228 SQL but missing from its snapshot, and the snapshot still
carries `decision_archive_notification_outbox.error_count`. Regenerating
folds both into this migration, and the resulting `ADD COLUMN
error_count`
would fail on a fresh database where master's 0228 already created that
column. Rebuilding from master's snapshot leaves that drift exactly
where
it is and keeps this migration to the two column drops. The drift is
pre-existing on master and is not addressed here.

## Risks

- **The migration is a destructive column drop.**
`0229_drop_company_brand_color_and_attachment_max_bytes.sql` removes
`companies.brand_color` and `companies.attachment_max_bytes`. It is safe
because both features are removed in the same change and nothing reads
either column after it. The statements use `DROP COLUMN IF EXISTS`,
matching the convention of the recent drop migrations in this
repository. The drop is not reversible: a downgrade after this migration
loses any stored values.
- **Stored brand colors are lost.** A company that had set a color now
renders the name-derived icon hue that every company without a color
already used. No other surface changes, and an uploaded logo still
overrides the icon.
- **API response shape narrows.** `brandColor` and `attachmentMaxBytes`
leave the company payloads, and `companyBrandColor` leaves the invite
summary payload. A client reading those fields now sees `undefined`. The
bundled UI and CLI are updated in this change.
- **Legacy imports are covered.** Packages exported by older versions
still carry both keys. The manifest schema is non-strict, so the keys
are stripped rather than rejected, and a test locks that in.
- **The over-limit message strings changed.** Anything matching on the
old `... exceeds N bytes` text — a test, a script, or a client that
string-matches `body.error` — needs updating. The status codes (422) and
response shapes are unchanged, so structured clients are unaffected.
- **Attachment limits can only widen.** A deployment that had lowered a
company below the deployment cap now allows uploads up to the cap for
that company. Lower `PAPERCLIP_ATTACHMENT_MAX_BYTES` if a smaller
ceiling is needed.
- **Storybook visual baselines shift** for the `CompanyPatternIcon`
matrix story, because those fixtures had brand colors. That workflow
runs only on a PR labeled `storybook-visual`, so it does not gate this
PR; regenerate the baselines if the label is added.

## Model Used

Claude (Anthropic), Claude Opus, agentic tool use via Claude Code.

## 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
- [x] 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
2026-08-27 12:11:05 -07:00

530 lines
20 KiB
TypeScript

// @vitest-environment jsdom
import { act, useEffect } from "react";
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 type { Company } from "@paperclipai/shared";
import { queryKeys } from "../lib/queryKeys";
import {
CompanyProvider,
resolveBootstrapCompanySelection,
shouldClearStoredCompanySelection,
useCompany,
} from "./CompanyContext";
const mockCompaniesApi = vi.hoisted(() => ({
list: vi.fn(),
create: vi.fn(),
detachInflightList: vi.fn(),
}));
vi.mock("../api/companies", () => ({
companiesApi: mockCompaniesApi,
}));
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
}));
vi.mock("../api/auth", () => ({
authApi: mockAuthApi,
}));
function sessionFor(userId: string) {
return {
session: { id: `session-${userId}`, userId },
user: { id: userId, name: "Example", email: `${userId}@example.com`, image: null },
};
}
const activeCompany = { id: "company-1" };
const secondActiveCompany = { id: "company-2" };
const archivedCompany = { id: "archived-company" };
function makeCompany(id: string): Company {
return {
id,
name: "Paperclip",
description: null,
status: "active",
pauseReason: null,
pausedAt: null,
issuePrefix: "PAP",
issueCounter: 1,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
defaultResponsibleUserId: null,
requireBoardApprovalForNewAgents: false,
interactionResolverGovernance: {},
feedbackDataSharingEnabled: false,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
logoAssetId: null,
logoUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
};
}
async function flushReact() {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
let captured: ReturnType<typeof useCompany> | null = null;
function Probe({ onSelectedCompanyId }: { onSelectedCompanyId: (companyId: string | null) => void }) {
const company = useCompany();
captured = company;
const { selectedCompanyId } = company;
useEffect(() => {
onSelectedCompanyId(selectedCompanyId);
}, [onSelectedCompanyId, selectedCompanyId]);
return <div data-selected-company-id={selectedCompanyId ?? ""} />;
}
describe("resolveBootstrapCompanySelection", () => {
it("does not expose a stale stored company id before companies load", () => {
expect(resolveBootstrapCompanySelection({
companies: [],
sidebarCompanies: [],
selectedCompanyId: null,
storedCompanyId: "stale-company",
})).toBeNull();
});
it("replaces a stale stored company id with the first loaded company", () => {
expect(resolveBootstrapCompanySelection({
companies: [activeCompany],
sidebarCompanies: [activeCompany],
selectedCompanyId: null,
storedCompanyId: "stale-company",
})).toBe("company-1");
});
it("keeps a valid selected company ahead of stored bootstrap state", () => {
expect(resolveBootstrapCompanySelection({
companies: [activeCompany],
sidebarCompanies: [activeCompany],
selectedCompanyId: "company-1",
storedCompanyId: "stale-company",
})).toBe("company-1");
});
it("keeps a valid stored company id instead of falling back to the first company", () => {
expect(resolveBootstrapCompanySelection({
companies: [activeCompany, secondActiveCompany],
sidebarCompanies: [activeCompany, secondActiveCompany],
selectedCompanyId: null,
storedCompanyId: "company-2",
})).toBe("company-2");
});
it("uses selectable sidebar companies before archived companies", () => {
expect(resolveBootstrapCompanySelection({
companies: [archivedCompany, activeCompany],
sidebarCompanies: [activeCompany],
selectedCompanyId: null,
storedCompanyId: "archived-company",
})).toBe("company-1");
});
it("keeps an explicitly selected archived company that still exists", () => {
// The Layout route-sync selects the company the URL names, archived
// included. Vetoing that selection here re-selected an active company,
// the route-sync selected the archived one again, and the two effects
// ping-ponged until React threw #185 and unmounted the app.
expect(resolveBootstrapCompanySelection({
companies: [archivedCompany, activeCompany],
sidebarCompanies: [activeCompany],
selectedCompanyId: "archived-company",
storedCompanyId: null,
})).toBe("archived-company");
});
it("still replaces a selected company that no longer exists at all", () => {
expect(resolveBootstrapCompanySelection({
companies: [activeCompany],
sidebarCompanies: [activeCompany],
selectedCompanyId: "deleted-company",
storedCompanyId: null,
})).toBe("company-1");
});
});
describe("shouldClearStoredCompanySelection", () => {
it("does not clear the stored company selection during an unauthorized company list response", () => {
expect(shouldClearStoredCompanySelection({
companies: [],
isLoading: false,
unauthorized: true,
errored: false,
})).toBe(false);
});
it("clears the stored company selection when an authorized company list is empty", () => {
expect(shouldClearStoredCompanySelection({
companies: [],
isLoading: false,
unauthorized: false,
errored: false,
})).toBe(true);
});
it("does not clear the stored company selection when the request failed", () => {
// `companiesListQueryOptions` sets `retry: false`, and a failure before any
// success leaves `data` undefined — which the provider defaults to an empty
// list. That is indistinguishable from "asked, and owns nothing", so
// without this a single failed request on a cold load would drop the
// customer's stored company.
expect(shouldClearStoredCompanySelection({
companies: [],
isLoading: false,
unauthorized: false,
errored: true,
})).toBe(false);
});
});
describe("CompanyProvider", () => {
let container: HTMLDivElement;
let root: Root;
let queryClient: QueryClient;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
captured = null;
localStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
mockAuthApi.getSession.mockResolvedValue(null);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
queryClient.clear();
container.remove();
vi.clearAllMocks();
});
it("keeps the stored company when the company request fails", async () => {
// The seam the predicate test above cannot reach: the provider defaults a
// failed request to an empty list, so the effect has to be told the
// request errored or it reads that as "asked, and owns nothing" and clears
// the customer's stored company.
localStorage.setItem("paperclip.selectedCompanyId", "company-a");
mockCompaniesApi.list.mockRejectedValue(new Error("companies unavailable"));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyProvider>
<Probe onSelectedCompanyId={() => {}} />
</CompanyProvider>
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-a");
});
it("does not expose a stale stored company id before companies load", async () => {
localStorage.setItem("paperclip.selectedCompanyId", "stale-company");
mockCompaniesApi.list.mockImplementation(() => new Promise(() => {}));
const seen: Array<string | null> = [];
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyProvider>
<Probe onSelectedCompanyId={(companyId) => seen.push(companyId)} />
</CompanyProvider>
</QueryClientProvider>,
);
});
expect(seen).toEqual([null]);
});
it("replaces a stale stored company id with the first loaded company", async () => {
localStorage.setItem("paperclip.selectedCompanyId", "stale-company");
// Signed out, and *known* to be: the list is keyed by account, so it cannot
// resolve before the session does. Seeding the answer is how this test says
// the account lookup has already happened.
queryClient.setQueryData(queryKeys.auth.session, null);
queryClient.setQueryData(queryKeys.companies.list(null), {
companies: [makeCompany("company-1")],
unauthorized: false,
});
mockCompaniesApi.list.mockImplementation(() => new Promise(() => {}));
const seen: Array<string | null> = [];
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyProvider>
<Probe onSelectedCompanyId={(companyId) => seen.push(companyId)} />
</CompanyProvider>
</QueryClientProvider>,
);
});
expect(seen).toEqual([null, "company-1"]);
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1");
});
// The `["companies"]` cache entry is shared app-wide and carries no account
// identity, so it survives a change of account in this tab. Cached data is
// served whether or not it is stale, so freshness cannot stand in for
// "belongs to the account signed in now".
describe("when the account changes in this tab", () => {
async function bootWithFirstAccount(seen: Array<string | null>) {
mockAuthApi.getSession.mockResolvedValue(sessionFor("user-1"));
queryClient.setQueryData(queryKeys.companies.list("user-1"), {
companies: [makeCompany("company-1")],
unauthorized: false,
});
mockCompaniesApi.list.mockImplementation(() => new Promise(() => {}));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyProvider>
<Probe onSelectedCompanyId={(companyId) => seen.push(companyId)} />
</CompanyProvider>
</QueryClientProvider>,
);
});
await flushReact();
expect(seen).toEqual([null, "company-1"]);
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1");
}
it("drops the previous account's selection and re-reads the list", async () => {
const seen: Array<string | null> = [];
await bootWithFirstAccount(seen);
let resolveSecondList: ((companies: Company[]) => void) | null = null;
mockCompaniesApi.list.mockImplementation(
() =>
new Promise<Company[]>((resolve) => {
resolveSecondList = resolve;
}),
);
await act(async () => {
queryClient.setQueryData(queryKeys.auth.session, sessionFor("user-2"));
});
await flushReact();
// Nothing from the previous account is exposed while the new list loads.
expect(seen).toEqual([null, "company-1", null]);
expect(queryClient.getQueryData(queryKeys.companies.list("user-2"))).toBeUndefined();
// The previous account's entry may still sit in the cache; keying by
// account is what makes that harmless, since nothing reads it now.
expect(queryClient.getQueryData(queryKeys.companies.list("user-1"))).toMatchObject({
companies: [{ id: "company-1" }],
});
expect(resolveSecondList).not.toBeNull();
// The replacement fetch must not be coalesced into a `/companies` request
// issued under the previous session.
expect(mockCompaniesApi.detachInflightList).toHaveBeenCalled();
await act(async () => {
resolveSecondList?.([makeCompany("company-2")]);
await Promise.resolve();
});
await flushReact();
expect(seen).toEqual([null, "company-1", null, "company-2"]);
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-2");
});
// The replacement fetch is the only thing standing between the account
// change and a usable app: the cache entry has already been removed, so a
// failure here leaves the tab with no list and no selection. It must not
// also leave it with no way out.
it("recovers the list when the replacement fetch fails and is retried", async () => {
const seen: Array<string | null> = [];
await bootWithFirstAccount(seen);
mockCompaniesApi.list.mockRejectedValue(new Error("network down"));
await act(async () => {
queryClient.setQueryData(queryKeys.auth.session, sessionFor("user-2"));
});
await flushReact();
// Past the bounded retries, so the failure has actually settled.
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 1200));
});
await flushReact();
// The previous account's company is gone and nothing took its place.
expect(seen).toEqual([null, "company-1", null]);
// Reported as unavailable, not as an account that owns nothing. Without
// this the switcher renders "No companies", which is a false claim.
expect(captured?.companyListUnavailable).toBe(true);
expect(captured?.companies).toEqual([]);
// The recovery path actually recovers.
mockCompaniesApi.list.mockResolvedValue([makeCompany("company-2")]);
await act(async () => {
await captured?.retryCompanies();
});
await flushReact();
expect(captured?.companyListUnavailable).toBe(false);
expect(seen).toEqual([null, "company-1", null, "company-2"]);
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-2");
});
// A transient blip must not need the customer to notice and click anything.
// Nothing configures a retry — the second attempt is the observer's own,
// issued when it rebinds to a fresh query after the removal above. This
// pins that, so the recovery affordance stays reserved for real outages
// rather than becoming the only way back from a one-off blip.
it("rides out a single failed replacement request without help", async () => {
const seen: Array<string | null> = [];
await bootWithFirstAccount(seen);
mockCompaniesApi.list
.mockRejectedValueOnce(new Error("blip"))
.mockResolvedValue([makeCompany("company-2")]);
await act(async () => {
queryClient.setQueryData(queryKeys.auth.session, sessionFor("user-2"));
});
await flushReact();
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 1200));
});
await flushReact();
expect(captured?.companyListUnavailable).toBe(false);
expect(seen).toEqual([null, "company-1", null, "company-2"]);
});
// The failure flag must not outlive the failure. A later success — a focus
// refetch, an invalidation from anywhere — settles the question, including
// when the honest answer is an empty list. Otherwise an account that owns
// nothing reads as "couldn't load", behind a retry that cannot change it.
it("stops reporting the list as unavailable once any fetch succeeds", async () => {
const seen: Array<string | null> = [];
await bootWithFirstAccount(seen);
mockCompaniesApi.list.mockRejectedValue(new Error("network down"));
await act(async () => {
queryClient.setQueryData(queryKeys.auth.session, sessionFor("user-2"));
});
await flushReact();
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 1200));
});
await flushReact();
expect(captured?.companyListUnavailable).toBe(true);
// This account genuinely owns no companies, and the request says so.
mockCompaniesApi.list.mockResolvedValue([]);
await act(async () => {
await queryClient.refetchQueries({ queryKey: queryKeys.companies.list("user-2") });
});
await flushReact();
expect(captured?.companies).toEqual([]);
expect(captured?.companyListUnavailable).toBe(false);
});
// The guarantee the account-keyed entry buys, stated directly: a list sitting
// in the cache for another account is not merely distrusted, it is not
// reachable. No gate, no freshness check, no refetch stands between the two —
// they are different entries.
it("cannot read another account's cached list at all", async () => {
const seen: Array<string | null> = [];
await bootWithFirstAccount(seen);
// user-2's list is slow. If the entries were shared, the cached user-1
// list would answer in the meantime — which is the whole bug.
mockCompaniesApi.list.mockImplementation(() => new Promise(() => {}));
await act(async () => {
queryClient.setQueryData(queryKeys.auth.session, sessionFor("user-2"));
});
await flushReact();
expect(captured?.companies).toEqual([]);
expect(captured?.selectedCompanyId).toBeNull();
expect(captured?.loading).toBe(true);
// Still cached, still keyed to whom it belongs, still unread.
expect(queryClient.getQueryData(queryKeys.companies.list("user-1"))).toMatchObject({
companies: [{ id: "company-1" }],
});
});
// A session request that failed answers nothing. Keying the list to
// `anonymous` on that basis would write an authenticated response — the
// request still carries the cookie — into the entry every signed-out reader
// trusts, which is the original bug rebuilt for anonymous.
//
// Cold cache on purpose: after a prior success React Query retains the old
// session `data` through a failed refetch, so the identity survives anyway
// and the bug does not show. It needs a session that never answered.
it("does not treat a never-answered session as a signed-out session", async () => {
mockAuthApi.getSession.mockRejectedValue(new Error("network down"));
localStorage.setItem("paperclip.selectedCompanyId", "company-1");
mockCompaniesApi.list.mockResolvedValue([makeCompany("company-1")]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyProvider>
<Probe onSelectedCompanyId={() => undefined} />
</CompanyProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
// Nothing asked for a list it could not attribute, and nothing landed in
// the anonymous entry.
expect(mockCompaniesApi.list).not.toHaveBeenCalled();
expect(queryClient.getQueryData(queryKeys.companies.list(null))).toBeUndefined();
// And the stored company survives: unavailable is not "owns nothing".
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1");
});
it("leaves the selection alone when the same account is observed again", async () => {
const seen: Array<string | null> = [];
await bootWithFirstAccount(seen);
const listCallsAfterBoot = mockCompaniesApi.list.mock.calls.length;
// A session refetch returns an equal-but-new object for the same account.
await act(async () => {
queryClient.setQueryData(queryKeys.auth.session, sessionFor("user-1"));
});
await flushReact();
expect(seen).toEqual([null, "company-1"]);
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1");
expect(mockCompaniesApi.list.mock.calls.length).toBe(listCallsAfterBoot);
});
});
});