Skip to content

Commit

Permalink
Add tests (#19)
Browse files Browse the repository at this point in the history
* Fix detection not working for collapsed rows

* add some tests

* Fix collapsed rows detection

* Add expanded rows test

* Add docstrings

* Simplify test client

* combine test cases
  • Loading branch information
xnyo authored Jul 30, 2024
1 parent 356153b commit 9a6c364
Show file tree
Hide file tree
Showing 17 changed files with 9,691 additions and 7 deletions.
12 changes: 8 additions & 4 deletions api/grafana/grafana.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ func NewAPIClient(client api.Client) APIClient {
return APIClient{Client: client}
}

func (cl APIClient) BaseURL() string {
return cl.Client.BaseURL
}

func (cl APIClient) GetPlugins(ctx context.Context) ([]Plugin, error) {
var out []Plugin
err := cl.Request(ctx, http.MethodGet, "plugins", &out)
Expand All @@ -48,18 +52,18 @@ func (cl APIClient) GetDashboard(ctx context.Context, uid string) (*DashboardDef
if err := cl.Request(ctx, http.MethodGet, "dashboards/uid/"+uid, &out); err != nil {
return nil, err
}
convertPanels(out.Dashboard.Panels)
ConvertPanels(out.Dashboard.Panels)
return out, nil
}

// convertPanels recursively converts datasources map[string]interface{} to custom type.
// ConvertPanels recursively converts datasources map[string]interface{} to custom type.
// The datasource field can either be a string (old) or object (new).
// Could check for schema, but this is easier.
func convertPanels(panels []*DashboardPanel) {
func ConvertPanels(panels []*DashboardPanel) {
for _, panel := range panels {
// Recurse
if len(panel.Panels) > 0 {
convertPanels(panel.Panels)
ConvertPanels(panel.Panels)
}

m, ok := panel.Datasource.(map[string]interface{})
Expand Down
18 changes: 15 additions & 3 deletions detector/detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,30 @@ const (
pluginIDTableOld = "table-old"
)

// GrafanaDetectorAPIClient is an interface that can be used to interact with the Grafana API for
// detecting Angular plugins.
type GrafanaDetectorAPIClient interface {
BaseURL() string
GetPlugins(ctx context.Context) ([]grafana.Plugin, error)
GetFrontendSettings(ctx context.Context) (*grafana.FrontendSettings, error)
GetServiceAccountPermissions(ctx context.Context) (map[string][]string, error)
GetDatasourcePluginIDs(ctx context.Context) ([]grafana.Datasource, error)
GetDashboards(ctx context.Context, page int) ([]grafana.ListedDashboard, error)
GetDashboard(ctx context.Context, uid string) (*grafana.DashboardDefinition, error)
}

// Detector can detect Angular plugins in Grafana dashboards.
type Detector struct {
log *logger.LeveledLogger
grafanaClient grafana.APIClient
grafanaClient GrafanaDetectorAPIClient
gcomClient gcom.APIClient

angularDetected map[string]bool
datasourcePluginIDs map[string]string
}

// NewDetector returns a new Detector.
func NewDetector(log *logger.LeveledLogger, grafanaClient grafana.APIClient, gcomClient gcom.APIClient) *Detector {
func NewDetector(log *logger.LeveledLogger, grafanaClient GrafanaDetectorAPIClient, gcomClient gcom.APIClient) *Detector {
return &Detector{
log: log,
grafanaClient: grafanaClient,
Expand Down Expand Up @@ -144,7 +156,7 @@ func (d *Detector) Run(ctx context.Context) ([]output.Dashboard, error) {

for _, dash := range dashboards {
// Determine absolute dashboard URL for output
dashboardAbsURL, err := url.JoinPath(strings.TrimSuffix(d.grafanaClient.BaseURL, "/api"), dash.URL)
dashboardAbsURL, err := url.JoinPath(strings.TrimSuffix(d.grafanaClient.BaseURL(), "/api"), dash.URL)
if err != nil {
// Silently ignore errors
dashboardAbsURL = ""
Expand Down
221 changes: 221 additions & 0 deletions detector/detector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package detector

import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"

"github.com/grafana/detect-angular-dashboards/api/gcom"
"github.com/grafana/detect-angular-dashboards/api/grafana"
"github.com/grafana/detect-angular-dashboards/logger"
"github.com/grafana/detect-angular-dashboards/output"
)

func TestDetector(t *testing.T) {
t.Run("meta", func(t *testing.T) {
cl := NewTestAPIClient(filepath.Join("testdata", "dashboards", "graph-old.json"))
d := NewDetector(logger.NewLeveledLogger(false), cl, gcom.NewAPIClient())
out, err := d.Run(context.Background())
require.NoError(t, err)
require.Len(t, out, 1)
require.Equal(t, "test case dashboard", out[0].Title)
require.Equal(t, "test case folder", out[0].Folder)
require.Equal(t, "d/test-case-dashboard/test-case-dashboard", out[0].URL)
require.Equal(t, "admin", out[0].CreatedBy)
require.Equal(t, "admin", out[0].UpdatedBy)
require.Equal(t, "2023-11-07T11:13:24+01:00", out[0].Created)
require.Equal(t, "2024-02-21T13:09:27+01:00", out[0].Updated)
})

type expDetection struct {
pluginID string
detectionType output.DetectionType
title string
message string
}
for _, tc := range []struct {
name string
file string
expDetections []expDetection
}{
{
name: "legacy panel",
file: "graph-old.json",
expDetections: []expDetection{{
pluginID: "graph",
detectionType: output.DetectionTypeLegacyPanel,
title: "Flot graph",
message: `Found legacy plugin "graph" in panel "Flot graph". It can be migrated to a React-based panel by Grafana when opening the dashboard.`,
}},
},
{
name: "angular panel",
file: "worldmap.json",
expDetections: []expDetection{{
pluginID: "grafana-worldmap-panel",
detectionType: output.DetectionTypePanel,
title: "Panel Title",
message: `Found angular panel "Panel Title" ("grafana-worldmap-panel")`,
}},
},
{
name: "datasource",
file: "datasource.json",
expDetections: []expDetection{{
pluginID: "akumuli-datasource",
detectionType: output.DetectionTypeDatasource,
title: "akumuli",
message: `Found panel with angular data source "akumuli" ("akumuli-datasource")`,
}},
},
{
name: "multiple",
file: "multiple.json",
expDetections: []expDetection{
{pluginID: "akumuli-datasource", detectionType: output.DetectionTypeDatasource, title: "akumuli"},
{pluginID: "grafana-worldmap-panel", detectionType: output.DetectionTypePanel, title: "worldmap + akumuli"},
{pluginID: "akumuli-datasource", detectionType: output.DetectionTypeDatasource, title: "worldmap + akumuli"},
{pluginID: "graph", detectionType: output.DetectionTypeLegacyPanel, title: "graph-old"},
},
},
{
name: "not angular",
file: "not-angular.json",
expDetections: nil,
},
{
name: "mix of angular and react",
file: "mixed.json",
expDetections: []expDetection{
{pluginID: "grafana-worldmap-panel", detectionType: output.DetectionTypePanel, title: "angular"},
},
},
{
name: "rows expanded",
file: "rows-expanded.json",
expDetections: []expDetection{
{pluginID: "grafana-worldmap-panel", detectionType: output.DetectionTypePanel, title: "expanded"},
},
},
{
name: "rows collapsed",
file: "rows-collapsed.json",
expDetections: []expDetection{
{pluginID: "grafana-worldmap-panel", detectionType: output.DetectionTypePanel, title: "collapsed"},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
cl := NewTestAPIClient(filepath.Join("testdata", "dashboards", tc.file))
d := NewDetector(logger.NewLeveledLogger(false), cl, gcom.NewAPIClient())
out, err := d.Run(context.Background())
require.NoError(t, err)
require.Len(t, out, 1, "should have result for one dashboard")
detections := out[0].Detections
require.Len(t, detections, len(tc.expDetections), "should have the correct number of detections in the dashboard")
for i, actual := range detections {
exp := tc.expDetections[i]
require.Equal(t, exp.pluginID, actual.PluginID)
require.Equal(t, exp.detectionType, actual.DetectionType)
require.Equal(t, exp.title, actual.Title)
if exp.message != "" {
require.Equal(t, exp.message, actual.String())
}
}
})
}
}

// TestAPIClient is a GrafanaDetectorAPIClient implementation for testing.
type TestAPIClient struct {
DashboardJSONFilePath string
DashboardMetaFilePath string
FrontendSettingsFilePath string
DatasourcesFilePath string
PluginsFilePath string
}

func NewTestAPIClient(dashboardJSONFilePath string) *TestAPIClient {
return &TestAPIClient{
DashboardJSONFilePath: dashboardJSONFilePath,
DashboardMetaFilePath: filepath.Join("testdata", "dashboard-meta.json"),
FrontendSettingsFilePath: filepath.Join("testdata", "frontend-settings.json"),
DatasourcesFilePath: filepath.Join("testdata", "datasources.json"),
PluginsFilePath: filepath.Join("testdata", "plugins.json"),
}
}

// unmarshalFromFile unmarshals JSON from a file into out, which must be a pointer to a value.
func unmarshalFromFile(fn string, out any) error {
f, err := os.Open(fn)
if err != nil {
return err
}
defer f.Close()
return json.NewDecoder(f).Decode(out)
}

// BaseURL always returns an empty string.
func (c *TestAPIClient) BaseURL() string {
return ""
}

// GetPlugins returns plugins the content of c.PluginsFilePath.
func (c *TestAPIClient) GetPlugins(_ context.Context) (plugins []grafana.Plugin, err error) {
err = unmarshalFromFile(c.PluginsFilePath, &plugins)
return
}

// GetDatasourcePluginIDs returns the content of c.DatasourcesFilePath.
func (c *TestAPIClient) GetDatasourcePluginIDs(_ context.Context) (datasources []grafana.Datasource, err error) {
err = unmarshalFromFile(c.DatasourcesFilePath, &datasources)
return
}

// GetDashboards returns a dummy response with only one dashboard.
func (c *TestAPIClient) GetDashboards(_ context.Context, _ int) ([]grafana.ListedDashboard, error) {
return []grafana.ListedDashboard{
{
UID: "test-case-dashboard",
URL: "/d/test-case-dashboard/test-case-dashboard",
Title: "test case dashboard",
},
}, nil
}

// GetDashboard returns a new DashboardDefinition that can be used for testing purposes.
// The dashboard definition is taken from the file specified in c.DashboardJSONFilePath.
// The dashboard meta is taken from the file specified in c.DashboardMetaFilePath.
func (c *TestAPIClient) GetDashboard(_ context.Context, _ string) (*grafana.DashboardDefinition, error) {
if c.DashboardJSONFilePath == "" {
return nil, fmt.Errorf("TestAPIClient DashboardJSONFilePath cannot be empty")
}
var out grafana.DashboardDefinition
if err := unmarshalFromFile(c.DashboardMetaFilePath, &out); err != nil {
return nil, fmt.Errorf("unmarshal meta: %w", err)
}
if err := unmarshalFromFile(c.DashboardJSONFilePath, &out.Dashboard); err != nil {
return nil, fmt.Errorf("unmarshal dashboard: %w", err)
}
grafana.ConvertPanels(out.Dashboard.Panels)
return &out, nil
}

// GetFrontendSettings returns the content of c.FrontendSettingsFilePath.
func (c *TestAPIClient) GetFrontendSettings(_ context.Context) (frontendSettings *grafana.FrontendSettings, err error) {
err = unmarshalFromFile(c.FrontendSettingsFilePath, &frontendSettings)
return
}

// GetServiceAccountPermissions is not implemented for testing purposes and always returns an empty map and a nil error.
func (c *TestAPIClient) GetServiceAccountPermissions(_ context.Context) (map[string][]string, error) {
return nil, nil
}

// static check
var _ GrafanaDetectorAPIClient = &TestAPIClient{}
39 changes: 39 additions & 0 deletions detector/testdata/dashboard-meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"meta": {
"type": "db",
"canSave": true,
"canEdit": true,
"canAdmin": true,
"canStar": true,
"canDelete": true,
"slug": "test-case-dashboard",
"url": "/d/test-case-dashboard/test-case-dashboard",
"expires": "0001-01-01T00:00:00Z",
"created": "2023-11-07T11:13:24+01:00",
"updated": "2024-02-21T13:09:27+01:00",
"updatedBy": "admin",
"createdBy": "admin",
"version": 6,
"hasAcl": false,
"isFolder": false,
"folderId": 200,
"folderUid": "test-case-folder",
"folderTitle": "test case folder",
"folderUrl": "/dashboards/f/test-case-folder/test-case-folder",
"provisioned": false,
"provisionedExternalId": "",
"annotationsPermissions": {
"dashboard": {
"canAdd": true,
"canEdit": true,
"canDelete": true
},
"organization": {
"canAdd": true,
"canEdit": true,
"canDelete": true
}
}
},
"dashboard": null
}
Loading

0 comments on commit 9a6c364

Please sign in to comment.