Files
insights/products/dashboards/frontend/logics/dashboardsFileSystemLogic.test.ts
zeekay 40d120dbf7 refactor(api): say /v1/ in the Python callers
Every first-party Python caller and test now names the mount the API actually
answers on. Mechanical: URL literals only, and only where the /api/ was ours --
a preceding word character means it was a module path like insights/api/ or a
third-party host like slack.com/api/, and those are untouched.

Left saying /api/, each for a reason rather than for safety:
- products/warehouse_sources/.../sources/** is a catalogue of vendor endpoints
- services/llm-gateway deploys separately and is under a code freeze, so moving
  its two calls would break it during the skew
- the mcp_store and Slack/Teams redirect_uris are registered with third parties
  and must byte-match what was registered
- the Wizard and Raycast client-metadata paths are OAuth client_ids
- /api/surveys and /api/feature_flag/local_evaluation are SDK aliases the
  ingress routes to the Rust fleets, held by released SDK versions

Test URLconfs moved with the requests that call them, and the Datastore
user-selection fixtures now carry the leading slash that request.path has.
2026-08-08 18:38:44 -07:00

255 lines
13 KiB
TypeScript

import { expectLogic } from 'kea-test-utils'
import api from 'lib/api'
import { toast } from 'lib/elements/Toast'
import { resumeKeaLoadersErrors, silenceKeaLoadersErrors } from '~/initKea'
import { projectTreeDataLogic } from '~/layout/panel-layout/ProjectTree/projectTreeDataLogic'
import { useMocks } from '~/mocks/jest'
import { dashboardsModel } from '~/models/dashboardsModel'
import { initKeaTests } from '~/test/init'
import { dashboardsFileSystemLogic } from './dashboardsFileSystemLogic'
describe('dashboardsFileSystemLogic', () => {
// Safety net for tests that call silenceKeaLoadersErrors() inline
afterEach(resumeKeaLoadersErrors)
let logic: ReturnType<typeof dashboardsFileSystemLogic.build>
useMocks({
get: {
'/v1/environments/:team_id/dashboards/': () => [200, { count: 0, results: [], next: null }],
'/v1/projects/:team_id/dashboards/': () => [200, { count: 0, results: [], next: null }],
},
})
beforeEach(() => {
jest.spyOn(api.fileSystem, 'list').mockImplementation(
async ({ type }: { type?: string } = {}) =>
(type === 'folder'
? { count: 0, results: [], users: [] }
: {
count: 1,
results: [{ id: 'fs-1', type: 'dashboard', ref: '1', path: 'Marketing/A' }],
users: [],
}) as any
)
jest.spyOn(api.fileSystem, 'unfiled').mockResolvedValue(null as any)
jest.spyOn(api.fileSystemShortcuts, 'list').mockResolvedValue({ count: 0, results: [] } as any)
jest.spyOn(api.fileSystem, 'create').mockResolvedValue({ id: 'fs-new', type: 'folder', path: 'x' } as any)
initKeaTests()
logic = dashboardsFileSystemLogic()
logic.mount()
})
afterEach(() => {
logic?.unmount()
jest.restoreAllMocks()
})
it('loads dashboard file-system entries indexed by ref', async () => {
await expectLogic(logic).toDispatchActions(['loadDashboardFileSystemEntriesSuccess'])
expect(logic.values.entryByRef['1']?.path).toEqual('Marketing/A')
})
it('pages through every dashboard entry instead of truncating at one page', async () => {
await expectLogic(logic).toDispatchActions([
'loadDashboardFileSystemEntriesSuccess',
'loadFolderEntriesSuccess',
])
const entry = (n: number): any => ({ id: `fs-${n}`, type: 'dashboard', ref: String(n), path: `Folder/${n}` })
const fullPage = Array.from({ length: 500 }, (_, i) => entry(i))
;(api.fileSystem.list as jest.Mock).mockImplementation(async ({ type, offset = 0 }: any) =>
type !== 'dashboard'
? { count: 0, results: [], users: [] }
: offset === 0
? { count: 501, results: fullPage, users: [] }
: { count: 501, results: [entry(500)], users: [] }
)
logic.unmount()
logic = dashboardsFileSystemLogic()
logic.mount()
await expectLogic(logic).toDispatchActions(['loadDashboardFileSystemEntriesSuccess'])
// 500 (page 1) + 1 (page 2): the 501st entry is loaded, not dropped past the old single-page cap.
expect(logic.values.entryByRef['500']?.path).toEqual('Folder/500')
expect(Object.keys(logic.values.entryByRef)).toHaveLength(501)
})
it('exposes empty folder rows in the folder tree', async () => {
// Drain the beforeEach mount's loaders so their (uncancelled) results don't race the re-mock below.
await expectLogic(logic).toDispatchActions([
'loadDashboardFileSystemEntriesSuccess',
'loadFolderEntriesSuccess',
])
;(api.fileSystem.list as jest.Mock).mockImplementation(
async ({ type }: { type?: string } = {}) =>
(type === 'folder'
? { count: 1, results: [{ id: 'fld', type: 'folder', path: 'Ideas' }], users: [] }
: { count: 0, results: [], users: [] }) as any
)
logic.unmount()
logic = dashboardsFileSystemLogic()
logic.mount()
await expectLogic(logic).toDispatchActions([
'loadDashboardFileSystemEntriesSuccess',
'loadFolderEntriesSuccess',
])
// The folder rows feed the tree even with no dashboards beneath them (empty folders appear).
expect(logic.values.folderTree.map((node) => node.path)).toEqual(['Ideas'])
})
it('navigates folders', async () => {
await expectLogic(logic, () => logic.actions.navigateToFolder('Marketing')).toMatchValues({
currentFolder: 'Marketing',
})
await expectLogic(logic, () => logic.actions.navigateToFolder('')).toMatchValues({ currentFolder: '' })
})
it('refetches entries when a dashboard move lands so the subtree reflects the new path', async () => {
await expectLogic(logic).toDispatchActions(['loadDashboardFileSystemEntriesSuccess'])
expect(logic.values.entryByRef['1']?.path).toEqual('Marketing/A')
;(api.fileSystem.list as jest.Mock).mockImplementation(
async ({ type }: { type?: string } = {}) =>
(type === 'folder'
? { count: 0, results: [], users: [] }
: {
count: 1,
results: [{ id: 'fs-1', type: 'dashboard', ref: '1', path: 'Product/A' }],
users: [],
}) as any
)
await expectLogic(logic, () => {
projectTreeDataLogic.actions.movedItem(
{ id: 'fs-1', type: 'dashboard', ref: '1', path: 'Marketing/A' } as any,
'Marketing/A',
'Product/A'
)
}).toDispatchActions(['loadDashboardFileSystemEntries', 'loadDashboardFileSystemEntriesSuccess'])
expect(logic.values.entryByRef['1']?.path).toEqual('Product/A')
})
it('ignores moves of non-dashboard, non-folder items (no wasted refetch)', async () => {
await expectLogic(logic).toDispatchActions([
'loadDashboardFileSystemEntriesSuccess',
'loadFolderEntriesSuccess',
])
;(api.fileSystem.list as jest.Mock).mockClear()
projectTreeDataLogic.actions.movedItem({ id: 'i-1', type: 'insight', path: 'a' } as any, 'a', 'b')
await expectLogic(logic).toFinishAllListeners()
expect(api.fileSystem.list).not.toHaveBeenCalled()
})
it('refetches the subtree after a duplicate lands', async () => {
await expectLogic(logic).toDispatchActions(['loadDashboardFileSystemEntriesSuccess'])
await expectLogic(logic, () => {
dashboardsModel.actions.duplicateDashboardSuccess({ id: 2, name: 'A (Copy)' } as any)
}).toDispatchActions(['loadDashboardFileSystemEntries'])
})
it('createFolder creates a folder under the current folder, syncs the sidebar, refetches, and selects it', async () => {
await expectLogic(logic).toDispatchActions(['loadDashboardFileSystemEntriesSuccess'])
logic.actions.navigateToFolder('Marketing')
;(api.fileSystem.create as jest.Mock).mockClear()
await expectLogic(logic, () => logic.actions.createFolder('Ideas')).toDispatchActions([
'createFolder',
// Syncs the sidebar's shared store, then refreshes our own folder rows.
'createSavedItem',
'loadFolderEntries',
])
expect(api.fileSystem.create).toHaveBeenCalledWith({ type: 'folder', path: 'Marketing/Ideas' })
expect(logic.values.currentFolder).toEqual('Marketing/Ideas')
})
it('createFolder ignores a blank name', async () => {
;(api.fileSystem.create as jest.Mock).mockClear()
logic.actions.createFolder(' ')
await expectLogic(logic).toFinishAllListeners()
expect(api.fileSystem.create).not.toHaveBeenCalled()
})
it('toasts an error when loading dashboard folders fails', async () => {
await expectLogic(logic).toDispatchActions(['loadDashboardFileSystemEntriesSuccess'])
const error = jest.spyOn(toast, 'error').mockReturnValue('' as any)
;(api.fileSystem.list as jest.Mock).mockRejectedValueOnce(new Error('boom'))
// Deliberate loader failure — kea-loaders would log it
silenceKeaLoadersErrors()
await expectLogic(logic, () => {
logic.actions.loadDashboardFileSystemEntries()
}).toDispatchActions(['loadDashboardFileSystemEntriesFailure'])
expect(error).toHaveBeenCalled()
})
it('toasts an error when loading folder rows fails', async () => {
await expectLogic(logic).toDispatchActions(['loadFolderEntriesSuccess'])
const error = jest.spyOn(toast, 'error').mockReturnValue('' as any)
;(api.fileSystem.list as jest.Mock).mockRejectedValueOnce(new Error('boom'))
// Deliberate loader failure — kea-loaders would log it
silenceKeaLoadersErrors()
await expectLogic(logic, () => {
logic.actions.loadFolderEntries()
}).toDispatchActions(['loadFolderEntriesFailure'])
expect(error).toHaveBeenCalled()
})
it('renameFolder moves the folder to the renamed sibling path and re-points the current scope', async () => {
const move = jest.spyOn(api.fileSystem, 'move').mockResolvedValue({} as any)
await expectLogic(logic).toDispatchActions(['loadFolderEntriesSuccess'])
logic.actions.navigateToFolder('Marketing')
logic.actions.renameFolder({ id: 'fld', type: 'folder', path: 'Marketing' } as any, 'Growth')
await expectLogic(logic).toFinishAllListeners()
expect(move).toHaveBeenCalledWith('fld', 'Growth')
expect(logic.values.currentFolder).toEqual('Growth')
})
it('renameFolder ignores a blank name and an unsaved (id-less) folder', async () => {
const move = jest.spyOn(api.fileSystem, 'move').mockResolvedValue({} as any)
logic.actions.renameFolder({ id: 'fld', type: 'folder', path: 'Marketing' } as any, ' ')
logic.actions.renameFolder({ type: 'folder', path: 'Marketing' } as any, 'Growth')
await expectLogic(logic).toFinishAllListeners()
expect(move).not.toHaveBeenCalled()
})
it('deleteFolder delegates to the shared delete path (projectTreeDataLogic.deleteItem)', async () => {
jest.spyOn(api.fileSystem, 'count').mockResolvedValue({ count: 0 } as any)
jest.spyOn(api.fileSystem, 'delete').mockResolvedValue({ deleted: [] } as any)
await expectLogic(logic, () => {
logic.actions.deleteFolder({ id: 'fld', type: 'folder', path: 'Marketing' } as any)
}).toDispatchActions([projectTreeDataLogic.actionTypes.deleteItem])
})
it('on delete, refetches and falls back to root when the current folder OR an ancestor is gone', async () => {
await expectLogic(logic).toDispatchActions(['loadFolderEntriesSuccess'])
logic.actions.navigateToFolder('Marketing/Q1')
await expectLogic(logic, () => {
// Deleting the ancestor 'Marketing' while scoped to 'Marketing/Q1' must reset to root.
projectTreeDataLogic.actions.deleteSavedItem({ id: 'fld', type: 'folder', path: 'Marketing' } as any)
}).toDispatchActions(['loadFolderEntries', 'loadDashboardFileSystemEntries'])
expect(logic.values.currentFolder).toEqual('')
})
it('on delete of an unrelated folder, keeps the current scope', async () => {
logic.actions.navigateToFolder('Marketing')
projectTreeDataLogic.actions.deleteSavedItem({ id: 'fld', type: 'folder', path: 'Finance' } as any)
await expectLogic(logic).toFinishAllListeners()
expect(logic.values.currentFolder).toEqual('Marketing')
})
it('refetches on undo (restoredItems) so restored items reappear without a reload', async () => {
await expectLogic(logic).toDispatchActions(['loadFolderEntriesSuccess'])
await expectLogic(logic, () => {
projectTreeDataLogic.actions.restoredItems()
}).toDispatchActions(['loadFolderEntries', 'loadDashboardFileSystemEntries'])
})
it('renameFolder re-points a descendant scope onto the renamed path', async () => {
jest.spyOn(api.fileSystem, 'move').mockResolvedValue({} as any)
await expectLogic(logic).toDispatchActions(['loadFolderEntriesSuccess'])
logic.actions.navigateToFolder('Marketing/Q1')
logic.actions.renameFolder({ id: 'fld', type: 'folder', path: 'Marketing' } as any, 'Growth')
await expectLogic(logic).toFinishAllListeners()
// Renaming the ancestor 'Marketing' → 'Growth' moves the descendant scope to 'Growth/Q1'.
expect(logic.values.currentFolder).toEqual('Growth/Q1')
})
})