package graphite import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "net/url" "testing" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/backend/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type mockRoundTripper struct { respBody []byte status int err error lastRequest *http.Request } func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { m.lastRequest = req if m.err != nil { return nil, m.err } resp := &http.Response{ StatusCode: m.status, Body: io.NopCloser(bytes.NewBuffer(m.respBody)), Header: make(http.Header), } return resp, nil } type mockInstanceManager struct { instance instancemgmt.Instance err error } func (m *mockInstanceManager) Get(ctx context.Context, pluginCtx backend.PluginContext) (instancemgmt.Instance, error) { return m.instance, m.err } func (m *mockInstanceManager) Dispose(_ string) {} func (m *mockInstanceManager) Do(ctx context.Context, pluginCtx backend.PluginContext, fn instancemgmt.InstanceCallbackFunc) error { return nil } func TestHandleEvents(t *testing.T) { mockEvents := []GraphiteEventsResponse{ {When: 1234567890, What: "event1", Tags: []string{"tag1"}, Data: "data1"}, {When: 1234567891, What: "event2", Tags: []string{"tag2"}, Data: "data2"}, } mockResp, _ := json.Marshal(mockEvents) tests := []struct { name string dsInfo *datasourceInfo request GraphiteEventsRequest expectedStatus int expectError bool errorContains string expectedEvents []GraphiteEventsResponse }{ { name: "Success with tags", dsInfo: &datasourceInfo{ Id: 1, URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, }, request: GraphiteEventsRequest{From: "now-1h", Until: "now", Tags: "foo"}, expectedStatus: 200, expectError: false, expectedEvents: mockEvents, }, { name: "Success without tags", dsInfo: &datasourceInfo{ Id: 1, URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, }, request: GraphiteEventsRequest{From: "now-1h", Until: "now"}, expectedStatus: 200, expectError: false, expectedEvents: mockEvents, }, { name: "Invalid URL", dsInfo: &datasourceInfo{ Id: 1, URL: "ht tp://invalid url", }, request: GraphiteEventsRequest{From: "now-1h", Until: "now"}, expectedStatus: http.StatusInternalServerError, expectError: true, errorContains: "failed to create events request", }, { name: "HTTP client error", dsInfo: &datasourceInfo{ Id: 1, URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{err: errors.New("network error")}}, }, request: GraphiteEventsRequest{From: "now-1h", Until: "now"}, expectedStatus: http.StatusInternalServerError, expectError: true, errorContains: "events request failed", }, { name: "Invalid response JSON", dsInfo: &datasourceInfo{ Id: 1, URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: []byte("invalid json"), status: 200}}, }, request: GraphiteEventsRequest{From: "now-1h", Until: "now"}, expectedStatus: http.StatusInternalServerError, expectError: true, errorContains: "events request failed", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { svc := &Service{logger: log.NewNullLogger()} respBody, status, err := svc.handleEvents(context.Background(), tt.dsInfo, &tt.request) assert.Equal(t, tt.expectedStatus, status) if tt.expectError { assert.Error(t, err) assert.Nil(t, respBody) if tt.errorContains != "" { assert.Contains(t, err.Error(), tt.errorContains) } } else { require.NoError(t, err) assert.NotNil(t, respBody) if tt.expectedEvents != nil { var result map[string][]GraphiteEventsResponse require.NoError(t, json.Unmarshal(respBody, &result)) assert.Equal(t, tt.expectedEvents, result["data"]) } } }) } } func TestHandleMetricsFind(t *testing.T) { mockMetrics := []GraphiteMetricsFindResponse{ {Text: "metric1", Id: "metric1.id", AllowChildren: 1, Expandable: 1, Leaf: 0}, {Text: "metric2", Id: "metric2.id", AllowChildren: 0, Expandable: 0, Leaf: 1}, } mockResp, _ := json.Marshal(mockMetrics) tests := []struct { name string dsInfo *datasourceInfo request GraphiteMetricsFindRequest expectedStatus int expectError bool errorContains string expectedMetrics []GraphiteMetricsFindResponse }{ { name: "Success with query", dsInfo: &datasourceInfo{ Id: 1, URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, }, request: GraphiteMetricsFindRequest{Query: "app.grafana.*"}, expectedStatus: 200, expectError: false, expectedMetrics: mockMetrics, }, { name: "Success with query and time range", dsInfo: &datasourceInfo{ Id: 1, URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, }, request: GraphiteMetricsFindRequest{ Query: "app.grafana.*", From: "now-1h", Until: "now", }, expectedStatus: 200, expectError: false, expectedMetrics: mockMetrics, }, { name: "Empty query", dsInfo: &datasourceInfo{Id: 1, URL: "http://graphite.grafana"}, request: GraphiteMetricsFindRequest{Query: ""}, expectedStatus: http.StatusBadRequest, expectError: true, errorContains: "query is required", }, { name: "Invalid URL", dsInfo: &datasourceInfo{ Id: 1, URL: "ht tp://invalid url", }, request: GraphiteMetricsFindRequest{Query: "app.grafana.*"}, expectedStatus: http.StatusInternalServerError, expectError: true, errorContains: "failed to create metrics find request", }, { name: "HTTP client error", dsInfo: &datasourceInfo{ Id: 1, URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{err: errors.New("network error")}}, }, request: GraphiteMetricsFindRequest{Query: "app.grafana.*"}, expectedStatus: http.StatusInternalServerError, expectError: true, errorContains: "metrics find request failed", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { svc := &Service{logger: log.NewNullLogger()} respBody, status, err := svc.handleMetricsFind(context.Background(), tt.dsInfo, &tt.request) assert.Equal(t, tt.expectedStatus, status) if tt.expectError { assert.Error(t, err) assert.Nil(t, respBody) if tt.errorContains != "" { assert.Contains(t, err.Error(), tt.errorContains) } } else { require.NoError(t, err) assert.NotNil(t, respBody) if tt.expectedMetrics != nil { var result []GraphiteMetricsFindResponse require.NoError(t, json.Unmarshal(respBody, &result)) assert.Equal(t, tt.expectedMetrics, result) } } }) } } func TestHandleMetricsExpand(t *testing.T) { mockExpandResponse := GraphiteMetricsExpandResponse{ Results: []string{"app.grafana.metric1", "app.grafana.metric2", "app.grafana.metric3"}, } mockResp, _ := json.Marshal(mockExpandResponse) expectedMetrics := []GraphiteMetricsFindResponse{ {Text: "app.grafana.metric1"}, {Text: "app.grafana.metric2"}, {Text: "app.grafana.metric3"}, } tests := []struct { name string dsInfo *datasourceInfo request GraphiteMetricsFindRequest expectedStatus int expectError bool errorContains string expectedMetrics []GraphiteMetricsFindResponse }{ { name: "Success with query", dsInfo: &datasourceInfo{ Id: 1, URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, }, request: GraphiteMetricsFindRequest{Query: "app.grafana.*"}, expectedStatus: 200, expectError: false, expectedMetrics: expectedMetrics, }, { name: "Success with query and time range", dsInfo: &datasourceInfo{ Id: 1, URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: mockResp, status: 200}}, }, request: GraphiteMetricsFindRequest{ Query: "app.grafana.*", From: "now-1h", Until: "now", }, expectedStatus: 200, expectError: false, expectedMetrics: expectedMetrics, }, { name: "Empty query", dsInfo: &datasourceInfo{Id: 1, URL: "http://graphite.grafana"}, request: GraphiteMetricsFindRequest{Query: ""}, expectedStatus: http.StatusBadRequest, expectError: true, errorContains: "query is required", }, { name: "Invalid URL", dsInfo: &datasourceInfo{ Id: 1, URL: "ht tp://invalid url", }, request: GraphiteMetricsFindRequest{Query: "app.grafana.*"}, expectedStatus: http.StatusInternalServerError, expectError: true, errorContains: "failed to create metrics expand request", }, { name: "HTTP client error", dsInfo: &datasourceInfo{ Id: 1, URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{err: errors.New("network error")}}, }, request: GraphiteMetricsFindRequest{Query: "app.grafana.*"}, expectedStatus: http.StatusInternalServerError, expectError: true, errorContains: "metrics expand request failed", }, { name: "Invalid response JSON", dsInfo: &datasourceInfo{ Id: 1, URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: []byte("invalid json"), status: 200}}, }, request: GraphiteMetricsFindRequest{Query: "app.grafana.*"}, expectedStatus: http.StatusInternalServerError, expectError: true, errorContains: "metrics expand request failed", }, { name: "Empty results", dsInfo: &datasourceInfo{ Id: 1, URL: "http://graphite.grafana", HTTPClient: &http.Client{Transport: &mockRoundTripper{respBody: []byte(`{"results":[]}`), status: 200}}, }, request: GraphiteMetricsFindRequest{Query: "nonexistent.*"}, expectedStatus: 200, expectError: false, expectedMetrics: []GraphiteMetricsFindResponse{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { svc := &Service{logger: log.NewNullLogger()} respBody, status, err := svc.handleMetricsExpand(context.Background(), tt.dsInfo, &tt.request) assert.Equal(t, tt.expectedStatus, status) if tt.expectError { assert.Error(t, err) assert.Nil(t, respBody) if tt.errorContains != "" { assert.Contains(t, err.Error(), tt.errorContains) } } else { require.NoError(t, err) assert.NotNil(t, respBody) if tt.expectedMetrics != nil { var result []GraphiteMetricsFindResponse require.NoError(t, json.Unmarshal(respBody, &result)) assert.Equal(t, tt.expectedMetrics, result) } } }) } } func TestHandleTagsAutocomplete(t *testing.T) { tests := []struct { name string request GraphiteTagsRequest responseBody string statusCode int expectError bool errorContains string expectedData []string }{ { name: "successful tags autocomplete request", request: GraphiteTagsRequest{ From: "1h", Until: "now", Limit: 10, TagPrefix: "app", }, responseBody: `["app", "application", "app_name"]`, statusCode: 200, expectedData: []string{"app", "application", "app_name"}, }, { name: "tags autocomplete with minimal request", request: GraphiteTagsRequest{}, responseBody: `["tag1", "tag2"]`, statusCode: 200, expectedData: []string{"tag1", "tag2"}, }, { name: "tags autocomplete with empty response", request: GraphiteTagsRequest{ TagPrefix: "nonexistent", }, responseBody: `[]`, statusCode: 200, expectedData: []string{}, }, { name: "tags autocomplete server error - invalid JSON causes marshal error", request: GraphiteTagsRequest{ From: "invalid", }, responseBody: `invalid json response`, statusCode: 400, expectError: true, errorContains: "tags autocomplete request failed", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockTransport := &mockRoundTripper{ respBody: []byte(tt.responseBody), status: tt.statusCode, } dsInfo := &datasourceInfo{ HTTPClient: &http.Client{Transport: mockTransport}, URL: "http://graphite.example.com", } service := &Service{ logger: log.NewNullLogger(), } result, statusCode, err := service.handleTagsAutocomplete(context.Background(), dsInfo, &tt.request) if tt.expectError { assert.Error(t, err) if tt.errorContains != "" { assert.Contains(t, err.Error(), tt.errorContains) } } else { assert.NoError(t, err) assert.Equal(t, tt.statusCode, statusCode) var tags []string err = json.Unmarshal(result, &tags) assert.NoError(t, err) assert.Equal(t, tt.expectedData, tags) } if !tt.expectError { expectedURL := "http://graphite.example.com/tags/autoComplete/tags" assert.Contains(t, mockTransport.lastRequest.URL.String(), expectedURL) if tt.request.From != "" { assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("from=%s", tt.request.From)) } if tt.request.Until != "" { assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("until=%s", tt.request.Until)) } if tt.request.Limit != 0 { assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("limit=%d", tt.request.Limit)) } if tt.request.TagPrefix != "" { assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("tagPrefix=%s", tt.request.TagPrefix)) } } }) } } func TestHandleTagValuesAutocomplete(t *testing.T) { tests := []struct { name string request GraphiteTagValuesRequest responseBody string statusCode int expectError bool errorContains string expectedData []string }{ { name: "successful tag values autocomplete request", request: GraphiteTagValuesRequest{ Expr: []string{"app=*"}, Tag: "environment", From: "1h", Until: "now", Limit: 5, ValuePrefix: "prod", }, responseBody: `["production", "prod-eu", "prod-us"]`, statusCode: 200, expectedData: []string{"production", "prod-eu", "prod-us"}, }, { name: "multiple expressions", request: GraphiteTagValuesRequest{ Expr: []string{"app=*", "region=us-*"}, Tag: "environment", From: "1h", Until: "now", Limit: 5, ValuePrefix: "prod", }, responseBody: `["production", "prod-eu", "prod-us"]`, statusCode: 200, expectedData: []string{"production", "prod-eu", "prod-us"}, }, { name: "tag values autocomplete with empty response", request: GraphiteTagValuesRequest{ Expr: []string{"app=nonexistent"}, Tag: "environment", ValuePrefix: "staging", }, responseBody: `[]`, statusCode: 200, expectedData: []string{}, }, { name: "tag values autocomplete server error", request: GraphiteTagValuesRequest{ Expr: []string{"invalid-expr"}, Tag: "env", }, responseBody: `invalid json response`, statusCode: 400, expectError: true, errorContains: "tag values autocomplete request failed", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockTransport := &mockRoundTripper{ respBody: []byte(tt.responseBody), status: tt.statusCode, } dsInfo := &datasourceInfo{ HTTPClient: &http.Client{Transport: mockTransport}, URL: "http://graphite.example.com", } service := &Service{ logger: log.NewNullLogger(), } result, statusCode, err := service.handleTagValuesAutocomplete(context.Background(), dsInfo, &tt.request) if tt.expectError { assert.Error(t, err) if tt.errorContains != "" { assert.Contains(t, err.Error(), tt.errorContains) } } else { assert.NoError(t, err) assert.Equal(t, tt.statusCode, statusCode) var tagValues []string err = json.Unmarshal(result, &tagValues) assert.NoError(t, err) assert.Equal(t, tt.expectedData, tagValues) } if !tt.expectError { expectedURL := "http://graphite.example.com/tags/autoComplete/values" assert.Contains(t, mockTransport.lastRequest.URL.String(), expectedURL) for _, expr := range tt.request.Expr { assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("expr=%s", url.QueryEscape(expr))) } assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("tag=%s", tt.request.Tag)) if tt.request.From != "" { assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("from=%s", tt.request.From)) } if tt.request.Until != "" { assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("until=%s", tt.request.Until)) } if tt.request.Limit != 0 { assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("limit=%d", tt.request.Limit)) } if tt.request.ValuePrefix != "" { assert.Contains(t, mockTransport.lastRequest.URL.RawQuery, fmt.Sprintf("valuePrefix=%s", tt.request.ValuePrefix)) } } }) } } func TestHandleVersion(t *testing.T) { tests := []struct { name string responseBody string statusCode int expectError bool errorContains string expectedData string }{ { name: "successful version request", responseBody: `"1.1.10"`, statusCode: 200, expectedData: "1.1.10", }, { name: "version with build info", responseBody: `"1.1.10-pre1"`, statusCode: 200, expectedData: "1.1.10-pre1", }, { name: "version request server error - invalid JSON causes parse error", responseBody: `{"error": "internal error"}`, statusCode: 500, expectError: true, errorContains: "version request failed", }, { name: "version request not found - invalid JSON causes parse error", responseBody: `{"error": "not found"}`, statusCode: 404, expectError: true, errorContains: "version request failed", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockTransport := &mockRoundTripper{ respBody: []byte(tt.responseBody), status: tt.statusCode, } dsInfo := &datasourceInfo{ HTTPClient: &http.Client{Transport: mockTransport}, URL: "http://graphite.example.com", } service := &Service{ logger: log.NewNullLogger(), } result, statusCode, err := service.handleVersion(context.Background(), dsInfo, nil) if tt.expectError { assert.Error(t, err) if tt.errorContains != "" { assert.Contains(t, err.Error(), tt.errorContains) } } else { assert.NoError(t, err) assert.Equal(t, tt.statusCode, statusCode) var version string err = json.Unmarshal(result, &version) assert.NoError(t, err) assert.Equal(t, tt.expectedData, version) } if !tt.expectError { expectedURL := "http://graphite.example.com/version" assert.Equal(t, expectedURL, mockTransport.lastRequest.URL.String()) assert.Equal(t, http.MethodGet, mockTransport.lastRequest.Method) } }) } } func TestHandleFunctions(t *testing.T) { tests := []struct { name string responseBody string statusCode int expectError bool errorContains string expectedData string }{ { name: "successful functions request", responseBody: `{"sum": {"description": "Sum function"}, "avg": {"description": "Average function"}}`, statusCode: 200, expectError: false, expectedData: `{"sum": {"description": "Sum function"}, "avg": {"description": "Average function"}}`, }, { name: "functions with infinity replacement", responseBody: `{"func": {"default": Infinity, "description": "Test function"}}`, statusCode: 200, expectError: false, expectedData: `{"func": {"default": 1e9999, "description": "Test function"}}`, }, { name: "empty functions response", responseBody: `{}`, statusCode: 200, expectError: false, expectedData: `{}`, }, { name: "functions request server error", responseBody: `{"error": "internal error"}`, statusCode: 500, expectError: true, errorContains: "functions request failed", }, { name: "functions request not found", responseBody: `{"error": "not found"}`, statusCode: 404, expectError: true, errorContains: "functions request failed", }, { name: "network error", responseBody: "", statusCode: 0, expectError: true, errorContains: "functions request failed", }, { name: "html response", responseBody: `