Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tests #19

Merged
merged 8 commits into from
Jul 30, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
235 changes: 235 additions & 0 deletions detector/detector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
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)

})

t.Run("legacy panel", 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.Len(t, out[0].Detections, 1)
require.Equal(t, "graph", out[0].Detections[0].PluginID)
require.Equal(t, output.DetectionTypeLegacyPanel, out[0].Detections[0].DetectionType)
require.Equal(t, "Flot graph", out[0].Detections[0].Title)
require.Equal(
t,
`Found legacy plugin "graph" in panel "Flot graph". It can be migrated to a React-based panel by Grafana when opening the dashboard.`,
out[0].Detections[0].String(),
)
})

t.Run("angular panel", func(t *testing.T) {
cl := NewTestAPIClient(filepath.Join("testdata", "dashboards", "worldmap.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.Len(t, out[0].Detections, 1)
require.Equal(t, "grafana-worldmap-panel", out[0].Detections[0].PluginID)
require.Equal(t, output.DetectionTypePanel, out[0].Detections[0].DetectionType)
require.Equal(t, "Panel Title", out[0].Detections[0].Title)
require.Equal(t, `Found angular panel "Panel Title" ("grafana-worldmap-panel")`, out[0].Detections[0].String())
})

t.Run("datasource", func(t *testing.T) {
cl := NewTestAPIClient(filepath.Join("testdata", "dashboards", "datasource.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.Len(t, out[0].Detections, 1)
require.Equal(t, "akumuli-datasource", out[0].Detections[0].PluginID)
require.Equal(t, output.DetectionTypeDatasource, out[0].Detections[0].DetectionType)
require.Equal(t, "akumuli", out[0].Detections[0].Title)
require.Equal(t, `Found panel with angular data source "akumuli" ("akumuli-datasource")`, out[0].Detections[0].String())
})

t.Run("not angular", func(t *testing.T) {
cl := NewTestAPIClient(filepath.Join("testdata", "dashboards", "not-angular.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.Empty(t, out[0].Detections)
})

t.Run("multiple", func(t *testing.T) {
cl := NewTestAPIClient(filepath.Join("testdata", "dashboards", "multiple.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.Len(t, out[0].Detections, 4)

exp := []struct {
pluginID string
detectionType output.DetectionType
title string
}{
{"akumuli-datasource", output.DetectionTypeDatasource, "akumuli"},
{"grafana-worldmap-panel", output.DetectionTypePanel, "worldmap + akumuli"},
{"akumuli-datasource", output.DetectionTypeDatasource, "worldmap + akumuli"},
{"graph", output.DetectionTypeLegacyPanel, "graph-old"},
}
for i, e := range exp {
require.Equalf(t, e.pluginID, out[0].Detections[i].PluginID, "plugin id %d", i)
require.Equalf(t, e.detectionType, out[0].Detections[i].DetectionType, "detection type %d", i)
require.Equalf(t, e.title, out[0].Detections[i].Title, "title %d", i)
}
})

t.Run("mixed", func(t *testing.T) {
// mix of angular and react panels

cl := NewTestAPIClient(filepath.Join("testdata", "dashboards", "mixed.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.Len(t, out[0].Detections, 1)
require.Equal(t, "angular", out[0].Detections[0].Title)
})

t.Run("rows", func(t *testing.T) {
t.Run("expanded", func(t *testing.T) {
cl := NewTestAPIClient(filepath.Join("testdata", "dashboards", "rows-expanded.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.Len(t, out[0].Detections, 1)
require.Equal(t, "expanded", out[0].Detections[0].Title)
})

t.Run("collapsed", func(t *testing.T) {
cl := NewTestAPIClient(filepath.Join("testdata", "dashboards", "rows-collapsed.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.Len(t, out[0].Detections, 1)
require.Equal(t, "collapsed", out[0].Detections[0].Title)
})
})
}

// 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