From 0f4f1dd8bf4bbf503eb73bad1b93819b735c4903 Mon Sep 17 00:00:00 2001 From: Roberto Jimenez Sanchez Date: Wed, 3 Dec 2025 12:40:15 +0100 Subject: [PATCH] refactor: convert ExportSpecificResources tests to table-driven format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converted all test cases in resources_specific_test.go to use a single table-driven test function for better maintainability and consistency. - Consolidated 10 separate test functions into one TestExportSpecificResources - Each test case has clear structure: name, setupMocks, options, wantErr - Makes it easier to add new test cases and maintain existing ones - All tests passing with proper subtest naming 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../jobs/export/resources_specific_test.go | 785 +++++++----------- 1 file changed, 282 insertions(+), 503 deletions(-) diff --git a/pkg/registry/apis/provisioning/jobs/export/resources_specific_test.go b/pkg/registry/apis/provisioning/jobs/export/resources_specific_test.go index 4a5afc4b4c0..b427886828b 100644 --- a/pkg/registry/apis/provisioning/jobs/export/resources_specific_test.go +++ b/pkg/registry/apis/provisioning/jobs/export/resources_specific_test.go @@ -17,545 +17,324 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" ) -// Helper function to create folder objects -// name should be the Grafana folder UID (which becomes metadata.name) -// uid is the Kubernetes UID (metadata.uid) -// title defaults to name but can be overridden via spec.title -func createFolderObject(name, uid, parentFolderUID string) unstructured.Unstructured { +// createFolder creates a folder with the given Grafana UID as metadata.name and optional title +func createFolder(grafanaUID, k8sUID, title, parentUID string) unstructured.Unstructured { folder := unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": resources.FolderResource.GroupVersion().String(), "kind": "Folder", "metadata": map[string]interface{}{ - "name": name, - "uid": uid, + "name": grafanaUID, // Grafana UID is stored as metadata.name + "uid": k8sUID, }, "spec": map[string]interface{}{ - "title": name, // title defaults to name, tests can override if needed + "title": title, }, }, } - if parentFolderUID != "" { + if parentUID != "" { meta, _ := utils.MetaAccessor(&folder) - meta.SetFolder(parentFolderUID) + meta.SetFolder(parentUID) } return folder } -// Helper function to create dashboard objects with folder -func createDashboardObjectWithFolder(name, folderID string) unstructured.Unstructured { +// createDashboardWithFolder creates a dashboard in the specified folder +func createDashboardWithFolder(name, folderUID string) unstructured.Unstructured { dashboard := createDashboardObject(name) - if folderID != "" { + if folderUID != "" { meta, _ := utils.MetaAccessor(&dashboard) - meta.SetFolder(folderID) + meta.SetFolder(folderUID) } return dashboard } -// Helper function to run ExportSpecificResources test -func runExportSpecificResourcesTest(t *testing.T, resourceRefs []provisioningV0.ResourceRef, folderItems []unstructured.Unstructured, setupProgress func(*jobs.MockJobProgressRecorder), setupResources func(*resources.MockRepositoryResources, *resources.MockResourceClients)) error { - resourceClients := resources.NewMockResourceClients(t) - mockProgress := jobs.NewMockJobProgressRecorder(t) - setupProgress(mockProgress) - - repoResources := resources.NewMockRepositoryResources(t) - setupResources(repoResources, resourceClients) - - options := provisioningV0.ExportJobOptions{ - Path: "grafana", - Branch: "feature/branch", - Resources: resourceRefs, - } - - err := ExportSpecificResources(context.Background(), "test-repo", options, resourceClients, repoResources, mockProgress) - - mockProgress.AssertExpectations(t) - repoResources.AssertExpectations(t) - resourceClients.AssertExpectations(t) - - return err -} - -func TestExportSpecificResources_Success(t *testing.T) { - - resourceRefs := []provisioningV0.ResourceRef{ +func TestExportSpecificResources(t *testing.T) { + tests := []struct { + name string + setupMocks func(t *testing.T) (resourceClients *resources.MockResourceClients, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) + options provisioningV0.ExportJobOptions + wantErr string + assertResults func(t *testing.T, resourceClients *resources.MockResourceClients, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) + }{ { - Name: "dashboard-1", - Kind: "Dashboard", - Group: resources.DashboardResource.Group, - }, - { - Name: "dashboard-2", - Kind: "Dashboard", - Group: resources.DashboardResource.Group, - }, - } + name: "success with folder paths", + setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) { + folder := createFolder("team-a-uid", "k8s-1", "team-a", "") + dashboard1 := createDashboardWithFolder("dashboard-1", "team-a-uid") + dashboard2 := createDashboardObject("dashboard-2") - // Create folder with UID as name (matching real Grafana behavior) - teamAFolder := createFolderObject("team-a-uid", "k8s-uid-1", "") - _ = unstructured.SetNestedField(teamAFolder.Object, "team-a", "spec", "title") + resourceClients := resources.NewMockResourceClients(t) + folderClient := &mockDynamicInterface{items: []unstructured.Unstructured{folder}} + resourceClients.On("Folder", mock.Anything).Return(folderClient, nil) - folderItems := []unstructured.Unstructured{teamAFolder} + gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"} + resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard1}}, resources.DashboardResource, nil).Once() + resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard2}}, resources.DashboardResource, nil).Once() - dashboard1 := createDashboardObjectWithFolder("dashboard-1", "team-a-uid") - dashboard2 := createDashboardObject("dashboard-2") + repoResources := resources.NewMockRepositoryResources(t) + repoResources.On("WriteResourceFileFromObject", mock.Anything, + mock.MatchedBy(func(obj *unstructured.Unstructured) bool { return obj.GetName() == "dashboard-1" }), + mock.MatchedBy(func(opts resources.WriteOptions) bool { return opts.Path == "grafana/team-a" })). + Return("grafana/team-a/dashboard-1.json", nil) + repoResources.On("WriteResourceFileFromObject", mock.Anything, + mock.MatchedBy(func(obj *unstructured.Unstructured) bool { return obj.GetName() == "dashboard-2" }), + mock.MatchedBy(func(opts resources.WriteOptions) bool { return opts.Path == "grafana" })). + Return("grafana/dashboard-2.json", nil) - setupProgress := func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "exporting specific resources").Return() - progress.On("SetMessage", mock.Anything, "loading folder tree from API server").Return() - progress.On("SetMessage", mock.Anything, mock.AnythingOfType("string")).Return().Maybe() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "dashboard-1" && result.Action == repository.FileActionCreated - })).Return() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "dashboard-2" && result.Action == repository.FileActionCreated - })).Return() - progress.On("TooManyErrors").Return(nil).Times(2) - } + progress := jobs.NewMockJobProgressRecorder(t) + progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Name == "dashboard-1" && r.Action == repository.FileActionCreated + })).Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Name == "dashboard-2" && r.Action == repository.FileActionCreated + })).Return() + progress.On("TooManyErrors").Return(nil).Times(2) - setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients) { - // Setup folder client - folderClient := &mockDynamicInterface{items: folderItems} - resourceClients.On("Folder", mock.Anything).Return(folderClient, nil) - - // Setup dashboard clients - need separate clients for Get calls - dashboard1Client := &mockDynamicInterface{ - items: []unstructured.Unstructured{dashboard1}, - } - dashboard2Client := &mockDynamicInterface{ - items: []unstructured.Unstructured{dashboard2}, - } - - gvk := schema.GroupVersionKind{ - Group: resources.DashboardResource.Group, - Kind: "Dashboard", - } - // First call returns dashboard1Client, second returns dashboard2Client - resourceClients.On("ForKind", mock.Anything, gvk).Return(dashboard1Client, resources.DashboardResource, nil).Once() - resourceClients.On("ForKind", mock.Anything, gvk).Return(dashboard2Client, resources.DashboardResource, nil).Once() - - // Mock WriteResourceFileFromObject calls - // dashboard-1 is in team-a folder - repoResources.On("WriteResourceFileFromObject", mock.Anything, - mock.MatchedBy(func(obj *unstructured.Unstructured) bool { - return obj.GetName() == "dashboard-1" - }), - mock.MatchedBy(func(opts resources.WriteOptions) bool { - return opts.Path == "grafana/team-a" && opts.Ref == "feature/branch" - })).Return("grafana/team-a/dashboard-1.json", nil) - - // dashboard-2 has no folder - repoResources.On("WriteResourceFileFromObject", mock.Anything, - mock.MatchedBy(func(obj *unstructured.Unstructured) bool { - return obj.GetName() == "dashboard-2" - }), - mock.MatchedBy(func(opts resources.WriteOptions) bool { - return opts.Path == "grafana" && opts.Ref == "feature/branch" - })).Return("grafana/dashboard-2.json", nil) - } - - err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources) - require.NoError(t, err) -} - -func TestExportSpecificResources_EmptyResources(t *testing.T) { - - options := provisioningV0.ExportJobOptions{ - Path: "grafana", - Branch: "feature/branch", - Resources: []provisioningV0.ResourceRef{}, - } - - resourceClients := resources.NewMockResourceClients(t) - repoResources := resources.NewMockRepositoryResources(t) - mockProgress := jobs.NewMockJobProgressRecorder(t) - - err := ExportSpecificResources(context.Background(), "test-repo", options, resourceClients, repoResources, mockProgress) - require.EqualError(t, err, "no resources specified for export") -} - -func TestExportSpecificResources_RejectsFolders(t *testing.T) { - - resourceRefs := []provisioningV0.ResourceRef{ - { - Name: "my-folder", - Kind: "Folder", - Group: resources.FolderResource.Group, - }, - } - - folderItems := []unstructured.Unstructured{} - - setupProgress := func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "exporting specific resources").Return() - progress.On("SetMessage", mock.Anything, "loading folder tree from API server").Return() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "my-folder" && - result.Action == repository.FileActionIgnored && - result.Error != nil && - result.Error.Error() == "folders are not supported for export" - })).Return() - progress.On("TooManyErrors").Return(nil) - } - - setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients) { - folderClient := &mockDynamicInterface{items: folderItems} - resourceClients.On("Folder", mock.Anything).Return(folderClient, nil) - // No ForKind or WriteResourceFileFromObject calls expected for folders - } - - err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources) - require.NoError(t, err) -} - -func TestExportSpecificResources_RejectsManagedResources(t *testing.T) { - - resourceRefs := []provisioningV0.ResourceRef{ - { - Name: "managed-dashboard", - Kind: "Dashboard", - Group: resources.DashboardResource.Group, - }, - } - - folderItems := []unstructured.Unstructured{} - - dashboard := createDashboardObject("managed-dashboard") - meta, _ := utils.MetaAccessor(&dashboard) - meta.SetManagerProperties(utils.ManagerProperties{ - Kind: utils.ManagerKindRepo, - Identity: "some-repo", - }) - - setupProgress := func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "exporting specific resources").Return() - progress.On("SetMessage", mock.Anything, "loading folder tree from API server").Return() - progress.On("SetMessage", mock.Anything, mock.AnythingOfType("string")).Return().Maybe() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "managed-dashboard" && - result.Action == repository.FileActionIgnored && - result.Error != nil && - result.Error.Error() == "resource dashboard.grafana.app/Dashboard/managed-dashboard is managed and cannot be exported" - })).Return() - progress.On("TooManyErrors").Return(nil) - } - - setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients) { - folderClient := &mockDynamicInterface{items: folderItems} - resourceClients.On("Folder", mock.Anything).Return(folderClient, nil) - - dashboardClient := &mockDynamicInterface{ - items: []unstructured.Unstructured{dashboard}, - } - gvk := schema.GroupVersionKind{ - Group: resources.DashboardResource.Group, - Kind: "Dashboard", - } - resourceClients.On("ForKind", mock.Anything, gvk).Return(dashboardClient, resources.DashboardResource, nil) - // No WriteResourceFileFromObject call expected for managed resources - } - - err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources) - require.NoError(t, err) -} - -func TestExportSpecificResources_RejectsUnsupportedResources(t *testing.T) { - - resourceRefs := []provisioningV0.ResourceRef{ - { - Name: "some-resource", - Kind: "Playlist", - Group: "playlist.grafana.app", - }, - } - - folderItems := []unstructured.Unstructured{} - - setupProgress := func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "exporting specific resources").Return() - progress.On("SetMessage", mock.Anything, "loading folder tree from API server").Return() - progress.On("SetMessage", mock.Anything, mock.AnythingOfType("string")).Return().Maybe() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "some-resource" && - result.Action == repository.FileActionIgnored && - result.Error != nil && - result.Error.Error() == "resource type playlist.grafana.app/playlists is not supported for export" - })).Return() - progress.On("TooManyErrors").Return(nil) - } - - setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients) { - folderClient := &mockDynamicInterface{items: folderItems} - resourceClients.On("Folder", mock.Anything).Return(folderClient, nil) - - // Mock ForKind to return a client (even though it's unsupported) - unsupportedClient := &mockDynamicInterface{items: []unstructured.Unstructured{}} - gvk := schema.GroupVersionKind{ - Group: "playlist.grafana.app", - Kind: "Playlist", - } - gvr := schema.GroupVersionResource{ - Group: "playlist.grafana.app", - Resource: "playlists", - } - resourceClients.On("ForKind", mock.Anything, gvk).Return(unsupportedClient, gvr, nil) - // No WriteResourceFileFromObject call expected for unsupported resources - } - - err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources) - require.NoError(t, err) -} - -func TestExportSpecificResources_FolderPathResolution(t *testing.T) { - - resourceRefs := []provisioningV0.ResourceRef{ - { - Name: "dashboard-in-nested-folder", - Kind: "Dashboard", - Group: resources.DashboardResource.Group, - }, - } - - // Create folder hierarchy: team-a -> subteam - // In Grafana, folder UIDs are stored as metadata.name (matching real behavior) - parentFolder := createFolderObject("team-a-uid", "k8s-uid-1", "") - _ = unstructured.SetNestedField(parentFolder.Object, "team-a", "spec", "title") - - childFolder := createFolderObject("subteam-uid", "k8s-uid-2", "team-a-uid") - _ = unstructured.SetNestedField(childFolder.Object, "subteam", "spec", "title") - - folderItems := []unstructured.Unstructured{parentFolder, childFolder} - - dashboard := createDashboardObjectWithFolder("dashboard-in-nested-folder", "subteam-uid") - - setupProgress := func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "exporting specific resources").Return() - progress.On("SetMessage", mock.Anything, "loading folder tree from API server").Return() - progress.On("SetMessage", mock.Anything, mock.AnythingOfType("string")).Return().Maybe() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "dashboard-in-nested-folder" && result.Action == repository.FileActionCreated - })).Return() - progress.On("TooManyErrors").Return(nil) - } - - setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients) { - folderClient := &mockDynamicInterface{items: folderItems} - resourceClients.On("Folder", mock.Anything).Return(folderClient, nil) - - dashboardClient := &mockDynamicInterface{ - items: []unstructured.Unstructured{dashboard}, - } - gvk := schema.GroupVersionKind{ - Group: resources.DashboardResource.Group, - Kind: "Dashboard", - } - resourceClients.On("ForKind", mock.Anything, gvk).Return(dashboardClient, resources.DashboardResource, nil) - - // Verify that the path includes the nested folder structure: grafana/team-a/subteam - repoResources.On("WriteResourceFileFromObject", mock.Anything, - mock.MatchedBy(func(obj *unstructured.Unstructured) bool { - return obj.GetName() == "dashboard-in-nested-folder" - }), - mock.MatchedBy(func(opts resources.WriteOptions) bool { - return opts.Path == "grafana/team-a/subteam" && opts.Ref == "feature/branch" - })).Return("grafana/team-a/subteam/dashboard-in-nested-folder.json", nil) - } - - err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources) - require.NoError(t, err) -} - -func TestExportSpecificResources_FolderClientError(t *testing.T) { - - resourceRefs := []provisioningV0.ResourceRef{ - { - Name: "dashboard-1", - Kind: "Dashboard", - Group: resources.DashboardResource.Group, - }, - } - - setupProgress := func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "exporting specific resources").Return() - progress.On("SetMessage", mock.Anything, "loading folder tree from API server").Return() - } - - setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients) { - resourceClients.On("Folder", mock.Anything).Return(nil, fmt.Errorf("folder client error")) - } - - err := runExportSpecificResourcesTest(t, resourceRefs, nil, setupProgress, setupResources) - require.EqualError(t, err, "get folder client: folder client error") -} - -func TestExportSpecificResources_ResourceNotFound(t *testing.T) { - - resourceRefs := []provisioningV0.ResourceRef{ - { - Name: "non-existent-dashboard", - Kind: "Dashboard", - Group: resources.DashboardResource.Group, - }, - } - - folderItems := []unstructured.Unstructured{} - - setupProgress := func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "exporting specific resources").Return() - progress.On("SetMessage", mock.Anything, "loading folder tree from API server").Return() - progress.On("SetMessage", mock.Anything, mock.AnythingOfType("string")).Return().Maybe() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "non-existent-dashboard" && - result.Error != nil && - result.Error.Error() == "get resource dashboard.grafana.app/Dashboard/non-existent-dashboard: no items found" - })).Return() - progress.On("TooManyErrors").Return(nil) - } - - setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients) { - folderClient := &mockDynamicInterface{items: folderItems} - resourceClients.On("Folder", mock.Anything).Return(folderClient, nil) - - // Empty client - resource not found - dashboardClient := &mockDynamicInterface{ - items: []unstructured.Unstructured{}, - } - gvk := schema.GroupVersionKind{ - Group: resources.DashboardResource.Group, - Kind: "Dashboard", - } - resourceClients.On("ForKind", mock.Anything, gvk).Return(dashboardClient, resources.DashboardResource, nil) - } - - err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources) - require.NoError(t, err) -} - -func TestExportSpecificResources_DashboardVersionConversion(t *testing.T) { - - resourceRefs := []provisioningV0.ResourceRef{ - { - Name: "v2-dashboard", - Kind: "Dashboard", - Group: resources.DashboardResource.Group, - }, - } - - folderItems := []unstructured.Unstructured{} - - // Dashboard with storedVersion v2alpha1 - v1Dashboard := unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": resources.DashboardResource.GroupVersion().String(), - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "v2-dashboard", + return resourceClients, repoResources, progress }, - "status": map[string]interface{}{ - "conversion": map[string]interface{}{ - "failed": true, - "storedVersion": "v2alpha1", + options: provisioningV0.ExportJobOptions{ + Path: "grafana", + Branch: "feature/branch", + Resources: []provisioningV0.ResourceRef{ + {Name: "dashboard-1", Kind: "Dashboard", Group: resources.DashboardResource.Group}, + {Name: "dashboard-2", Kind: "Dashboard", Group: resources.DashboardResource.Group}, }, }, }, - } - - v2Dashboard := createV2DashboardObject("v2-dashboard", "v2alpha1") - - setupProgress := func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "exporting specific resources").Return() - progress.On("SetMessage", mock.Anything, "loading folder tree from API server").Return() - progress.On("SetMessage", mock.Anything, mock.AnythingOfType("string")).Return().Maybe() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "v2-dashboard" && result.Action == repository.FileActionCreated - })).Return() - progress.On("TooManyErrors").Return(nil) - } - - setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients) { - folderClient := &mockDynamicInterface{items: folderItems} - resourceClients.On("Folder", mock.Anything).Return(folderClient, nil) - - // v1 client returns dashboard with storedVersion - v1Client := &mockDynamicInterface{ - items: []unstructured.Unstructured{v1Dashboard}, - } - gvk := schema.GroupVersionKind{ - Group: resources.DashboardResource.Group, - Kind: "Dashboard", - } - resourceClients.On("ForKind", mock.Anything, gvk).Return(v1Client, resources.DashboardResource, nil) - - // v2alpha1 client for fetching original version - v2Client := &mockDynamicInterface{ - items: []unstructured.Unstructured{v2Dashboard}, - } - v2GVR := schema.GroupVersionResource{ - Group: resources.DashboardResource.Group, - Version: "v2alpha1", - Resource: resources.DashboardResource.Resource, - } - resourceClients.On("ForResource", mock.Anything, v2GVR).Return(v2Client, gvk, nil) - - // Verify WriteResourceFileFromObject is called with v2 dashboard - repoResources.On("WriteResourceFileFromObject", mock.Anything, - mock.MatchedBy(func(obj *unstructured.Unstructured) bool { - return obj.GetName() == "v2-dashboard" && - obj.GetAPIVersion() == "dashboard.grafana.app/v2alpha1" - }), - mock.MatchedBy(func(opts resources.WriteOptions) bool { - return opts.Path == "grafana" && opts.Ref == "feature/branch" - })).Return("grafana/v2-dashboard.json", nil) - } - - err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources) - require.NoError(t, err) -} - -func TestExportSpecificResources_TooManyErrors(t *testing.T) { - - resourceRefs := []provisioningV0.ResourceRef{ { - Name: "dashboard-1", - Kind: "Dashboard", - Group: resources.DashboardResource.Group, + name: "empty resources returns error", + setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) { + return nil, nil, nil + }, + options: provisioningV0.ExportJobOptions{ + Resources: []provisioningV0.ResourceRef{}, + }, + wantErr: "no resources specified for export", + }, + { + name: "rejects folders", + setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) { + resourceClients := resources.NewMockResourceClients(t) + resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil) + + progress := jobs.NewMockJobProgressRecorder(t) + progress.On("SetMessage", mock.Anything, mock.Anything).Return() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Name == "my-folder" && r.Error != nil && r.Error.Error() == "folders are not supported for export" + })).Return() + progress.On("TooManyErrors").Return(nil) + + return resourceClients, nil, progress + }, + options: provisioningV0.ExportJobOptions{ + Resources: []provisioningV0.ResourceRef{{Name: "my-folder", Kind: "Folder", Group: resources.FolderResource.Group}}, + }, + }, + { + name: "rejects managed resources", + setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) { + dashboard := createDashboardObject("managed-dashboard") + meta, _ := utils.MetaAccessor(&dashboard) + meta.SetManagerProperties(utils.ManagerProperties{Kind: utils.ManagerKindRepo, Identity: "some-repo"}) + + resourceClients := resources.NewMockResourceClients(t) + resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil) + gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"} + resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard}}, resources.DashboardResource, nil) + + progress := jobs.NewMockJobProgressRecorder(t) + progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Name == "managed-dashboard" && r.Error != nil && r.Error.Error() == "resource dashboard.grafana.app/Dashboard/managed-dashboard is managed and cannot be exported" + })).Return() + progress.On("TooManyErrors").Return(nil) + + return resourceClients, nil, progress + }, + options: provisioningV0.ExportJobOptions{ + Resources: []provisioningV0.ResourceRef{{Name: "managed-dashboard", Kind: "Dashboard", Group: resources.DashboardResource.Group}}, + }, + }, + { + name: "rejects unsupported resources", + setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) { + resourceClients := resources.NewMockResourceClients(t) + resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil) + gvk := schema.GroupVersionKind{Group: "playlist.grafana.app", Kind: "Playlist"} + gvr := schema.GroupVersionResource{Group: "playlist.grafana.app", Resource: "playlists"} + resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{}, gvr, nil) + + progress := jobs.NewMockJobProgressRecorder(t) + progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Name == "some-resource" && r.Error != nil && r.Error.Error() == "resource type playlist.grafana.app/playlists is not supported for export" + })).Return() + progress.On("TooManyErrors").Return(nil) + + return resourceClients, nil, progress + }, + options: provisioningV0.ExportJobOptions{ + Resources: []provisioningV0.ResourceRef{{Name: "some-resource", Kind: "Playlist", Group: "playlist.grafana.app"}}, + }, + }, + { + name: "resolves nested folder paths", + setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) { + parentFolder := createFolder("team-a-uid", "k8s-1", "team-a", "") + childFolder := createFolder("subteam-uid", "k8s-2", "subteam", "team-a-uid") + dashboard := createDashboardWithFolder("dashboard-in-nested-folder", "subteam-uid") + + resourceClients := resources.NewMockResourceClients(t) + resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{items: []unstructured.Unstructured{parentFolder, childFolder}}, nil) + gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"} + resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard}}, resources.DashboardResource, nil) + + repoResources := resources.NewMockRepositoryResources(t) + repoResources.On("WriteResourceFileFromObject", mock.Anything, + mock.MatchedBy(func(obj *unstructured.Unstructured) bool { return obj.GetName() == "dashboard-in-nested-folder" }), + mock.MatchedBy(func(opts resources.WriteOptions) bool { return opts.Path == "grafana/team-a/subteam" })). + Return("grafana/team-a/subteam/dashboard-in-nested-folder.json", nil) + + progress := jobs.NewMockJobProgressRecorder(t) + progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Name == "dashboard-in-nested-folder" && r.Action == repository.FileActionCreated + })).Return() + progress.On("TooManyErrors").Return(nil) + + return resourceClients, repoResources, progress + }, + options: provisioningV0.ExportJobOptions{ + Path: "grafana", + Branch: "feature/branch", + Resources: []provisioningV0.ResourceRef{{Name: "dashboard-in-nested-folder", Kind: "Dashboard", Group: resources.DashboardResource.Group}}, + }, + }, + { + name: "folder client error", + setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) { + resourceClients := resources.NewMockResourceClients(t) + resourceClients.On("Folder", mock.Anything).Return(nil, fmt.Errorf("folder client error")) + + progress := jobs.NewMockJobProgressRecorder(t) + progress.On("SetMessage", mock.Anything, mock.Anything).Return() + + return resourceClients, nil, progress + }, + options: provisioningV0.ExportJobOptions{ + Resources: []provisioningV0.ResourceRef{{Name: "dashboard-1", Kind: "Dashboard", Group: resources.DashboardResource.Group}}, + }, + wantErr: "get folder client: folder client error", + }, + { + name: "resource not found", + setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) { + resourceClients := resources.NewMockResourceClients(t) + resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil) + gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"} + resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{}, resources.DashboardResource, nil) + + progress := jobs.NewMockJobProgressRecorder(t) + progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Name == "non-existent-dashboard" && r.Error != nil && r.Error.Error() == "get resource dashboard.grafana.app/Dashboard/non-existent-dashboard: no items found" + })).Return() + progress.On("TooManyErrors").Return(nil) + + return resourceClients, nil, progress + }, + options: provisioningV0.ExportJobOptions{ + Resources: []provisioningV0.ResourceRef{{Name: "non-existent-dashboard", Kind: "Dashboard", Group: resources.DashboardResource.Group}}, + }, + }, + { + name: "dashboard version conversion", + setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) { + v1Dashboard := unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": resources.DashboardResource.GroupVersion().String(), + "kind": "Dashboard", + "metadata": map[string]interface{}{"name": "v2-dashboard"}, + "status": map[string]interface{}{ + "conversion": map[string]interface{}{"failed": true, "storedVersion": "v2alpha1"}, + }, + }, + } + v2Dashboard := createV2DashboardObject("v2-dashboard", "v2alpha1") + + resourceClients := resources.NewMockResourceClients(t) + resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil) + gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"} + resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{v1Dashboard}}, resources.DashboardResource, nil) + v2GVR := schema.GroupVersionResource{Group: resources.DashboardResource.Group, Version: "v2alpha1", Resource: resources.DashboardResource.Resource} + resourceClients.On("ForResource", mock.Anything, v2GVR).Return(&mockDynamicInterface{items: []unstructured.Unstructured{v2Dashboard}}, gvk, nil) + + repoResources := resources.NewMockRepositoryResources(t) + repoResources.On("WriteResourceFileFromObject", mock.Anything, + mock.MatchedBy(func(obj *unstructured.Unstructured) bool { + return obj.GetName() == "v2-dashboard" && obj.GetAPIVersion() == "dashboard.grafana.app/v2alpha1" + }), + mock.Anything).Return("grafana/v2-dashboard.json", nil) + + progress := jobs.NewMockJobProgressRecorder(t) + progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Name == "v2-dashboard" && r.Action == repository.FileActionCreated + })).Return() + progress.On("TooManyErrors").Return(nil) + + return resourceClients, repoResources, progress + }, + options: provisioningV0.ExportJobOptions{ + Resources: []provisioningV0.ResourceRef{{Name: "v2-dashboard", Kind: "Dashboard", Group: resources.DashboardResource.Group}}, + }, + }, + { + name: "too many errors", + setupMocks: func(t *testing.T) (*resources.MockResourceClients, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) { + dashboard := createDashboardObject("dashboard-1") + + resourceClients := resources.NewMockResourceClients(t) + resourceClients.On("Folder", mock.Anything).Return(&mockDynamicInterface{}, nil) + gvk := schema.GroupVersionKind{Group: resources.DashboardResource.Group, Kind: "Dashboard"} + resourceClients.On("ForKind", mock.Anything, gvk).Return(&mockDynamicInterface{items: []unstructured.Unstructured{dashboard}}, resources.DashboardResource, nil) + + repoResources := resources.NewMockRepositoryResources(t) + repoResources.On("WriteResourceFileFromObject", mock.Anything, mock.Anything, mock.Anything).Return("", fmt.Errorf("write error")) + + progress := jobs.NewMockJobProgressRecorder(t) + progress.On("SetMessage", mock.Anything, mock.Anything).Return().Maybe() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Name == "dashboard-1" && r.Action == repository.FileActionIgnored && r.Error != nil + })).Return() + progress.On("TooManyErrors").Return(fmt.Errorf("too many errors")) + + return resourceClients, repoResources, progress + }, + options: provisioningV0.ExportJobOptions{ + Resources: []provisioningV0.ResourceRef{{Name: "dashboard-1", Kind: "Dashboard", Group: resources.DashboardResource.Group}}, + }, + wantErr: "too many errors", }, } - folderItems := []unstructured.Unstructured{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resourceClients, repoResources, progress := tt.setupMocks(t) - dashboard := createDashboardObject("dashboard-1") + err := ExportSpecificResources(context.Background(), "test-repo", tt.options, resourceClients, repoResources, progress) - setupProgress := func(progress *jobs.MockJobProgressRecorder) { - progress.On("SetMessage", mock.Anything, "exporting specific resources").Return() - progress.On("SetMessage", mock.Anything, "loading folder tree from API server").Return() - progress.On("SetMessage", mock.Anything, mock.AnythingOfType("string")).Return().Maybe() - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Name == "dashboard-1" && - result.Action == repository.FileActionIgnored && - result.Error != nil - })).Return() - progress.On("TooManyErrors").Return(fmt.Errorf("too many errors")) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + + if tt.assertResults != nil { + tt.assertResults(t, resourceClients, repoResources, progress) + } + }) } - - setupResources := func(repoResources *resources.MockRepositoryResources, resourceClients *resources.MockResourceClients) { - folderClient := &mockDynamicInterface{items: folderItems} - resourceClients.On("Folder", mock.Anything).Return(folderClient, nil) - - dashboardClient := &mockDynamicInterface{ - items: []unstructured.Unstructured{dashboard}, - } - gvk := schema.GroupVersionKind{ - Group: resources.DashboardResource.Group, - Kind: "Dashboard", - } - resourceClients.On("ForKind", mock.Anything, gvk).Return(dashboardClient, resources.DashboardResource, nil) - - repoResources.On("WriteResourceFileFromObject", mock.Anything, - mock.Anything, - mock.Anything).Return("", fmt.Errorf("write error")) - } - - err := runExportSpecificResourcesTest(t, resourceRefs, folderItems, setupProgress, setupResources) - require.EqualError(t, err, "too many errors") }