From 946ff7464f035bd01255cae66c0a7f1844f35e9d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 00:32:58 -0700 Subject: [PATCH] test(prefetch): cover the workspace-list seed, and fix two tests that could not fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit found the seed contract — the whole point of #6656 — had no test, and that one existing assertion was vacuous. - prefetchWorkspaceSidebar and seedWorkspaceList now have coverage: the empty list seeds nothing (so the client reaches the route's default-workspace creation path), a populated list seeds, a rejected read neither throws nor seeds, and a host context for another workspace seeds nothing at all. Verified falsifiable — removing the empty-list guard turns the first red. - The graceful-failure row for prefetchFilesBrowser asserted on the file-list key, which that function deliberately never writes, so it held no matter what the code did. It now asserts the folder key it owns, and the setup rejects the folder read. - The sidebar was the third hand-rolled copy of the folder prefetch the shared helper was extracted to remove; it now calls prefetchResourceFolders too. - prefetchKnowledgeBases returns early without a userId like every sibling, rather than reaching the authenticator and throwing per render. - Dropped two docblock claims about a `retry` default that no longer applies server-side. --- .../[workspaceId]/knowledge/prefetch.ts | 2 + .../[workspaceId]/lib/prefetch.test.ts | 120 +++++++++++++++++- .../app/workspace/[workspaceId]/prefetch.ts | 21 +-- 3 files changed, 126 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts index 1bfaf144acf..7aad80a5bca 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -36,6 +36,8 @@ export async function prefetchKnowledgeBases( workspaceId: string, userId: string | undefined ): Promise { + if (!userId) return + await Promise.all([ queryClient.prefetchQuery({ queryKey: knowledgeKeys.list(workspaceId, 'active'), diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index 32309a08be9..dee79520936 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -12,6 +12,11 @@ const { mockListFoldersForWorkspace, mockListInternalKnowledgeBases, mockListPinnedItemsForUser, + mockListWorkflowsForUser, + mockListWorkspacesForViewer, + mockGetUserProfile, + mockGetWorkspacePermissions, + mockListMothershipChats, mockListTables, mockListWorkspaceFileFolders, mockListWorkspaceFilesWithShares, @@ -23,6 +28,11 @@ const { mockListFoldersForWorkspace: vi.fn(), mockListInternalKnowledgeBases: vi.fn(), mockListPinnedItemsForUser: vi.fn(), + mockListWorkflowsForUser: vi.fn(), + mockListWorkspacesForViewer: vi.fn(), + mockGetUserProfile: vi.fn(), + mockGetWorkspacePermissions: vi.fn(), + mockListMothershipChats: vi.fn(), mockListTables: vi.fn(), mockListWorkspaceFileFolders: vi.fn(), mockListWorkspaceFilesWithShares: vi.fn(), @@ -45,6 +55,19 @@ vi.mock('@/lib/pinned-items/queries', () => ({ })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ getWorkspaceMemberProfiles: mockGetWorkspaceMemberProfiles, + getWorkspacePermissionsForAuthorizedViewer: mockGetWorkspacePermissions, +})) +vi.mock('@/lib/workflows/queries', () => ({ + listWorkflowsForUser: mockListWorkflowsForUser, +})) +vi.mock('@/lib/workspaces/list', () => ({ + listWorkspacesForViewer: mockListWorkspacesForViewer, +})) +vi.mock('@/lib/users/queries', () => ({ + getUserProfile: mockGetUserProfile, +})) +vi.mock('@/lib/copilot/chat/list-mothership-chats', () => ({ + listMothershipChats: mockListMothershipChats, })) vi.mock('@/lib/table/service', () => ({ listTables: mockListTables, @@ -74,6 +97,7 @@ vi.mock('@sim/emcn', () => ({ import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch' import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch' +import { prefetchWorkspaceSidebar } from '@/app/workspace/[workspaceId]/prefetch' import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch' import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' @@ -98,6 +122,16 @@ describe('workspace list prefetches', () => { mockListWorkspaceFilesWithShares.mockResolvedValue([]) mockListWorkspaceFileFolders.mockResolvedValue([]) mockListPinnedItemsForUser.mockResolvedValue([]) + mockListWorkflowsForUser.mockResolvedValue([]) + mockGetUserProfile.mockResolvedValue({ id: USER_ID, name: 'Ada', email: 'a@b.c' }) + mockGetWorkspacePermissions.mockResolvedValue({ users: [] }) + mockListMothershipChats.mockResolvedValue([]) + mockListWorkspacesForViewer.mockResolvedValue({ + workspaces: [], + lastActiveWorkspaceId: null, + pinnedWorkspaceIds: [], + creationPolicy: null, + }) mockGetWorkspaceMemberProfiles.mockResolvedValue([]) mockListTables.mockResolvedValue([]) mockAuthenticate.mockResolvedValue({ kind: 'session', userId: USER_ID, sessionId: 'sess-1' }) @@ -366,6 +400,84 @@ describe('workspace list prefetches', () => { } }) + describe('prefetchWorkspaceSidebar / seedWorkspaceList', () => { + const HOST_CONTEXT = { + workspace: { id: WORKSPACE_ID }, + viewer: { permission: 'admin' }, + } as never + + const WORKSPACE_ROW = { + id: WORKSPACE_ID, + name: 'GTM', + ownerId: USER_ID, + organizationId: null, + workspaceMode: 'personal', + permissions: 'admin', + } + + const LIST_PAYLOAD = { + workspaces: [WORKSPACE_ROW], + lastActiveWorkspaceId: null, + pinnedWorkspaceIds: [], + creationPolicy: null, + } + + /** + * The load-bearing contract: an empty list must leave the key UNSET so the client + * fetch reaches `GET /api/workspaces`' default-workspace creation path. Seeding an + * empty array instead would suppress it and strand a brand-new viewer. + */ + it('seeds nothing when the viewer has no workspaces', async () => { + mockListWorkspacesForViewer.mockResolvedValue({ ...LIST_PAYLOAD, workspaces: [] }) + const client = makeClient() + + await prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null) + + expect(client.getQueryData(workspaceKeys.list('active'))).toBeUndefined() + }) + + it('seeds the workspace list when the viewer has one', async () => { + mockListWorkspacesForViewer.mockResolvedValue(LIST_PAYLOAD) + const client = makeClient() + + await prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null) + + const cached = client.getQueryData(workspaceKeys.list('active')) as + | { workspaces: Array<{ id: string }> } + | undefined + expect(cached).toBeDefined() + expect(cached?.workspaces.map((w) => w.id)).toEqual([WORKSPACE_ID]) + }) + + /** A failed seed is an optimization loss, not a render failure. */ + it('does not throw when the workspace read rejects, and seeds nothing', async () => { + mockListWorkspacesForViewer.mockRejectedValue(new Error('500')) + const client = makeClient() + + await expect( + prefetchWorkspaceSidebar(client, WORKSPACE_ID, USER_ID, HOST_CONTEXT, null) + ).resolves.toBeUndefined() + expect(client.getQueryData(workspaceKeys.list('active'))).toBeUndefined() + }) + + /** Guards the mismatch check that keeps one workspace's data out of another's cache. */ + it('seeds nothing when the host context is for a different workspace', async () => { + mockListWorkspacesForViewer.mockResolvedValue(LIST_PAYLOAD) + const client = makeClient() + + await prefetchWorkspaceSidebar( + client, + WORKSPACE_ID, + USER_ID, + { workspace: { id: 'other-ws' }, viewer: { permission: 'admin' } } as never, + null + ) + + expect(client.getQueryCache().getAll()).toHaveLength(0) + expect(mockListWorkspacesForViewer).not.toHaveBeenCalled() + }) + }) + describe('graceful failure', () => { it.each([ [ @@ -379,9 +491,14 @@ describe('workspace list prefetches', () => { tableKeys.list(WORKSPACE_ID, 'active'), ], [ + /** + * Asserted against the folder key, not the file list: `prefetchFilesBrowser` + * deliberately never seeds `workspaceFilesKeys` (the layout owns it), so an + * assertion on that key would hold no matter what this function did. + */ 'prefetchFilesBrowser', (client: QueryClient) => prefetchFilesBrowser(client, WORKSPACE_ID, USER_ID), - workspaceFilesKeys.list(WORKSPACE_ID, 'active'), + workspaceFileFolderKeys.list(WORKSPACE_ID, 'active'), ], ] as const)( '%s does not throw when the fetcher rejects (page still renders, client refetches)', @@ -393,6 +510,7 @@ describe('workspace list prefetches', () => { mockListInternalKnowledgeBases.mockRejectedValue(boom) mockListPinnedItemsForUser.mockRejectedValue(boom) mockGetWorkspaceMemberProfiles.mockRejectedValue(boom) + mockListWorkspaceFileFolders.mockRejectedValue(boom) const client = makeClient() await expect(prefetch(client)).resolves.toBeUndefined() diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 0023b34d851..c24198fb51f 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -4,13 +4,13 @@ import type { QueryClient } from '@tanstack/react-query' import { listWorkspacesContract, type WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' import { isChatEnabled } from '@/lib/core/config/env-flags' -import { listFoldersForWorkspace } from '@/lib/folders/queries' import { getUserProfile } from '@/lib/users/queries' import { listWorkflowsForUser } from '@/lib/workflows/queries' import { listWorkspaceFilesWithShares } from '@/lib/workspace-files/queries' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { listWorkspacesForViewer } from '@/lib/workspaces/list' import { getWorkspacePermissionsForAuthorizedViewer } from '@/lib/workspaces/permissions/utils' +import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders' import { MOTHERSHIP_CHAT_LIST_STALE_TIME, mapChat, @@ -21,7 +21,6 @@ import { USER_PROFILE_STALE_TIME, userProfileKeys, } from '@/hooks/queries/user-profile' -import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' import { normalizeWorkspacesResponse } from '@/hooks/queries/utils/workspace-list-query' @@ -59,9 +58,8 @@ const logger = createLogger('WorkspacePrefetch') * Seeded rather than prefetched so the empty-list case can decline to create a * cache entry at all: the route's default-workspace creation path must run on * the client, and an entry — even an empty one — would suppress it. Expressing - * that as an absent seed keeps a normal state out of the error channel, where - * it previously cost a full second re-read (`retry: 1`) to re-derive an outcome - * already known. + * that as an absent seed also keeps a routine state out of the error channel, + * where it read as a failure rather than as "nothing to seed". */ async function seedWorkspaceList( queryClient: QueryClient, @@ -122,9 +120,7 @@ async function seedWorkspaceList( * to produce, without routing a normal state through the error channel. That * matters because only a settled query is dehydrated: an unawaited read would be * dropped from the payload entirely, so the switcher would waterfall on every - * cold load rather than paint populated. Seeding also skips the `retry` default, - * which previously ran the whole read a second time, a retry delay later, purely - * to re-derive an outcome already known. + * cold load rather than paint populated. */ export async function prefetchWorkspaceSidebar( queryClient: QueryClient, @@ -156,14 +152,7 @@ export async function prefetchWorkspaceSidebar( }), ] : []), - queryClient.prefetchQuery({ - queryKey: folderKeys.list(workspaceId, 'active', 'workflow'), - queryFn: async () => { - const rows = await listFoldersForWorkspace(workspaceId, 'active', 'workflow') - return rows.map(mapFolder) - }, - staleTime: FOLDER_LIST_STALE_TIME, - }), + prefetchResourceFolders(queryClient, workspaceId, 'workflow', userId), /** * The sidebar reads the workspace's files for its search modal, on EVERY workspace route — so this * query is registered by sidebar chrome before any page renders. That ordering is why it has to be