Skip to content

Commit 9a6c364

Browse files
authored
Add tests (#19)
* 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
1 parent 356153b commit 9a6c364

17 files changed

+9691
-7
lines changed

api/grafana/grafana.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ func NewAPIClient(client api.Client) APIClient {
2222
return APIClient{Client: client}
2323
}
2424

25+
func (cl APIClient) BaseURL() string {
26+
return cl.Client.BaseURL
27+
}
28+
2529
func (cl APIClient) GetPlugins(ctx context.Context) ([]Plugin, error) {
2630
var out []Plugin
2731
err := cl.Request(ctx, http.MethodGet, "plugins", &out)
@@ -48,18 +52,18 @@ func (cl APIClient) GetDashboard(ctx context.Context, uid string) (*DashboardDef
4852
if err := cl.Request(ctx, http.MethodGet, "dashboards/uid/"+uid, &out); err != nil {
4953
return nil, err
5054
}
51-
convertPanels(out.Dashboard.Panels)
55+
ConvertPanels(out.Dashboard.Panels)
5256
return out, nil
5357
}
5458

55-
// convertPanels recursively converts datasources map[string]interface{} to custom type.
59+
// ConvertPanels recursively converts datasources map[string]interface{} to custom type.
5660
// The datasource field can either be a string (old) or object (new).
5761
// Could check for schema, but this is easier.
58-
func convertPanels(panels []*DashboardPanel) {
62+
func ConvertPanels(panels []*DashboardPanel) {
5963
for _, panel := range panels {
6064
// Recurse
6165
if len(panel.Panels) > 0 {
62-
convertPanels(panel.Panels)
66+
ConvertPanels(panel.Panels)
6367
}
6468

6569
m, ok := panel.Datasource.(map[string]interface{})

detector/detector.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,30 @@ const (
1818
pluginIDTableOld = "table-old"
1919
)
2020

21+
// GrafanaDetectorAPIClient is an interface that can be used to interact with the Grafana API for
22+
// detecting Angular plugins.
23+
type GrafanaDetectorAPIClient interface {
24+
BaseURL() string
25+
GetPlugins(ctx context.Context) ([]grafana.Plugin, error)
26+
GetFrontendSettings(ctx context.Context) (*grafana.FrontendSettings, error)
27+
GetServiceAccountPermissions(ctx context.Context) (map[string][]string, error)
28+
GetDatasourcePluginIDs(ctx context.Context) ([]grafana.Datasource, error)
29+
GetDashboards(ctx context.Context, page int) ([]grafana.ListedDashboard, error)
30+
GetDashboard(ctx context.Context, uid string) (*grafana.DashboardDefinition, error)
31+
}
32+
2133
// Detector can detect Angular plugins in Grafana dashboards.
2234
type Detector struct {
2335
log *logger.LeveledLogger
24-
grafanaClient grafana.APIClient
36+
grafanaClient GrafanaDetectorAPIClient
2537
gcomClient gcom.APIClient
2638

2739
angularDetected map[string]bool
2840
datasourcePluginIDs map[string]string
2941
}
3042

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

145157
for _, dash := range dashboards {
146158
// Determine absolute dashboard URL for output
147-
dashboardAbsURL, err := url.JoinPath(strings.TrimSuffix(d.grafanaClient.BaseURL, "/api"), dash.URL)
159+
dashboardAbsURL, err := url.JoinPath(strings.TrimSuffix(d.grafanaClient.BaseURL(), "/api"), dash.URL)
148160
if err != nil {
149161
// Silently ignore errors
150162
dashboardAbsURL = ""

detector/detector_test.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package detector
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
13+
"github.com/grafana/detect-angular-dashboards/api/gcom"
14+
"github.com/grafana/detect-angular-dashboards/api/grafana"
15+
"github.com/grafana/detect-angular-dashboards/logger"
16+
"github.com/grafana/detect-angular-dashboards/output"
17+
)
18+
19+
func TestDetector(t *testing.T) {
20+
t.Run("meta", func(t *testing.T) {
21+
cl := NewTestAPIClient(filepath.Join("testdata", "dashboards", "graph-old.json"))
22+
d := NewDetector(logger.NewLeveledLogger(false), cl, gcom.NewAPIClient())
23+
out, err := d.Run(context.Background())
24+
require.NoError(t, err)
25+
require.Len(t, out, 1)
26+
require.Equal(t, "test case dashboard", out[0].Title)
27+
require.Equal(t, "test case folder", out[0].Folder)
28+
require.Equal(t, "d/test-case-dashboard/test-case-dashboard", out[0].URL)
29+
require.Equal(t, "admin", out[0].CreatedBy)
30+
require.Equal(t, "admin", out[0].UpdatedBy)
31+
require.Equal(t, "2023-11-07T11:13:24+01:00", out[0].Created)
32+
require.Equal(t, "2024-02-21T13:09:27+01:00", out[0].Updated)
33+
})
34+
35+
type expDetection struct {
36+
pluginID string
37+
detectionType output.DetectionType
38+
title string
39+
message string
40+
}
41+
for _, tc := range []struct {
42+
name string
43+
file string
44+
expDetections []expDetection
45+
}{
46+
{
47+
name: "legacy panel",
48+
file: "graph-old.json",
49+
expDetections: []expDetection{{
50+
pluginID: "graph",
51+
detectionType: output.DetectionTypeLegacyPanel,
52+
title: "Flot graph",
53+
message: `Found legacy plugin "graph" in panel "Flot graph". It can be migrated to a React-based panel by Grafana when opening the dashboard.`,
54+
}},
55+
},
56+
{
57+
name: "angular panel",
58+
file: "worldmap.json",
59+
expDetections: []expDetection{{
60+
pluginID: "grafana-worldmap-panel",
61+
detectionType: output.DetectionTypePanel,
62+
title: "Panel Title",
63+
message: `Found angular panel "Panel Title" ("grafana-worldmap-panel")`,
64+
}},
65+
},
66+
{
67+
name: "datasource",
68+
file: "datasource.json",
69+
expDetections: []expDetection{{
70+
pluginID: "akumuli-datasource",
71+
detectionType: output.DetectionTypeDatasource,
72+
title: "akumuli",
73+
message: `Found panel with angular data source "akumuli" ("akumuli-datasource")`,
74+
}},
75+
},
76+
{
77+
name: "multiple",
78+
file: "multiple.json",
79+
expDetections: []expDetection{
80+
{pluginID: "akumuli-datasource", detectionType: output.DetectionTypeDatasource, title: "akumuli"},
81+
{pluginID: "grafana-worldmap-panel", detectionType: output.DetectionTypePanel, title: "worldmap + akumuli"},
82+
{pluginID: "akumuli-datasource", detectionType: output.DetectionTypeDatasource, title: "worldmap + akumuli"},
83+
{pluginID: "graph", detectionType: output.DetectionTypeLegacyPanel, title: "graph-old"},
84+
},
85+
},
86+
{
87+
name: "not angular",
88+
file: "not-angular.json",
89+
expDetections: nil,
90+
},
91+
{
92+
name: "mix of angular and react",
93+
file: "mixed.json",
94+
expDetections: []expDetection{
95+
{pluginID: "grafana-worldmap-panel", detectionType: output.DetectionTypePanel, title: "angular"},
96+
},
97+
},
98+
{
99+
name: "rows expanded",
100+
file: "rows-expanded.json",
101+
expDetections: []expDetection{
102+
{pluginID: "grafana-worldmap-panel", detectionType: output.DetectionTypePanel, title: "expanded"},
103+
},
104+
},
105+
{
106+
name: "rows collapsed",
107+
file: "rows-collapsed.json",
108+
expDetections: []expDetection{
109+
{pluginID: "grafana-worldmap-panel", detectionType: output.DetectionTypePanel, title: "collapsed"},
110+
},
111+
},
112+
} {
113+
t.Run(tc.name, func(t *testing.T) {
114+
cl := NewTestAPIClient(filepath.Join("testdata", "dashboards", tc.file))
115+
d := NewDetector(logger.NewLeveledLogger(false), cl, gcom.NewAPIClient())
116+
out, err := d.Run(context.Background())
117+
require.NoError(t, err)
118+
require.Len(t, out, 1, "should have result for one dashboard")
119+
detections := out[0].Detections
120+
require.Len(t, detections, len(tc.expDetections), "should have the correct number of detections in the dashboard")
121+
for i, actual := range detections {
122+
exp := tc.expDetections[i]
123+
require.Equal(t, exp.pluginID, actual.PluginID)
124+
require.Equal(t, exp.detectionType, actual.DetectionType)
125+
require.Equal(t, exp.title, actual.Title)
126+
if exp.message != "" {
127+
require.Equal(t, exp.message, actual.String())
128+
}
129+
}
130+
})
131+
}
132+
}
133+
134+
// TestAPIClient is a GrafanaDetectorAPIClient implementation for testing.
135+
type TestAPIClient struct {
136+
DashboardJSONFilePath string
137+
DashboardMetaFilePath string
138+
FrontendSettingsFilePath string
139+
DatasourcesFilePath string
140+
PluginsFilePath string
141+
}
142+
143+
func NewTestAPIClient(dashboardJSONFilePath string) *TestAPIClient {
144+
return &TestAPIClient{
145+
DashboardJSONFilePath: dashboardJSONFilePath,
146+
DashboardMetaFilePath: filepath.Join("testdata", "dashboard-meta.json"),
147+
FrontendSettingsFilePath: filepath.Join("testdata", "frontend-settings.json"),
148+
DatasourcesFilePath: filepath.Join("testdata", "datasources.json"),
149+
PluginsFilePath: filepath.Join("testdata", "plugins.json"),
150+
}
151+
}
152+
153+
// unmarshalFromFile unmarshals JSON from a file into out, which must be a pointer to a value.
154+
func unmarshalFromFile(fn string, out any) error {
155+
f, err := os.Open(fn)
156+
if err != nil {
157+
return err
158+
}
159+
defer f.Close()
160+
return json.NewDecoder(f).Decode(out)
161+
}
162+
163+
// BaseURL always returns an empty string.
164+
func (c *TestAPIClient) BaseURL() string {
165+
return ""
166+
}
167+
168+
// GetPlugins returns plugins the content of c.PluginsFilePath.
169+
func (c *TestAPIClient) GetPlugins(_ context.Context) (plugins []grafana.Plugin, err error) {
170+
err = unmarshalFromFile(c.PluginsFilePath, &plugins)
171+
return
172+
}
173+
174+
// GetDatasourcePluginIDs returns the content of c.DatasourcesFilePath.
175+
func (c *TestAPIClient) GetDatasourcePluginIDs(_ context.Context) (datasources []grafana.Datasource, err error) {
176+
err = unmarshalFromFile(c.DatasourcesFilePath, &datasources)
177+
return
178+
}
179+
180+
// GetDashboards returns a dummy response with only one dashboard.
181+
func (c *TestAPIClient) GetDashboards(_ context.Context, _ int) ([]grafana.ListedDashboard, error) {
182+
return []grafana.ListedDashboard{
183+
{
184+
UID: "test-case-dashboard",
185+
URL: "/d/test-case-dashboard/test-case-dashboard",
186+
Title: "test case dashboard",
187+
},
188+
}, nil
189+
}
190+
191+
// GetDashboard returns a new DashboardDefinition that can be used for testing purposes.
192+
// The dashboard definition is taken from the file specified in c.DashboardJSONFilePath.
193+
// The dashboard meta is taken from the file specified in c.DashboardMetaFilePath.
194+
func (c *TestAPIClient) GetDashboard(_ context.Context, _ string) (*grafana.DashboardDefinition, error) {
195+
if c.DashboardJSONFilePath == "" {
196+
return nil, fmt.Errorf("TestAPIClient DashboardJSONFilePath cannot be empty")
197+
}
198+
var out grafana.DashboardDefinition
199+
if err := unmarshalFromFile(c.DashboardMetaFilePath, &out); err != nil {
200+
return nil, fmt.Errorf("unmarshal meta: %w", err)
201+
}
202+
if err := unmarshalFromFile(c.DashboardJSONFilePath, &out.Dashboard); err != nil {
203+
return nil, fmt.Errorf("unmarshal dashboard: %w", err)
204+
}
205+
grafana.ConvertPanels(out.Dashboard.Panels)
206+
return &out, nil
207+
}
208+
209+
// GetFrontendSettings returns the content of c.FrontendSettingsFilePath.
210+
func (c *TestAPIClient) GetFrontendSettings(_ context.Context) (frontendSettings *grafana.FrontendSettings, err error) {
211+
err = unmarshalFromFile(c.FrontendSettingsFilePath, &frontendSettings)
212+
return
213+
}
214+
215+
// GetServiceAccountPermissions is not implemented for testing purposes and always returns an empty map and a nil error.
216+
func (c *TestAPIClient) GetServiceAccountPermissions(_ context.Context) (map[string][]string, error) {
217+
return nil, nil
218+
}
219+
220+
// static check
221+
var _ GrafanaDetectorAPIClient = &TestAPIClient{}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"meta": {
3+
"type": "db",
4+
"canSave": true,
5+
"canEdit": true,
6+
"canAdmin": true,
7+
"canStar": true,
8+
"canDelete": true,
9+
"slug": "test-case-dashboard",
10+
"url": "/d/test-case-dashboard/test-case-dashboard",
11+
"expires": "0001-01-01T00:00:00Z",
12+
"created": "2023-11-07T11:13:24+01:00",
13+
"updated": "2024-02-21T13:09:27+01:00",
14+
"updatedBy": "admin",
15+
"createdBy": "admin",
16+
"version": 6,
17+
"hasAcl": false,
18+
"isFolder": false,
19+
"folderId": 200,
20+
"folderUid": "test-case-folder",
21+
"folderTitle": "test case folder",
22+
"folderUrl": "/dashboards/f/test-case-folder/test-case-folder",
23+
"provisioned": false,
24+
"provisionedExternalId": "",
25+
"annotationsPermissions": {
26+
"dashboard": {
27+
"canAdd": true,
28+
"canEdit": true,
29+
"canDelete": true
30+
},
31+
"organization": {
32+
"canAdd": true,
33+
"canEdit": true,
34+
"canDelete": true
35+
}
36+
}
37+
},
38+
"dashboard": null
39+
}

0 commit comments

Comments
 (0)