Files
nexu-io__open-design/apps/web/tests/useWorkspaceContext.invalidation.test.tsx
lefarcen eea8a8522d fix(collab): bound workspace refresh storms (#6871)
* fix(collab): bound workspace refresh storms

* fix(collab): push workspace directory freshness

* test(collab): follow identity-aware hub callbacks

* fix(collab): reset authority on account changes

* fix(collab): fence workspace identity transitions

* test(collab): guard full identity reset
2026-08-14 02:19:51 +00:00

287 lines
9.0 KiB
TypeScript

// @vitest-environment jsdom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetCoalescedGet } from '../src/lib/coalesced-get';
import {
resetWorkspaceContextCache,
useWorkspaceContext,
} from '../src/collab/useWorkspaceContext';
import {
workspaceContextFixture,
workspaceDirectoryFixture,
} from './helpers/workspace-context';
class OpeningEventSource {
static instances: OpeningEventSource[] = [];
static autoOpen = true;
readonly url: string;
readonly withCredentials = false;
readonly CONNECTING = 0;
readonly OPEN = 1;
readonly CLOSED = 2;
readyState = this.CONNECTING;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
private readonly listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
constructor(url: string | URL) {
this.url = String(url);
OpeningEventSource.instances.push(this);
if (OpeningEventSource.autoOpen) queueMicrotask(() => this.open());
}
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
): void {
if (!listener) return;
const listeners = this.listeners.get(type) ?? new Set();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
): void {
if (!listener) return;
this.listeners.get(type)?.delete(listener);
}
dispatchEvent(event: Event): boolean {
for (const listener of this.listeners.get(event.type) ?? []) {
if (typeof listener === 'function') listener.call(this, event);
else listener.handleEvent(event);
}
return true;
}
close(): void {
this.readyState = this.CLOSED;
}
open(): void {
this.readyState = this.OPEN;
this.onopen?.(new Event('open'));
}
emit(type: string, data: unknown = {}): void {
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(data) }));
}
}
const TEAM_CONTEXT = workspaceContextFixture({
workspaceId: 'workspace-team',
workspaceMemberId: 'member-team',
workspaceName: 'Team workspace',
});
const PERSONAL_CONTEXT = workspaceContextFixture({
workspaceId: 'workspace-personal',
workspaceMemberId: 'member-personal',
workspaceName: 'Personal workspace',
workspaceType: 'personal',
role: 'owner',
});
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
describe('useWorkspaceContext invalidation freshness', () => {
beforeEach(() => {
resetCoalescedGet();
resetWorkspaceContextCache();
OpeningEventSource.instances = [];
OpeningEventSource.autoOpen = true;
vi.stubGlobal('EventSource', OpeningEventSource as unknown as typeof EventSource);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
resetCoalescedGet();
resetWorkspaceContextCache();
OpeningEventSource.instances = [];
OpeningEventSource.autoOpen = true;
});
it('revalidates the directory after a pushed membership revocation', async () => {
OpeningEventSource.autoOpen = false;
let membershipActive = true;
let directoryReads = 0;
let contextReads = 0;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/workspace/directory') {
directoryReads += 1;
return jsonResponse(workspaceDirectoryFixture([
membershipActive ? TEAM_CONTEXT : PERSONAL_CONTEXT,
]));
}
if (url === '/api/workspace/context') {
contextReads += 1;
return jsonResponse({
context: membershipActive ? TEAM_CONTEXT : PERSONAL_CONTEXT,
});
}
throw new Error(`unexpected fetch ${url}`);
}));
const hook = renderHook(() => useWorkspaceContext());
await waitFor(() => {
expect(hook.result.current.context?.workspaceId).toBe(TEAM_CONTEXT.workspaceId);
});
expect(directoryReads).toBe(1);
expect(contextReads).toBe(1);
membershipActive = false;
await waitFor(() => expect(OpeningEventSource.instances).toHaveLength(1));
await act(async () => {
OpeningEventSource.instances[0]?.emit('workspace-context-changed', {
type: 'workspace-context-changed',
});
});
await waitFor(() => {
expect(directoryReads).toBe(2);
expect(contextReads).toBe(2);
expect(hook.result.current.context?.workspaceId).toBe(PERSONAL_CONTEXT.workspaceId);
});
});
it('uses a fresh snapshot when the workspace stream first connects', async () => {
OpeningEventSource.autoOpen = false;
let role: 'member' | 'admin' = 'member';
let directoryReads = 0;
let contextReads = 0;
const context = () => workspaceContextFixture({
...TEAM_CONTEXT,
role,
permissions: {
...TEAM_CONTEXT.permissions,
canInviteMembers: role === 'admin',
},
});
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/workspace/directory') {
directoryReads += 1;
return jsonResponse(workspaceDirectoryFixture([context()]));
}
if (url === '/api/workspace/context') {
contextReads += 1;
return jsonResponse({ context: context() });
}
throw new Error(`unexpected fetch ${url}`);
}));
const hook = renderHook(() => useWorkspaceContext());
await waitFor(() => {
expect(hook.result.current.context?.role).toBe('member');
expect(OpeningEventSource.instances).toHaveLength(1);
});
expect(directoryReads).toBe(1);
expect(contextReads).toBe(1);
// The role changed while the browser had no workspace-event sink. Opening
// the stream is the only catch-up signal; settled client caches still hold
// the old member snapshot at this point.
role = 'admin';
await act(async () => {
OpeningEventSource.instances[0]?.open();
});
await waitFor(() => {
expect(directoryReads).toBe(2);
expect(contextReads).toBe(2);
expect(hook.result.current.context).toMatchObject({
role: 'admin',
permissions: { canInviteMembers: true },
});
});
});
it('uses an exact-scope safety read without relisting the account while SSE is connected', async () => {
vi.useFakeTimers();
OpeningEventSource.autoOpen = false;
let directoryReads = 0;
let contextReads = 0;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/workspace/directory') {
directoryReads += 1;
return jsonResponse(workspaceDirectoryFixture([TEAM_CONTEXT]));
}
if (url === '/api/workspace/context') {
contextReads += 1;
return jsonResponse({ context: TEAM_CONTEXT });
}
throw new Error(`unexpected fetch ${url}`);
}));
const hook = renderHook(() => useWorkspaceContext());
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(hook.result.current.context?.workspaceId).toBe(TEAM_CONTEXT.workspaceId);
expect(directoryReads).toBe(1);
expect(contextReads).toBe(1);
await act(async () => {
OpeningEventSource.instances[0]?.open();
await vi.advanceTimersByTimeAsync(0);
});
expect(directoryReads).toBe(2);
expect(contextReads).toBe(2);
await act(async () => {
await vi.advanceTimersByTimeAsync(120_000);
});
expect(directoryReads).toBe(2);
expect(contextReads).toBe(3);
});
it('uses an exact-scope read on focus while the stream remains connected', async () => {
OpeningEventSource.autoOpen = false;
let clock = 0;
vi.spyOn(performance, 'now').mockImplementation(() => clock);
let directoryReads = 0;
let contextReads = 0;
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/workspace/directory') {
directoryReads += 1;
return jsonResponse(workspaceDirectoryFixture([TEAM_CONTEXT]));
}
if (url === '/api/workspace/context') {
contextReads += 1;
return jsonResponse({ context: TEAM_CONTEXT });
}
throw new Error(`unexpected fetch ${url}`);
}));
const hook = renderHook(() => useWorkspaceContext());
await waitFor(() => {
expect(hook.result.current.context?.workspaceId).toBe(TEAM_CONTEXT.workspaceId);
});
act(() => OpeningEventSource.instances[0]?.open());
await waitFor(() => expect(directoryReads).toBe(2));
expect(contextReads).toBe(2);
// Outside forceCoalescedGet's same-transition burst window, this is a
// genuinely separate ambient safety check.
clock = 251;
act(() => window.dispatchEvent(new Event('focus')));
await waitFor(() => expect(contextReads).toBe(3));
expect(directoryReads).toBe(2);
});
});