diff --git a/packages/grafana-test-utils/src/handlers/all-handlers.ts b/packages/grafana-test-utils/src/handlers/all-handlers.ts index 3e626ba021b..89662264875 100644 --- a/packages/grafana-test-utils/src/handlers/all-handlers.ts +++ b/packages/grafana-test-utils/src/handlers/all-handlers.ts @@ -2,8 +2,16 @@ import { HttpHandler } from 'msw'; import folderHandlers from './api/folders/handlers'; import teamsHandlers from './api/teams/handlers'; -import appPlatformFolderHandlers from './apis/dashboard.grafana.app/v0alpha1/handlers'; +import appPlatformDashboardv0alpha1Handlers from './apis/dashboard.grafana.app/v0alpha1/handlers'; +import appPlatformFolderv1beta1Handlers from './apis/folder.grafana.app/v1beta1/handlers'; +import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/handlers'; -const allHandlers: HttpHandler[] = [...teamsHandlers, ...folderHandlers, ...appPlatformFolderHandlers]; +const allHandlers: HttpHandler[] = [ + ...teamsHandlers, + ...folderHandlers, + ...appPlatformDashboardv0alpha1Handlers, + ...appPlatformFolderv1beta1Handlers, + ...appPlatformIamv0alpha1Handlers, +]; export default allHandlers; diff --git a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts index 103b816ce3a..2dcac8b0160 100644 --- a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts @@ -6,6 +6,27 @@ const [mockTree] = wellFormedTree(); const [mockTreeThatViewersCanEdit] = treeViewersCanEdit(); const collator = new Intl.Collator(); +// TODO: Generalise access control response and additional properties +const mockAccessControl = { + 'dashboards.permissions:write': true, + 'dashboards:create': true, +}; +const additionalProperties = { + canAdmin: true, + canDelete: true, + canEdit: true, + canSave: true, + created: '2025-07-14T12:07:36+02:00', + createdBy: 'Anonymous', + hasAcl: false, + id: 1, + orgId: 1, + updated: '2025-07-15T18:01:36+02:00', + updatedBy: 'Anonymous', + url: '/grafana/dashboards/f/1ca93012-1ffc-5d64-ae2e-54835c234c67/rik-cujahda-pi', + version: 1, +}; + const listFoldersHandler = () => http.get('/api/folders', ({ request }) => { const url = new URL(request.url); @@ -24,6 +45,7 @@ const listFoldersHandler = () => return { uid: folder.item.uid, title: folder.item.kind === 'folder' ? folder.item.title : "invalid - this shouldn't happen", + ...additionalProperties, }; }) .sort((a, b) => collator.compare(a.title, b.title)) // API always sorts by title @@ -33,10 +55,13 @@ const listFoldersHandler = () => }); const getFolderHandler = () => - http.get('/api/folders/:uid', ({ params }) => { + http.get('/api/folders/:uid', ({ params, request }) => { const { uid } = params; + const url = new URL(request.url); + const accessControlQueryParam = url.searchParams.get('accesscontrol'); const folder = mockTree.find((v) => v.item.uid === uid); + if (!folder) { return HttpResponse.json({ message: 'folder not found', status: 'not-found' }, { status: 404 }); } @@ -44,6 +69,8 @@ const getFolderHandler = () => return HttpResponse.json({ title: folder?.item.title, uid: folder?.item.uid, + ...additionalProperties, + ...(accessControlQueryParam ? { accessControl: mockAccessControl } : {}), }); }); diff --git a/packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts new file mode 100644 index 00000000000..3823e539ba3 --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts @@ -0,0 +1,117 @@ +import { HttpResponse, http } from 'msw'; + +import { wellFormedTree } from '../../../../fixtures/folders'; + +const [mockTree] = wellFormedTree(); + +const getFolderHandler = () => + http.get<{ folderUid: string; namespace: string }>( + '/apis/folder.grafana.app/v1beta1/namespaces/:namespace/folders/:folderUid', + ({ params }) => { + const { folderUid, namespace } = params; + const response = mockTree.find(({ item }) => { + return item.uid === folderUid; + }); + + if (!response) { + return HttpResponse.json( + { + kind: 'Status', + apiVersion: 'v1', + metadata: {}, + status: 'Failure', + message: 'folder not found', + code: 404, + }, + { status: 404 } + ); + } + + return HttpResponse.json({ + kind: 'Folder', + apiVersion: 'folder.grafana.app/v1beta1', + metadata: { + name: response.item.uid, + namespace, + uid: response.item.uid, + creationTimestamp: '2023-01-01T00:00:00Z', + annotations: { + // TODO: Generalise annotations in fixture data + 'grafana.app/createdBy': 'user:1', + 'grafana.app/updatedBy': 'user:2', + 'grafana.app/managedBy': 'user', + 'grafana.app/updatedTimestamp': '2024-01-01T00:00:00Z', + 'grafana.app/folder': response.item.kind === 'folder' ? response.item.parentUID : undefined, + }, + labels: { + 'grafana.app/deprecatedInternalID': '123', + }, + }, + spec: { title: response.item.title, description: '' }, + status: {}, + }); + } + ); + +const getFolderParentsHandler = () => + http.get<{ folderUid: string; namespace: string }>( + '/apis/folder.grafana.app/v1beta1/namespaces/:namespace/folders/:folderUid/parents', + ({ params }) => { + const { folderUid } = params; + + const folder = mockTree.find(({ item }) => { + return item.kind === 'folder' && item.uid === folderUid; + }); + if (!folder || folder.item.kind !== 'folder') { + return HttpResponse.json({ + kind: 'Status', + apiVersion: 'v1', + metadata: {}, + status: 'Failure', + message: 'folder not found', + code: 404, + }); + } + + const findParents = (parents: Array<(typeof mockTree)[number]>, folderUid?: string) => { + if (!folderUid) { + return parents; + } + + const parent = mockTree.find(({ item }) => { + return item.kind === 'folder' && item.uid === folderUid; + }); + + if (parent) { + parents.push(parent); + return findParents(parents, parent.item.kind === 'folder' ? parent.item.parentUID : undefined); + } + return parents; + }; + + const parents = findParents([], folder?.item?.parentUID); + + const mapped = parents.map((parent) => ({ + name: parent.item.uid, + title: parent.item.title, + parentUid: parent.item.kind === 'folder' ? parent.item.parentUID : undefined, + })); + + if (folder) { + mapped.push({ + name: folder.item.uid, + title: folder.item.title, + parentUid: folder.item.parentUID, + }); + } + + return HttpResponse.json({ + kind: 'FolderInfoList', + apiVersion: 'folder.grafana.app/v1beta1', + metadata: {}, + items: mapped, + }); + } + ); + +export default [getFolderHandler(), getFolderParentsHandler()]; diff --git a/packages/grafana-test-utils/src/handlers/apis/iam.grafana.app/v0alpha1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/iam.grafana.app/v0alpha1/handlers.ts new file mode 100644 index 00000000000..7a15817564b --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/apis/iam.grafana.app/v0alpha1/handlers.ts @@ -0,0 +1,29 @@ +import { HttpResponse, http } from 'msw'; + +const getDisplayMapping = () => + http.get<{ namespace: string }>('/apis/iam.grafana.app/v0alpha1/namespaces/:namespace/display', ({ request }) => { + const url = new URL(request.url); + const keys = url.searchParams.getAll('key'); + + // Turn query params such as `user:1` into mock mapping of `User 1` etc. + const mockMappings = keys.map((key) => { + const [_, id] = key.split(':'); + const displayName = `User ${id}`; + return { + identity: { + type: 'user', + name: `u00000000${id}`, + }, + displayName, + internalId: parseInt(id, 10), + }; + }); + + return HttpResponse.json({ + metadata: {}, + keys, + display: mockMappings, + }); + }); + +export default [getDisplayMapping()]; diff --git a/public/app/api/clients/folder/v1beta1/hooks.test.ts b/public/app/api/clients/folder/v1beta1/hooks.test.ts index 5d7de2639f2..e974c563b33 100644 --- a/public/app/api/clients/folder/v1beta1/hooks.test.ts +++ b/public/app/api/clients/folder/v1beta1/hooks.test.ts @@ -1,175 +1,107 @@ -import { QueryStatus } from '@reduxjs/toolkit/query'; -import { renderHook } from '@testing-library/react'; +import { renderHook, getWrapper, waitFor } from 'test/test-utils'; -import { config } from '@grafana/runtime'; -import { useGetFolderQuery as useGetFolderQueryLegacy } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; - -import { - AnnoKeyCreatedBy, - AnnoKeyFolder, - AnnoKeyManagerKind, - AnnoKeyUpdatedBy, - AnnoKeyUpdatedTimestamp, - DeprecatedInternalId, -} from '../../../../features/apiserver/types'; -import { useGetDisplayMappingQuery } from '../../iam/v0alpha1'; +import { config, setBackendSrv } from '@grafana/runtime'; +import { setupMockServer } from '@grafana/test-utils/server'; +import { getFolderFixtures } from '@grafana/test-utils/unstable'; +import { backendSrv } from 'app/core/services/backend_srv'; import { useGetFolderQueryFacade } from './hooks'; -import { useGetFolderQuery, useGetFolderParentsQuery } from './index'; +setBackendSrv(backendSrv); +setupMockServer(); -// Mocks for the hooks used inside useGetFolderQueryFacade -jest.mock('./index', () => ({ - useGetFolderQuery: jest.fn(), - useGetFolderParentsQuery: jest.fn(), -})); +const [_, { folderA, folderA_folderA }] = getFolderFixtures(); -jest.mock('app/features/browse-dashboards/api/browseDashboardsAPI', () => ({ - useGetFolderQuery: jest.fn(), -})); +const expectedUid = folderA_folderA.item.uid; +const expectedTitle = folderA_folderA.item.title; +const urlSlug = expectedTitle.toLowerCase().replace(/ /g, '-').replace(/[\.]/g, ''); +const expectedUrl = `/grafana/dashboards/f/${expectedUid}/${urlSlug}`; -jest.mock('../../iam/v0alpha1', () => ({ - useGetDisplayMappingQuery: jest.fn(), -})); +const parentUrlSlug = folderA.item.title.toLowerCase().replace(/ /g, '-').replace(/[\.]/g, ''); +const expectedParentUrl = `/grafana/dashboards/f/${folderA.item.uid}/${parentUrlSlug}`; -// Mock config and constants -jest.mock('@grafana/runtime', () => { - const runtime = jest.requireActual('@grafana/runtime'); - return { - ...runtime, - config: { - ...runtime.config, - featureToggles: { - ...runtime.config.featureToggles, - foldersAppPlatformAPI: true, - }, - appSubUrl: '/grafana', - }, - }; -}); - -const mockFolder = { - data: { - metadata: { - name: 'folder-uid', - labels: { [DeprecatedInternalId]: '123' }, - annotations: { - [AnnoKeyUpdatedBy]: 'user-1', - [AnnoKeyCreatedBy]: 'user-2', - [AnnoKeyFolder]: 'parent-uid', - [AnnoKeyManagerKind]: 'user', - [AnnoKeyUpdatedTimestamp]: '2024-01-01T00:00:00Z', - }, - creationTimestamp: '2023-01-01T00:00:00Z', - generation: 2, - }, - spec: { title: 'Test Folder' }, - }, - ...getResponseAttributes(), +const renderFolderHook = async () => { + const { result } = renderHook(() => useGetFolderQueryFacade(folderA_folderA.item.uid), { + wrapper: getWrapper({}), + }); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + return result; }; -const mockParents = { - data: { items: [{ name: 'parent-uid', title: 'Parent Folder' }] }, - ...getResponseAttributes(), -}; - -const mockLegacyResponse = { - data: { - id: 1, - uid: 'uiduiduid', - orgId: 1, - title: 'bar', - url: '/dashboards/f/uiduiduid/bar', - hasAcl: false, - canSave: true, - canEdit: true, - canAdmin: true, - canDelete: true, - createdBy: 'Anonymous', - created: '2025-07-14T12:07:36+02:00', - updatedBy: 'Anonymous', - updated: '2025-07-15T18:01:36+02:00', - version: 1, - accessControl: { - 'dashboards.permissions:write': true, - 'dashboards:create': true, - }, - }, - ...getResponseAttributes(), -}; - -const mockUserDisplay = { - data: { - keys: ['user-1', 'user-2'], - display: [{ displayName: 'User One' }, { displayName: 'User Two' }], - }, - ...getResponseAttributes(), -}; +const originalToggles = { ...config.featureToggles }; +const originalAppSubUrl = String(config.appSubUrl); describe('useGetFolderQueryFacade', () => { - const oldToggleValue = config.featureToggles.foldersAppPlatformAPI; - - afterAll(() => { - config.featureToggles.foldersAppPlatformAPI = oldToggleValue; - }); - beforeEach(() => { - (useGetFolderQuery as jest.Mock).mockReturnValue(mockFolder); - (useGetFolderParentsQuery as jest.Mock).mockReturnValue(mockParents); - (useGetDisplayMappingQuery as jest.Mock).mockReturnValue(mockUserDisplay); - (useGetFolderQueryLegacy as jest.Mock).mockReturnValue(mockLegacyResponse); + config.appSubUrl = '/grafana'; }); - it('merges multiple responses into a single FolderDTO-like object if flag is true', () => { + afterEach(() => { + config.featureToggles = originalToggles; + config.appSubUrl = originalAppSubUrl; + }); + + it('merges multiple responses into a single FolderDTO-like object if flag is true', async () => { config.featureToggles.foldersAppPlatformAPI = true; - const { result } = renderHook(() => useGetFolderQueryFacade('folder-uid')); + + const result = await renderFolderHook(); + expect(result.current.data).toMatchObject({ canAdmin: true, canDelete: true, canEdit: true, canSave: true, created: '2023-01-01T00:00:00Z', - createdBy: 'User Two', + createdBy: 'User 1', hasAcl: false, id: 123, - parentUid: 'parent-uid', + parentUid: folderA.item.uid, managedBy: 'user', - title: 'Test Folder', - uid: 'folder-uid', + title: expectedTitle, + uid: expectedUid, updated: '2024-01-01T00:00:00Z', - updatedBy: 'User One', - url: '/grafana/dashboards/f/folder-uid/test-folder', - version: 2, + updatedBy: 'User 2', + url: expectedUrl, + version: 1, accessControl: { 'dashboards.permissions:write': true, 'dashboards:create': true, }, parents: [ { - title: 'Parent Folder', - uid: 'parent-uid', - url: '/grafana/dashboards/f/parent-uid/parent-folder', + title: folderA.item.title, + uid: folderA.item.uid, + url: expectedParentUrl, }, ], }); }); - it('returns legacy folder response if flag is false', () => { + it('returns legacy folder response if flag is false', async () => { config.featureToggles.foldersAppPlatformAPI = false; - const { result } = renderHook(() => useGetFolderQueryFacade('folder-uid')); - expect(result.current.data).toMatchObject(mockLegacyResponse.data); + const result = await renderFolderHook(); + expect(result.current.data).toMatchObject({ + id: 1, + title: folderA_folderA.item.title, + url: expectedUrl, + uid: expectedUid, + orgId: 1, + hasAcl: false, + canSave: true, + canEdit: true, + canAdmin: true, + canDelete: true, + createdBy: 'Anonymous', + created: '2025-07-14T12:07:36+02:00', + updatedBy: 'Anonymous', + updated: '2025-07-15T18:01:36+02:00', + version: 1, + accessControl: { + 'dashboards.permissions:write': true, + 'dashboards:create': true, + }, + }); }); }); - -function getResponseAttributes() { - return { - status: QueryStatus.fulfilled, - isUninitialized: false, - isLoading: false, - isFetching: false, - isSuccess: true, - isError: false, - error: undefined, - refetch: jest.fn(), - }; -}