test(prefetch): cover the workspace-list seed, and fix two tests that could not fail (#6659)

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.
This commit is contained in:
Waleed
2026-08-13 00:36:14 -07:00
committed by GitHub
parent c49751b32e
commit 618cee5cb9
3 changed files with 126 additions and 17 deletions
@@ -36,6 +36,8 @@ export async function prefetchKnowledgeBases(
workspaceId: string,
userId: string | undefined
): Promise<void> {
if (!userId) return
await Promise.all([
queryClient.prefetchQuery({
queryKey: knowledgeKeys.list(workspaceId, 'active'),
@@ -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()
@@ -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