2026-05-08 14:27:46 +08:00
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
buildPersistedConfig,
|
2026-05-11 20:52:45 +08:00
|
|
|
isAutosaveDraftOnlyChange,
|
2026-08-05 08:31:54 +08:00
|
|
|
hydrateReadyTeamProject,
|
2026-07-22 16:11:03 +09:00
|
|
|
mergeAgentModelChoice,
|
2026-05-08 14:27:46 +08:00
|
|
|
persistComposioConfigChange,
|
2026-08-05 08:31:54 +08:00
|
|
|
projectViewAuthorizationLifetimeKey,
|
|
|
|
|
projectRouteSurfaceState,
|
|
|
|
|
resolveDeepLinkedTeamSharedProject,
|
2026-05-08 14:27:46 +08:00
|
|
|
resolveSettingsCloseConfig,
|
2026-08-05 08:31:54 +08:00
|
|
|
shouldRouteToFirstRunOnboarding,
|
2026-05-08 14:27:46 +08:00
|
|
|
shouldSyncMediaProvidersOnSave,
|
|
|
|
|
} from '../src/App';
|
2026-08-05 08:31:54 +08:00
|
|
|
import type { AppConfig, Project } from '../src/types';
|
|
|
|
|
import type {
|
|
|
|
|
WorkspaceCollabContext,
|
|
|
|
|
WorkspaceProjectSummary,
|
|
|
|
|
} from '@open-design/contracts';
|
|
|
|
|
|
|
|
|
|
describe('projectRouteSurfaceState', () => {
|
|
|
|
|
it('only shows an unbounded loader while the initial project list is loading', () => {
|
|
|
|
|
expect(projectRouteSurfaceState({
|
|
|
|
|
projectsLoading: true,
|
|
|
|
|
hasActiveProject: false,
|
|
|
|
|
daemonLive: false,
|
|
|
|
|
})).toBe('loading-projects');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('makes an absent project terminal when the daemon is unavailable', () => {
|
|
|
|
|
expect(projectRouteSurfaceState({
|
|
|
|
|
projectsLoading: false,
|
|
|
|
|
hasActiveProject: false,
|
|
|
|
|
daemonLive: false,
|
|
|
|
|
})).toBe('daemon-unavailable');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('exposes bounded deep-link failures instead of leaving the route loading forever', () => {
|
|
|
|
|
expect(projectRouteSurfaceState({
|
|
|
|
|
projectsLoading: false,
|
|
|
|
|
hasActiveProject: false,
|
|
|
|
|
daemonLive: true,
|
|
|
|
|
resolutionFailure: 'missing',
|
|
|
|
|
})).toBe('missing');
|
|
|
|
|
expect(projectRouteSurfaceState({
|
|
|
|
|
projectsLoading: false,
|
|
|
|
|
hasActiveProject: false,
|
|
|
|
|
daemonLive: true,
|
|
|
|
|
resolutionFailure: 'materialization-failed',
|
|
|
|
|
})).toBe('materialization-failed');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('renders a loaded project regardless of stale failure metadata', () => {
|
|
|
|
|
expect(projectRouteSurfaceState({
|
|
|
|
|
projectsLoading: false,
|
|
|
|
|
hasActiveProject: true,
|
|
|
|
|
daemonLive: true,
|
|
|
|
|
resolutionFailure: 'missing',
|
|
|
|
|
})).toBe('ready');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-05-08 14:27:46 +08:00
|
|
|
|
|
|
|
|
const baseConfig: AppConfig = {
|
|
|
|
|
mode: 'api',
|
|
|
|
|
apiKey: 'sk-test',
|
|
|
|
|
apiProtocol: 'anthropic',
|
|
|
|
|
baseUrl: 'https://api.anthropic.com',
|
|
|
|
|
model: 'claude-sonnet-4-5',
|
|
|
|
|
apiProviderBaseUrl: 'https://api.anthropic.com',
|
|
|
|
|
agentId: null,
|
|
|
|
|
skillId: null,
|
|
|
|
|
designSystemId: null,
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-05 08:31:54 +08:00
|
|
|
describe('shouldRouteToFirstRunOnboarding', () => {
|
|
|
|
|
it('never hijacks an explicit project deep link while daemon config is hydrating', () => {
|
|
|
|
|
const unfinished = { ...baseConfig, onboardingCompleted: false };
|
|
|
|
|
|
|
|
|
|
expect(shouldRouteToFirstRunOnboarding(unfinished, '/projects/project-a')).toBe(false);
|
|
|
|
|
expect(shouldRouteToFirstRunOnboarding(unfinished, '/')).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('hydrateReadyTeamProject', () => {
|
|
|
|
|
const project: Project = {
|
|
|
|
|
id: 'shared-ready',
|
|
|
|
|
name: 'Ready project',
|
|
|
|
|
skillId: null,
|
|
|
|
|
designSystemId: null,
|
|
|
|
|
createdAt: 1,
|
|
|
|
|
updatedAt: 2,
|
|
|
|
|
workspaceId: 'ws-1',
|
|
|
|
|
};
|
|
|
|
|
const teamContext = {
|
|
|
|
|
workspaceId: 'ws-1',
|
|
|
|
|
workspaceType: 'team',
|
|
|
|
|
workspaceMemberId: 'wm-1',
|
|
|
|
|
memberStatus: 'active',
|
|
|
|
|
lifecycleState: 'active',
|
|
|
|
|
teamId: 'team-1',
|
|
|
|
|
} as WorkspaceCollabContext;
|
|
|
|
|
const summary: WorkspaceProjectSummary = {
|
|
|
|
|
id: project.id,
|
|
|
|
|
name: project.name,
|
|
|
|
|
workspaceId: 'ws-1',
|
|
|
|
|
visibility: 'team',
|
|
|
|
|
resourceState: 'active',
|
|
|
|
|
createdByWorkspaceMemberId: null,
|
|
|
|
|
resourceHubResourceId: 'resource-shared-ready',
|
|
|
|
|
cloudTombstonedAt: null,
|
|
|
|
|
currentUserAccess: {
|
|
|
|
|
canOpen: true,
|
|
|
|
|
canRename: false,
|
|
|
|
|
canDelete: false,
|
|
|
|
|
canDuplicate: false,
|
|
|
|
|
canMoveToTeam: false,
|
|
|
|
|
canMoveToPersonal: false,
|
|
|
|
|
canExport: true,
|
|
|
|
|
canSendTo: true,
|
|
|
|
|
canRestoreVersion: false,
|
|
|
|
|
},
|
|
|
|
|
syncState: 'synced',
|
|
|
|
|
createdAt: project.createdAt,
|
|
|
|
|
updatedAt: project.updatedAt,
|
|
|
|
|
project,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it('fetches and applies only the ready project with the validated workspace scope', async () => {
|
|
|
|
|
const listWorkspaceProjects = vi.fn(async () => [summary]);
|
|
|
|
|
const applyProject = vi.fn();
|
|
|
|
|
const result = await hydrateReadyTeamProject('shared-ready', 'ws-1', {
|
|
|
|
|
getWorkspaceContext: () => teamContext,
|
|
|
|
|
listWorkspaceProjects,
|
|
|
|
|
applyProject,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(listWorkspaceProjects).toHaveBeenCalledWith(teamContext);
|
|
|
|
|
expect(applyProject).toHaveBeenCalledWith(project);
|
|
|
|
|
expect(result).toEqual(project);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('invalidates the exact file authority before publishing readiness', async () => {
|
|
|
|
|
const order: string[] = [];
|
|
|
|
|
const onReady = vi.fn((_project: Project, context: WorkspaceCollabContext) => {
|
|
|
|
|
expect(context).toBe(teamContext);
|
|
|
|
|
order.push('invalidate');
|
|
|
|
|
});
|
|
|
|
|
const applyProject = vi.fn(() => order.push('apply'));
|
|
|
|
|
|
|
|
|
|
await expect(hydrateReadyTeamProject(project.id, 'ws-1', {
|
|
|
|
|
getWorkspaceContext: () => teamContext,
|
|
|
|
|
listWorkspaceProjects: async () => [summary],
|
|
|
|
|
onReady,
|
|
|
|
|
applyProject,
|
|
|
|
|
})).resolves.toEqual(project);
|
|
|
|
|
|
|
|
|
|
expect(onReady).toHaveBeenCalledWith(project, teamContext);
|
|
|
|
|
expect(order).toEqual(['invalidate', 'apply']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('drops a hydration result when the workspace changes while the scoped list is in flight', async () => {
|
|
|
|
|
let resolveProjects!: (value: WorkspaceProjectSummary[]) => void;
|
|
|
|
|
const pending = new Promise<WorkspaceProjectSummary[]>((resolve) => {
|
|
|
|
|
resolveProjects = resolve;
|
|
|
|
|
});
|
|
|
|
|
let context: WorkspaceCollabContext = teamContext;
|
|
|
|
|
const applyProject = vi.fn();
|
|
|
|
|
const hydration = hydrateReadyTeamProject('shared-ready', 'ws-1', {
|
|
|
|
|
getWorkspaceContext: () => context,
|
|
|
|
|
listWorkspaceProjects: async () => pending,
|
|
|
|
|
applyProject,
|
|
|
|
|
});
|
|
|
|
|
context = { ...teamContext, workspaceId: 'ws-other' };
|
|
|
|
|
resolveProjects([summary]);
|
|
|
|
|
|
|
|
|
|
await expect(hydration).resolves.toBeNull();
|
|
|
|
|
expect(applyProject).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects a remote-only catalog card that has no materialized local binding', async () => {
|
|
|
|
|
const applyProject = vi.fn();
|
|
|
|
|
const remoteOnly = {
|
|
|
|
|
...summary,
|
|
|
|
|
id: 'resource-shared-ready',
|
|
|
|
|
project: { ...project, workspaceId: undefined },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await expect(hydrateReadyTeamProject('shared-ready', 'ws-1', {
|
|
|
|
|
getWorkspaceContext: () => teamContext,
|
|
|
|
|
listWorkspaceProjects: async () => [remoteOnly],
|
|
|
|
|
applyProject,
|
|
|
|
|
})).resolves.toBeNull();
|
|
|
|
|
expect(applyProject).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('projectViewAuthorizationLifetimeKey', () => {
|
|
|
|
|
const projectId = 'same-project';
|
|
|
|
|
const baseContext = {
|
|
|
|
|
workspaceId: 'workspace-a',
|
|
|
|
|
workspaceType: 'team',
|
|
|
|
|
workspaceMemberId: 'member-a',
|
|
|
|
|
memberStatus: 'active',
|
|
|
|
|
lifecycleState: 'active',
|
|
|
|
|
teamId: 'team-a',
|
|
|
|
|
} as WorkspaceCollabContext;
|
|
|
|
|
|
|
|
|
|
it('changes when any Workspace authorization field changes', () => {
|
|
|
|
|
const initial = projectViewAuthorizationLifetimeKey(projectId, baseContext);
|
|
|
|
|
|
|
|
|
|
expect(projectViewAuthorizationLifetimeKey(projectId, {
|
|
|
|
|
...baseContext,
|
|
|
|
|
workspaceId: 'workspace-b',
|
|
|
|
|
})).not.toBe(initial);
|
|
|
|
|
expect(projectViewAuthorizationLifetimeKey(projectId, {
|
|
|
|
|
...baseContext,
|
|
|
|
|
workspaceMemberId: 'member-b',
|
|
|
|
|
})).not.toBe(initial);
|
|
|
|
|
expect(projectViewAuthorizationLifetimeKey(projectId, {
|
|
|
|
|
...baseContext,
|
|
|
|
|
role: 'admin',
|
|
|
|
|
})).not.toBe(initial);
|
|
|
|
|
expect(projectViewAuthorizationLifetimeKey(projectId, {
|
|
|
|
|
...baseContext,
|
|
|
|
|
lifecycleState: 'locked',
|
|
|
|
|
})).not.toBe(initial);
|
|
|
|
|
expect(projectViewAuthorizationLifetimeKey(projectId, {
|
|
|
|
|
...baseContext,
|
|
|
|
|
permissions: {
|
|
|
|
|
...baseContext.permissions,
|
|
|
|
|
canShareProjects: true,
|
|
|
|
|
canWriteSyncedFiles: false,
|
|
|
|
|
},
|
|
|
|
|
})).not.toBe(initial);
|
|
|
|
|
expect(projectViewAuthorizationLifetimeKey(projectId, null)).not.toBe(initial);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-22 16:11:03 +09:00
|
|
|
describe('mergeAgentModelChoice', () => {
|
|
|
|
|
it('preserves serviceTier when an unrelated update omits the key', () => {
|
|
|
|
|
expect(
|
|
|
|
|
mergeAgentModelChoice(
|
|
|
|
|
{ model: 'gpt-5.5', reasoning: 'default', serviceTier: 'priority' },
|
|
|
|
|
{ reasoning: 'high' },
|
|
|
|
|
),
|
|
|
|
|
).toEqual({
|
|
|
|
|
model: 'gpt-5.5',
|
|
|
|
|
reasoning: 'high',
|
|
|
|
|
serviceTier: 'priority',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('removes serviceTier only when the update explicitly clears it', () => {
|
|
|
|
|
const merged = mergeAgentModelChoice(
|
|
|
|
|
{ model: 'gpt-5.5', reasoning: 'default', serviceTier: 'priority' },
|
|
|
|
|
{ serviceTier: undefined },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(merged).toEqual({
|
|
|
|
|
model: 'gpt-5.5',
|
|
|
|
|
reasoning: 'default',
|
|
|
|
|
});
|
|
|
|
|
expect(Object.prototype.hasOwnProperty.call(merged, 'serviceTier')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 14:27:46 +08:00
|
|
|
describe('persistComposioConfigChange', () => {
|
|
|
|
|
it('does not update local saved state when the daemon save fails', async () => {
|
|
|
|
|
await expect(
|
|
|
|
|
persistComposioConfigChange(
|
|
|
|
|
baseConfig,
|
|
|
|
|
{ apiKey: 'cmp_new_key', apiKeyConfigured: false },
|
|
|
|
|
vi.fn(async () => false),
|
|
|
|
|
),
|
|
|
|
|
).rejects.toThrow('Composio config save failed');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('normalizes the saved Composio key after a successful daemon save', async () => {
|
|
|
|
|
await expect(
|
|
|
|
|
persistComposioConfigChange(
|
|
|
|
|
baseConfig,
|
|
|
|
|
{ apiKey: 'cmp_new_key', apiKeyConfigured: false },
|
|
|
|
|
vi.fn(async () => true),
|
|
|
|
|
),
|
|
|
|
|
).resolves.toMatchObject({
|
|
|
|
|
composio: {
|
|
|
|
|
apiKey: '',
|
|
|
|
|
apiKeyConfigured: true,
|
|
|
|
|
apiKeyTail: '_key',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('shouldSyncMediaProvidersOnSave', () => {
|
|
|
|
|
it('keeps bootstrap-style empty media maps from syncing by default', () => {
|
|
|
|
|
expect(shouldSyncMediaProvidersOnSave({})).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('syncs an explicit empty media map when the user save should force a clear', () => {
|
|
|
|
|
expect(shouldSyncMediaProvidersOnSave({}, { force: true })).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('buildPersistedConfig', () => {
|
|
|
|
|
it('preserves onboarding completion when a stale autosave snapshot says false', () => {
|
|
|
|
|
expect(
|
|
|
|
|
buildPersistedConfig(
|
|
|
|
|
{ ...baseConfig, onboardingCompleted: false },
|
|
|
|
|
{ ...baseConfig, onboardingCompleted: true },
|
|
|
|
|
),
|
|
|
|
|
).toMatchObject({ onboardingCompleted: true });
|
|
|
|
|
});
|
2026-06-03 18:40:22 +08:00
|
|
|
|
|
|
|
|
it('preserves a current privacy decision when settings autosaves a stale pre-consent snapshot', () => {
|
|
|
|
|
expect(
|
|
|
|
|
buildPersistedConfig(
|
|
|
|
|
{
|
|
|
|
|
...baseConfig,
|
|
|
|
|
apiProtocol: 'google',
|
|
|
|
|
privacyDecisionAt: null,
|
|
|
|
|
telemetry: { metrics: true, content: true, artifactManifest: false },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
...baseConfig,
|
|
|
|
|
installationId: 'inst-current',
|
|
|
|
|
privacyDecisionAt: 12345,
|
|
|
|
|
telemetry: { metrics: false, content: false, artifactManifest: false },
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
).toMatchObject({
|
|
|
|
|
apiProtocol: 'google',
|
|
|
|
|
installationId: 'inst-current',
|
|
|
|
|
privacyDecisionAt: 12345,
|
|
|
|
|
telemetry: { metrics: false, content: false, artifactManifest: false },
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-05-08 14:27:46 +08:00
|
|
|
});
|
|
|
|
|
|
2026-05-11 20:52:45 +08:00
|
|
|
describe('isAutosaveDraftOnlyChange', () => {
|
|
|
|
|
const savedComposio: AppConfig = {
|
|
|
|
|
...baseConfig,
|
|
|
|
|
composio: { apiKey: '', apiKeyConfigured: true, apiKeyTail: 'beef' },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it('treats an in-flight Composio API key edit as draft-only', () => {
|
|
|
|
|
const typing: AppConfig = {
|
|
|
|
|
...savedComposio,
|
|
|
|
|
composio: { ...savedComposio.composio, apiKey: '111' },
|
|
|
|
|
};
|
|
|
|
|
expect(isAutosaveDraftOnlyChange(typing, savedComposio)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('flags a real change (non-draft field) as persist-worthy', () => {
|
|
|
|
|
const flipped: AppConfig = { ...savedComposio, model: 'claude-opus-4-7' };
|
|
|
|
|
expect(isAutosaveDraftOnlyChange(flipped, savedComposio)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('flags apiKeyConfigured / tail flips as persist-worthy', () => {
|
|
|
|
|
const cleared: AppConfig = {
|
|
|
|
|
...savedComposio,
|
|
|
|
|
composio: { apiKey: '', apiKeyConfigured: false, apiKeyTail: '' },
|
|
|
|
|
};
|
|
|
|
|
expect(isAutosaveDraftOnlyChange(cleared, savedComposio)).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns true for an identical snapshot (no-op autosave tick)', () => {
|
|
|
|
|
expect(isAutosaveDraftOnlyChange(savedComposio, savedComposio)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-08 14:27:46 +08:00
|
|
|
describe('resolveSettingsCloseConfig', () => {
|
|
|
|
|
it('marks onboarding complete without discarding the latest persisted draft', () => {
|
|
|
|
|
expect(
|
|
|
|
|
resolveSettingsCloseConfig(
|
|
|
|
|
{
|
|
|
|
|
...baseConfig,
|
|
|
|
|
onboardingCompleted: false,
|
|
|
|
|
orbit: { enabled: false, time: '09:00', templateSkillId: 'stale-template' },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
...baseConfig,
|
|
|
|
|
onboardingCompleted: false,
|
|
|
|
|
orbit: { enabled: true, time: '11:30', templateSkillId: 'fresh-template' },
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
).toMatchObject({
|
|
|
|
|
onboardingCompleted: true,
|
|
|
|
|
orbit: { enabled: true, time: '11:30', templateSkillId: 'fresh-template' },
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-05 08:31:54 +08:00
|
|
|
|
|
|
|
|
// Regression coverage for the deep-link bootstrap effect's team-share race
|
|
|
|
|
// (App.tsx's "Deep-linked route to a project we don't have yet" effect). A
|
|
|
|
|
// team member's FIRST open of a project the owner just shared with them
|
|
|
|
|
// arrives as a deep link before the daemon has materialized any local sqlite
|
|
|
|
|
// row for that project. The effect used to treat a single immediate miss as
|
|
|
|
|
// "this project doesn't exist" and navigate the member straight back to Home
|
|
|
|
|
// mid-sync — even when the hub-backed `/api/workspace/projects/team` catalog
|
|
|
|
|
// already confirmed the project belongs to their team. These tests exercise
|
|
|
|
|
// the extracted decision function directly (no React, no timers) so the
|
|
|
|
|
// retry/backoff and the found vs. still-materializing vs. not-found
|
|
|
|
|
// classification stay pinned without a flaky fake-timer + RTL harness.
|
|
|
|
|
describe('resolveDeepLinkedTeamSharedProject', () => {
|
|
|
|
|
const sharedProject: Project = {
|
|
|
|
|
id: 'shared-1',
|
|
|
|
|
name: 'Owner Shared Project',
|
|
|
|
|
skillId: null,
|
|
|
|
|
designSystemId: null,
|
|
|
|
|
createdAt: 1778244000000,
|
|
|
|
|
updatedAt: 1778244000000,
|
|
|
|
|
};
|
|
|
|
|
const noopDelay = async () => {};
|
|
|
|
|
|
|
|
|
|
it('resolves as found immediately when the local project already exists', async () => {
|
|
|
|
|
const getProject = vi.fn(async () => sharedProject);
|
2026-09-07 17:08:24 +00:00
|
|
|
const pullTeamSharedProjectIfAvailable = vi.fn(async () => ({ catalogAvailable: true, isTeamShared: false, pulled: false }));
|
2026-08-05 08:31:54 +08:00
|
|
|
|
|
|
|
|
const resolution = await resolveDeepLinkedTeamSharedProject('shared-1', {
|
|
|
|
|
getProject,
|
|
|
|
|
pullTeamSharedProjectIfAvailable,
|
|
|
|
|
delay: noopDelay,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(resolution).toEqual({ kind: 'found', project: sharedProject });
|
|
|
|
|
expect(getProject).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(pullTeamSharedProjectIfAvailable).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('resolves as found once local materialization catches up mid-retry', async () => {
|
|
|
|
|
const getProject = vi
|
|
|
|
|
.fn<(id: string) => Promise<Project | null>>()
|
|
|
|
|
.mockResolvedValueOnce(null)
|
|
|
|
|
.mockResolvedValueOnce(null)
|
|
|
|
|
.mockResolvedValueOnce(sharedProject);
|
2026-09-07 17:08:24 +00:00
|
|
|
const pullTeamSharedProjectIfAvailable = vi.fn(async () => ({ catalogAvailable: true, isTeamShared: true, pulled: true }));
|
2026-08-05 08:31:54 +08:00
|
|
|
|
|
|
|
|
const resolution = await resolveDeepLinkedTeamSharedProject('shared-1', {
|
|
|
|
|
getProject,
|
|
|
|
|
pullTeamSharedProjectIfAvailable,
|
|
|
|
|
delay: noopDelay,
|
|
|
|
|
retryAttempts: 4,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(resolution).toEqual({ kind: 'found', project: sharedProject });
|
|
|
|
|
// 1st attempt: getProject miss + pull's post-pull re-check miss (2 calls).
|
|
|
|
|
// 2nd attempt: getProject hit (3rd call) — no pull needed once found.
|
|
|
|
|
expect(getProject).toHaveBeenCalledTimes(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// This is the exact regression: the hub confirms the project belongs to the
|
|
|
|
|
// member's team (isTeamShared: true) on every attempt, but local
|
|
|
|
|
// materialization never lands within the bounded retry window (e.g. a slow
|
|
|
|
|
// first-ever content pull, or the daemon's ensureSharedProjectPlaceholder
|
|
|
|
|
// firing through a different code path than this one polls). The member
|
|
|
|
|
// must NOT be told "not found" here — the caller's not-found/navigate-home
|
|
|
|
|
// path must not run for a project the hub says they can see.
|
|
|
|
|
it('resolves as still-materializing (never not-found) when the hub confirms sharing but local sync is still catching up', async () => {
|
|
|
|
|
const getProject = vi.fn(async () => null);
|
2026-09-07 17:08:24 +00:00
|
|
|
const pullTeamSharedProjectIfAvailable = vi.fn(async () => ({ catalogAvailable: true, isTeamShared: true, pulled: true }));
|
2026-08-05 08:31:54 +08:00
|
|
|
|
|
|
|
|
const resolution = await resolveDeepLinkedTeamSharedProject('shared-1', {
|
|
|
|
|
getProject,
|
|
|
|
|
pullTeamSharedProjectIfAvailable,
|
|
|
|
|
delay: noopDelay,
|
|
|
|
|
retryAttempts: 3,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(resolution).toEqual({ kind: 'still-materializing' });
|
|
|
|
|
expect(pullTeamSharedProjectIfAvailable).toHaveBeenCalledTimes(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The safety net: a project genuinely absent from the team catalog (never
|
|
|
|
|
// shared, revoked, or a real typo'd/unauthorized id) must still resolve as
|
|
|
|
|
// not-found so the caller's existing list-based fallback and
|
|
|
|
|
// navigate-home behavior keeps firing exactly as before this fix.
|
|
|
|
|
it('resolves as not-found when the hub never confirms team membership', async () => {
|
|
|
|
|
const getProject = vi.fn(async () => null);
|
2026-09-07 17:08:24 +00:00
|
|
|
const pullTeamSharedProjectIfAvailable = vi.fn(async () => ({ catalogAvailable: true, isTeamShared: false, pulled: false }));
|
2026-08-05 08:31:54 +08:00
|
|
|
|
|
|
|
|
const resolution = await resolveDeepLinkedTeamSharedProject('missing-1', {
|
|
|
|
|
getProject,
|
|
|
|
|
pullTeamSharedProjectIfAvailable,
|
|
|
|
|
delay: noopDelay,
|
|
|
|
|
retryAttempts: 3,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(resolution).toEqual({ kind: 'not-found' });
|
|
|
|
|
expect(pullTeamSharedProjectIfAvailable).toHaveBeenCalledTimes(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('stops retrying once the caller reports cancellation', async () => {
|
|
|
|
|
let calls = 0;
|
|
|
|
|
const getProject = vi.fn(async () => {
|
|
|
|
|
calls += 1;
|
|
|
|
|
return null;
|
|
|
|
|
});
|
2026-09-07 17:08:24 +00:00
|
|
|
const pullTeamSharedProjectIfAvailable = vi.fn(async () => ({ catalogAvailable: true, isTeamShared: true, pulled: false }));
|
2026-08-05 08:31:54 +08:00
|
|
|
|
|
|
|
|
const resolution = await resolveDeepLinkedTeamSharedProject('shared-1', {
|
|
|
|
|
getProject,
|
|
|
|
|
pullTeamSharedProjectIfAvailable,
|
|
|
|
|
delay: noopDelay,
|
|
|
|
|
retryAttempts: 10,
|
|
|
|
|
// Cancel right after the very first getProject call, as an unmount mid
|
|
|
|
|
// retry would — the loop must not keep spinning through every attempt.
|
|
|
|
|
isCancelled: () => calls >= 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(resolution).toEqual({ kind: 'still-materializing' });
|
|
|
|
|
expect(getProject).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(pullTeamSharedProjectIfAvailable).not.toHaveBeenCalled();
|
|
|
|
|
});
|
2026-09-07 17:08:24 +00:00
|
|
|
|
|
|
|
|
it('fails fast when the Team catalog is unavailable instead of waiting the materialization budget', async () => {
|
|
|
|
|
const getProject = vi.fn(async () => null);
|
|
|
|
|
const pullTeamSharedProjectIfAvailable = vi.fn(async () => ({
|
|
|
|
|
catalogAvailable: false,
|
|
|
|
|
isTeamShared: false,
|
|
|
|
|
pulled: false,
|
|
|
|
|
}));
|
|
|
|
|
const delay = vi.fn(async () => {});
|
|
|
|
|
|
|
|
|
|
const resolution = await resolveDeepLinkedTeamSharedProject('shared-1', {
|
|
|
|
|
getProject,
|
|
|
|
|
pullTeamSharedProjectIfAvailable,
|
|
|
|
|
delay,
|
|
|
|
|
retryAttempts: 21,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(resolution).toEqual({ kind: 'unavailable' });
|
|
|
|
|
expect(getProject).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(pullTeamSharedProjectIfAvailable).toHaveBeenCalledTimes(1);
|
|
|
|
|
expect(delay).not.toHaveBeenCalled();
|
|
|
|
|
});
|
2026-08-05 08:31:54 +08:00
|
|
|
});
|