Files
nexu-io__open-design/apps/web/tests/useProjectRouteWorkspaceContext.test.tsx
lefarcen 67576cb0b6 fix(collab): keep workspace projects usable during outages and first open (#7027)
* fix(daemon): decouple local data plane from workspace authority

* test(e2e): align workspace authority contracts

* fix(daemon): preserve read-only team mirrors offline

* test(daemon): preserve retryable result literal

* fix(web): keep projects usable during workspace outages

* test(web): keep collaboration mounted during outages

* test(daemon): keep viewer comments local during outages

* fix(collab): open team projects during materialization

* fix(collab): keep project placeholders fail-closed
2026-08-18 11:36:14 +00:00

612 lines
20 KiB
TypeScript

// @vitest-environment jsdom
import { useEffect } from 'react';
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { WorkspaceCollabContext } from '@open-design/contracts';
import {
projectResourceReadsCanStart,
useProjectRouteWorkspaceContext,
} from '../src/collab/useProjectRouteWorkspaceContext';
import {
currentWorkspaceAccountGeneration,
notifyWorkspaceContextRefresh,
resetWorkspaceContextCache,
type WorkspaceContextState,
} from '../src/collab/useWorkspaceContext';
import {
workspaceIdentityCacheKey,
workspaceProjectHeaders,
} from '../src/collab/workspace-identity';
import {
workspaceContextFixture,
workspaceDirectoryFixture,
} from './helpers/workspace-context';
const PROJECT_ID = 'project-deep-link';
const PROJECT_ENDPOINTS = [
`/api/projects/${PROJECT_ID}/folders`,
`/api/projects/${PROJECT_ID}/collab/status`,
`/api/projects/${PROJECT_ID}/workspace-scope`,
`/api/projects/${PROJECT_ID}`,
`/api/projects/${PROJECT_ID}/files`,
`/api/projects/${PROJECT_ID}/conversations`,
`/api/projects/${PROJECT_ID}/tabs`,
`/api/projects/${PROJECT_ID}/live-artifacts`,
] as const;
const WORKSPACE_A = workspaceContextFixture({
workspaceId: 'workspace-a',
workspaceMemberId: 'member-a',
role: 'owner',
workspaceName: 'Workspace A',
});
const WORKSPACE_B = workspaceContextFixture({
workspaceId: 'workspace-b',
workspaceMemberId: 'member-b',
role: 'member',
workspaceName: 'Workspace B',
});
interface RequestRecord {
url: string;
headers: Headers;
}
function ProjectResourceFanout(props: {
persistedWorkspaceId?: string;
ambientState: WorkspaceContextState;
}) {
const resolved = useProjectRouteWorkspaceContext(
props.persistedWorkspaceId,
props.ambientState,
);
const authorityKey = workspaceIdentityCacheKey(resolved.context);
const canStart = projectResourceReadsCanStart(
props.persistedWorkspaceId,
resolved,
);
useEffect(() => {
if (!canStart) return;
const headers = resolved.context
? workspaceProjectHeaders(resolved.context)
: undefined;
for (const endpoint of PROJECT_ENDPOINTS) {
void fetch(endpoint, headers ? { headers } : undefined);
}
}, [authorityKey, canStart, resolved.context]);
return <output data-testid="scope">{canStart ? authorityKey : 'waiting'}</output>;
}
afterEach(() => {
vi.useRealTimers();
cleanup();
vi.unstubAllGlobals();
resetWorkspaceContextCache();
});
describe('fresh project route Workspace gate', () => {
it('adopts a bootstrap witness without waiting for the ambient directory read', async () => {
const fetchMock = vi.fn(() => new Promise<Response>(() => {}));
vi.stubGlobal('fetch', fetchMock);
const hook = renderHook(() => useProjectRouteWorkspaceContext(
WORKSPACE_A.workspaceId,
{ context: null, loading: true },
WORKSPACE_A,
));
expect(hook.result.current).toMatchObject({
context: WORKSPACE_A,
loading: false,
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('separates ambient Workspace selection from real account generations', () => {
expect(currentWorkspaceAccountGeneration()).toBe(0);
notifyWorkspaceContextRefresh({ context: WORKSPACE_B });
expect(currentWorkspaceAccountGeneration()).toBe(0);
notifyWorkspaceContextRefresh();
expect(currentWorkspaceAccountGeneration()).toBe(1);
});
it('waits for persisted Workspace A and emits exactly one fully scoped resource wave even when ambient is B', async () => {
let resolveDirectory!: (response: Response) => void;
const directoryResponse = new Promise<Response>((resolve) => {
resolveDirectory = resolve;
});
const requests: RequestRecord[] = [];
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
requests.push({ url, headers: new Headers(init?.headers) });
if (url === '/api/workspace/directory') return directoryResponse;
return new Response('{}', {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
const view = render(
<ProjectResourceFanout
persistedWorkspaceId={WORKSPACE_A.workspaceId}
ambientState={{ context: WORKSPACE_B, loading: false }}
/>,
);
expect(view.getByTestId('scope').textContent).toBe('waiting');
expect(requests.map((request) => request.url)).toEqual(['/api/workspace/directory']);
expect(
requests.filter((request) => request.url.startsWith(`/api/projects/${PROJECT_ID}`)),
).toHaveLength(0);
await act(async () => {
resolveDirectory(new Response(JSON.stringify(workspaceDirectoryFixture([
WORKSPACE_A,
WORKSPACE_B,
])), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
await directoryResponse;
});
await waitFor(() => {
expect(view.getByTestId('scope').textContent).toBe(
workspaceIdentityCacheKey(WORKSPACE_A),
);
});
const projectRequests = requests.filter(
(request) => request.url.startsWith(`/api/projects/${PROJECT_ID}`),
);
expect(projectRequests.map((request) => request.url)).toEqual(PROJECT_ENDPOINTS);
for (const endpoint of PROJECT_ENDPOINTS) {
expect(projectRequests.filter((request) => request.url === endpoint)).toHaveLength(1);
}
for (const request of projectRequests) {
expect(request.headers.get('x-od-workspace-id')).toBe(WORKSPACE_A.workspaceId);
expect(request.headers.get('x-od-workspace-member-id')).toBe(
WORKSPACE_A.workspaceMemberId,
);
expect(request.headers.get('x-od-workspace-id')).not.toBe(
WORKSPACE_B.workspaceId,
);
}
expect(
projectRequests.filter(
(request) =>
!request.headers.get('x-od-workspace-id')
|| !request.headers.get('x-od-workspace-member-id'),
),
).toHaveLength(0);
expect(requests.filter((request) => request.url === '/api/workspace/directory')).toHaveLength(1);
});
it('keeps an anonymous unbound local project on the immediate headerless compatibility lane', async () => {
const requests: RequestRecord[] = [];
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ url: String(input), headers: new Headers(init?.headers) });
return new Response('{}', {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
const view = render(
<ProjectResourceFanout
ambientState={{ context: null, loading: false }}
/>,
);
await waitFor(() => {
expect(view.getByTestId('scope').textContent).toBe('none');
expect(requests).toHaveLength(PROJECT_ENDPOINTS.length);
});
expect(requests.map((request) => request.url)).toEqual(PROJECT_ENDPOINTS);
expect(requests.some((request) => request.url === '/api/workspace/directory')).toBe(false);
for (const request of requests) {
expect(request.headers.get('x-od-workspace-id')).toBeNull();
expect(request.headers.get('x-od-workspace-member-id')).toBeNull();
}
});
it('derives the complete read authority from directory role/lifecycle instead of ambient defaults', async () => {
const lockedAdmin = workspaceContextFixture({
workspaceId: 'workspace-locked',
workspaceMemberId: 'member-admin',
role: 'admin',
lifecycleState: 'locked',
workspaceName: 'Locked team',
});
vi.stubGlobal('fetch', vi.fn(async () => new Response(
JSON.stringify(workspaceDirectoryFixture([lockedAdmin])),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
)));
let resolved: WorkspaceCollabContext | null = null;
function Probe() {
const state = useProjectRouteWorkspaceContext(
lockedAdmin.workspaceId,
{ context: WORKSPACE_B, loading: false },
);
resolved = state.context;
return null;
}
render(<Probe />);
await waitFor(() => expect(resolved).not.toBeNull());
expect(resolved).toMatchObject({
workspaceId: lockedAdmin.workspaceId,
workspaceMemberId: lockedAdmin.workspaceMemberId,
workspaceType: 'team',
role: 'admin',
lifecycleState: 'locked',
permissions: {
canShareProjects: false,
canWriteSyncedFiles: false,
},
});
});
it('surfaces a terminal denial and recovers through an explicit fresh retry', async () => {
let membershipAvailable = false;
const fetchMock = vi.fn(async () => new Response(
JSON.stringify(workspaceDirectoryFixture(
membershipAvailable ? [WORKSPACE_A] : [],
)),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
));
vi.stubGlobal('fetch', fetchMock);
const hook = renderHook(() => useProjectRouteWorkspaceContext(
WORKSPACE_A.workspaceId,
{ context: WORKSPACE_B, loading: false },
));
await waitFor(() => {
expect(hook.result.current).toMatchObject({
context: null,
loading: false,
failure: 'forbidden',
});
});
membershipAvailable = true;
act(() => hook.result.current.retry());
await waitFor(() => {
expect(hook.result.current.context?.workspaceId).toBe(WORKSPACE_A.workspaceId);
expect(hook.result.current.loading).toBe(false);
expect(hook.result.current.failure).toBeUndefined();
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('keeps the last verified member authority through a transient directory outage', async () => {
const memberContext = workspaceContextFixture({
workspaceId: WORKSPACE_A.workspaceId,
workspaceMemberId: 'member-a-readonly',
role: 'member',
workspaceName: WORKSPACE_A.workspaceName,
});
let directoryUnavailable = false;
const fetchMock = vi.fn(async () => {
if (directoryUnavailable) {
return new Response(JSON.stringify({ error: 'temporarily unavailable' }), {
status: 503,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify(workspaceDirectoryFixture([memberContext])), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
const hook = renderHook(() => useProjectRouteWorkspaceContext(
memberContext.workspaceId,
{ context: WORKSPACE_B, loading: false },
));
await waitFor(() => {
expect(hook.result.current.context).toMatchObject({
workspaceId: memberContext.workspaceId,
workspaceMemberId: memberContext.workspaceMemberId,
role: 'member',
});
});
const verifiedMemberContext = hook.result.current.context;
expect(verifiedMemberContext).not.toBeNull();
directoryUnavailable = true;
act(() => window.dispatchEvent(new Event('focus')));
await waitFor(() => {
expect(hook.result.current).toMatchObject({
context: verifiedMemberContext,
loading: false,
failure: 'unavailable',
});
});
directoryUnavailable = false;
act(() => hook.result.current.retry());
await waitFor(() => {
expect(hook.result.current.context).toEqual(verifiedMemberContext);
expect(hook.result.current.failure).toBeUndefined();
});
});
it('keeps the open project authority when an ambient Workspace switch cannot revalidate it', async () => {
let directoryRejected = false;
vi.stubGlobal('fetch', vi.fn(async () => directoryRejected
? new Response(JSON.stringify({ error: 'account authorization unavailable' }), {
status: 403,
headers: { 'content-type': 'application/json' },
})
: new Response(JSON.stringify(workspaceDirectoryFixture([WORKSPACE_A, WORKSPACE_B])), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
const hook = renderHook(() => useProjectRouteWorkspaceContext(
WORKSPACE_A.workspaceId,
{ context: WORKSPACE_B, loading: false },
));
await waitFor(() => expect(hook.result.current.context).toMatchObject({
workspaceId: WORKSPACE_A.workspaceId,
workspaceMemberId: WORKSPACE_A.workspaceMemberId,
}));
const projectAuthority = hook.result.current.context;
directoryRejected = true;
act(() => notifyWorkspaceContextRefresh({ context: WORKSPACE_B }));
await waitFor(() => {
expect(hook.result.current).toMatchObject({
context: projectAuthority,
loading: false,
failure: 'unavailable',
});
});
expect(currentWorkspaceAccountGeneration()).toBe(0);
});
it('revokes a retained authority after a successful directory response removes the member', async () => {
let membershipAvailable = true;
vi.stubGlobal('fetch', vi.fn(async () => new Response(
JSON.stringify(workspaceDirectoryFixture(
membershipAvailable ? [WORKSPACE_A] : [],
)),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
)));
const hook = renderHook(() => useProjectRouteWorkspaceContext(
WORKSPACE_A.workspaceId,
{ context: WORKSPACE_B, loading: false },
));
await waitFor(() => expect(hook.result.current.context).toMatchObject({
workspaceId: WORKSPACE_A.workspaceId,
workspaceMemberId: WORKSPACE_A.workspaceMemberId,
role: WORKSPACE_A.role,
}));
membershipAvailable = false;
act(() => window.dispatchEvent(new Event('pageshow')));
await waitFor(() => {
expect(hook.result.current).toMatchObject({
context: null,
loading: false,
failure: 'forbidden',
});
});
});
it('does not promote an incomplete legacy bootstrap row during an outage', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(
JSON.stringify({ error: 'temporarily unavailable' }),
{
status: 503,
headers: { 'content-type': 'application/json' },
},
)));
const legacyBootstrap = {
...WORKSPACE_A,
workspaceMemberId: '',
};
const hook = renderHook(() => useProjectRouteWorkspaceContext(
WORKSPACE_A.workspaceId,
{ context: WORKSPACE_B, loading: false },
legacyBootstrap,
));
await waitFor(() => {
expect(hook.result.current).toMatchObject({
context: null,
loading: false,
failure: 'unavailable',
});
});
});
it('retries transient failures with jittered exponential backoff and resets after recovery', async () => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(0);
let directoryUnavailable = false;
let directoryReads = 0;
vi.stubGlobal('fetch', vi.fn(async () => {
directoryReads += 1;
return directoryUnavailable
? new Response(JSON.stringify({ error: 'temporarily unavailable' }), {
status: 503,
headers: { 'content-type': 'application/json' },
})
: new Response(JSON.stringify(workspaceDirectoryFixture([WORKSPACE_A])), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}));
const hook = renderHook(() => useProjectRouteWorkspaceContext(
WORKSPACE_A.workspaceId,
{ context: WORKSPACE_B, loading: false },
));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(hook.result.current.context?.workspaceId).toBe(WORKSPACE_A.workspaceId);
directoryUnavailable = true;
await act(async () => {
hook.result.current.retry();
await vi.advanceTimersByTimeAsync(0);
});
expect(directoryReads).toBe(2);
expect(hook.result.current.failure).toBe('unavailable');
// random=0 applies the minimum 0.5 jitter multiplier: 1s base -> 500ms.
await act(async () => {
await vi.advanceTimersByTimeAsync(499);
});
expect(directoryReads).toBe(2);
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(directoryReads).toBe(3);
// The next base doubled to 2s -> 1s with the same deterministic jitter.
directoryUnavailable = false;
await act(async () => {
await vi.advanceTimersByTimeAsync(999);
});
expect(directoryReads).toBe(3);
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(directoryReads).toBe(4);
expect(hook.result.current.failure).toBeUndefined();
// A genuine success resets the next failure to the initial 500ms delay.
// Move beyond the directory single-flight share window before starting a
// genuinely new failure cycle.
await act(async () => {
await vi.advanceTimersByTimeAsync(1_001);
});
directoryUnavailable = true;
await act(async () => {
hook.result.current.retry();
await vi.advanceTimersByTimeAsync(0);
});
const readsBeforeResetRetry = directoryReads;
await act(async () => {
await vi.advanceTimersByTimeAsync(499);
});
expect(directoryReads).toBe(readsBeforeResetRetry);
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(directoryReads).toBe(readsBeforeResetRetry + 1);
});
it('fails closed across an account generation change and never reuses old member headers', async () => {
const oldMembership = WORKSPACE_A;
const newMembership = {
...WORKSPACE_A,
workspaceMemberId: 'member-a-new-account',
};
let resolveFreshDirectory!: (response: Response) => void;
const freshDirectory = new Promise<Response>((resolve) => {
resolveFreshDirectory = resolve;
});
let directoryReads = 0;
const requests: RequestRecord[] = [];
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
requests.push({ url, headers: new Headers(init?.headers) });
if (url === '/api/workspace/directory') {
directoryReads += 1;
if (directoryReads === 1) {
return new Response(JSON.stringify(workspaceDirectoryFixture([oldMembership])), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return freshDirectory;
}
return new Response('{}', {
status: 200,
headers: { 'content-type': 'application/json' },
});
}));
const view = render(
<ProjectResourceFanout
persistedWorkspaceId={WORKSPACE_A.workspaceId}
ambientState={{ context: WORKSPACE_B, loading: false }}
/>,
);
await waitFor(() => {
expect(view.getByTestId('scope').textContent).toBe(
workspaceIdentityCacheKey(oldMembership),
);
});
expect(
requests.filter((request) => request.url.startsWith(`/api/projects/${PROJECT_ID}`)),
).toHaveLength(PROJECT_ENDPOINTS.length);
act(() => notifyWorkspaceContextRefresh());
expect(view.getByTestId('scope').textContent).toBe('waiting');
expect(
requests.filter((request) => request.url.startsWith(`/api/projects/${PROJECT_ID}`)),
).toHaveLength(PROJECT_ENDPOINTS.length);
await act(async () => {
resolveFreshDirectory(new Response(
JSON.stringify(workspaceDirectoryFixture([newMembership])),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
));
await freshDirectory;
});
await waitFor(() => {
expect(view.getByTestId('scope').textContent).toBe(
workspaceIdentityCacheKey(newMembership),
);
});
const projectRequests = requests.filter(
(request) => request.url.startsWith(`/api/projects/${PROJECT_ID}`),
);
expect(projectRequests).toHaveLength(PROJECT_ENDPOINTS.length * 2);
expect(
projectRequests.slice(PROJECT_ENDPOINTS.length).every(
(request) =>
request.headers.get('x-od-workspace-member-id')
=== newMembership.workspaceMemberId,
),
).toBe(true);
});
});