Repository: onedr0p/exportarr (go)

Issue:
Latests version crashes on request
# Details

I'm using docker with tag for latest version. Since 1 week when I run container and on first attempt to request metrics containers crashes with:
 sonarr-exporter  | 2023-10-17T13:26:14.970Z     INFO    Starting exportarr      {"app_name": "exportarr", "version": "vmaster", "buildTime": "2023-10-08T18:15:27.347Z", "revision": "b8426af721e5ec7cdeae612686ef9c8083ffc85a"}
sonarr-exporter  | 2023-10-17T13:26:14.971Z     INFO    Starting HTTP Server    {"interface": "0.0.0.0", "port": 9707}
sonarr-exporter  | 2023-10-17T13:26:19.812Z     INFO    Sending HTTP request    {"url": "http://sonarr:8989/api/v3/series"}
sonarr-exporter  | 2023-10-17T13:26:19.812Z     INFO    Sending HTTP request    {"url": "http://sonarr:8989/api/v3/system/status"}
sonarr-exporter  | panic: assignment to entry in nil map
sonarr-exporter  |
sonarr-exporter  | goroutine 35 [running]:
sonarr-exporter  | net/url.Values.Add(...)
sonarr-exporter  |      /usr/local/go/src/net/url/url.go:902
sonarr-exporter  | github.com/onedr0p/exportarr/internal/arr/collector.(*queueCollector).Collect(0xc0000c1fc8, 0xc000293760?)
sonarr-exporter  |      /build/internal/arr/collector/queue.go:52 +0x225
sonarr-exporter  | github.com/prometheus/client_golang/prometheus.(*Registry).Gather.func1()
sonarr-exporter  |      /go/pkg/mod/github.com/prometheus/client_golang@v1.17.0/prometheus/registry.go:455 +0x102
sonarr-exporter  | created by github.com/prometheus/client_golang/prometheus.(*Registry).Gather in goroutine 29
sonarr-exporter  |      /go/pkg/mod/github.com/prometheus/client_golang@v1.17.0/prometheus/registry.go:547 +0xbab


When I'm using v.1.5.5 - everything works OK.

Pull request description:
fix: Fix #230, Add tests for shared collectors
<!--
Before you open the request please review the following guidelines and tips to help it be more easily integrated:

- Describe the scope of your change - i.e. what the change does.
- Describe any known limitations with your change.
- Please run any tests or examples that can exercise your modified code.

Thank you for contributing! We will try to test and integrate the change as soon as we can. There is no need to bump or check in on a pull request (it will clutter the discussion of the request).

Also don't be worried if the request is closed or not integrated sometimes our priorities might not match the priorities of the pull request. Don't fret, the open source community thrives on forks and GitHub makes it easy to keep your changes in a forked repo.
-->

**Description of the change**

Pretty much title. Fixes an error where the param map is not initialized prior to use. I also added tests for all the common collectors to make sure we catch this type of issue in the future.

**Benefits**

See Description

**Possible drawbacks**

<!-- Describe any known limitations with your change -->

**Applicable issues**

<!-- Enter any applicable Issues here (You can reference an issue using #) -->
- fixes #230 

**Additional information**

<!-- If there's anything else that's important and relevant to your pull request, mention that information here.-->


Patch:
diff --git a/go.sum b/go.sum
index a530430..a1c53f8 100644
--- a/go.sum
+++ b/go.sum
@@ -68,16 +68,12 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
 go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
 go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
 go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
-golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
-golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
 golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
-golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
 golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
diff --git a/internal/arr/collector/lidarr.go b/internal/arr/collector/lidarr.go
index 4000b71..f54dd46 100644
--- a/internal/arr/collector/lidarr.go
+++ b/internal/arr/collector/lidarr.go
@@ -169,7 +169,7 @@ func (collector *lidarrCollector) Collect(ch chan<- prometheus.Metric) {
 		if collector.config.EnableAdditionalMetrics {
 			songFile := model.SongFile{}
 
-			var params client.QueryParams
+			params := client.QueryParams{}
 			params.Add("artistid", fmt.Sprintf("%d", s.Id))
 
 			if err := c.DoRequest("trackfile", &songFile, params); err != nil {
diff --git a/internal/arr/collector/prowlarr.go b/internal/arr/collector/prowlarr.go
index d67aa81..b756327 100644
--- a/internal/arr/collector/prowlarr.go
+++ b/internal/arr/collector/prowlarr.go
@@ -318,7 +318,7 @@ func (collector *prowlarrCollector) Collect(ch chan<- prometheus.Metric) {
 	startDate := collector.lastStatUpdate.In(time.UTC)
 	endDate := time.Now().In(time.UTC)
 
-	var params client.QueryParams
+	params := client.QueryParams{}
 	params.Add("startDate", startDate.Format(time.RFC3339))
 	params.Add("endDate", endDate.Format(time.RFC3339))
 
diff --git a/internal/arr/collector/queue.go b/internal/arr/collector/queue.go
index c57f790..f6696a5 100644
--- a/internal/arr/collector/queue.go
+++ b/internal/arr/collector/queue.go
@@ -48,7 +48,7 @@ func (collector *queueCollector) Collect(ch chan<- prometheus.Metric) {
 		return
 	}
 
-	var params client.QueryParams
+	params := client.QueryParams{}
 	params.Add("page", "1")
 	if collector.config.EnableUnknownQueueItems {
 		if collector.config.App == "sonarr" {
diff --git a/internal/arr/collector/sonarr.go b/internal/arr/collector/sonarr.go
index d0751b3..69df535 100644
--- a/internal/arr/collector/sonarr.go
+++ b/internal/arr/collector/sonarr.go
@@ -220,7 +220,7 @@ func (collector *sonarrCollector) Collect(ch chan<- prometheus.Metric) {
 			textra := time.Now()
 			episodeFile := model.EpisodeFile{}
 
-			var params client.QueryParams
+			params := client.QueryParams{}
 			params.Add("seriesId", fmt.Sprintf("%d", s.Id))
 
 			if err := c.DoRequest("episodefile", &episodeFile, params); err != nil {
@@ -261,7 +261,7 @@ func (collector *sonarrCollector) Collect(ch chan<- prometheus.Metric) {
 
 	episodesMissing := model.Missing{}
 
-	var params client.QueryParams
+	params := client.QueryParams{}
 	params.Add("sortKey", "airDateUtc")
 
 	if err := c.DoRequest("wanted/missing", &episodesMissing, params); err != nil {


Test patch:
diff --git a/internal/arr/collector/bazarr_test.go b/internal/arr/collector/bazarr_test.go
index d330797..98d4e9f 100644
--- a/internal/arr/collector/bazarr_test.go
+++ b/internal/arr/collector/bazarr_test.go
@@ -8,26 +8,15 @@ import (
 	"testing"
 
 	"github.com/onedr0p/exportarr/internal/arr/config"
+	"github.com/onedr0p/exportarr/internal/test_util"
 	"github.com/prometheus/client_golang/prometheus/testutil"
 	"github.com/stretchr/testify/require"
 )
 
-const test_fixtures_path = "../test_fixtures/bazarr/"
-const API_KEY = "abcdef0123456789abcdef0123456789"
+const bazarr_test_fixtures_path = "../test_fixtures/bazarr/"
 
 func newTestBazarrServer(t *testing.T, fn func(http.ResponseWriter, *http.Request)) (*httptest.Server, error) {
-	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		fn(w, r)
-		require.NotEmpty(t, r.URL.Path)
-		// turns /api/some/path into some_path
-		endpoint := strings.Replace(strings.Replace(r.URL.Path, "/api/", "", -1), "/", "_", -1)
-		w.WriteHeader(http.StatusOK)
-		// NOTE: this assumes there is a file that matches the some_path
-		json, err := os.ReadFile(test_fixtures_path + endpoint + ".json")
-		require.NoError(t, err)
-		_, err = w.Write(json)
-		require.NoError(t, err)
-	})), nil
+	return test_util.NewTestServer(t, bazarr_test_fixtures_path, fn)
 }
 
 func TestBazarrCollect(t *testing.T) {
@@ -42,12 +31,12 @@ func TestBazarrCollect(t *testing.T) {
 	config := &config.ArrConfig{
 		URL:    ts.URL,
 		App:    "bazarr",
-		ApiKey: API_KEY,
+		ApiKey: test_util.API_KEY,
 	}
 	collector := NewBazarrCollector(config)
 	require.NoError(err)
 
-	b, err := os.ReadFile(test_fixtures_path + "expected_metrics.txt")
+	b, err := os.ReadFile(bazarr_test_fixtures_path + "expected_metrics.txt")
 	require.NoError(err)
 
 	expected := strings.Replace(string(b), "SOMEURL", ts.URL, -1)
@@ -109,7 +98,7 @@ func TestBazarrCollect_FailureDoesntPanic(t *testing.T) {
 
 	config := &config.ArrConfig{
 		URL:    ts.URL,
-		ApiKey: API_KEY,
+		ApiKey: test_util.API_KEY,
 	}
 	collector := NewBazarrCollector(config)
 
diff --git a/internal/arr/collector/health_test.go b/internal/arr/collector/health_test.go
new file mode 100644
index 0000000..448251a
--- /dev/null
+++ b/internal/arr/collector/health_test.go
@@ -0,0 +1,107 @@
+package collector
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/onedr0p/exportarr/internal/arr/config"
+	"github.com/onedr0p/exportarr/internal/test_util"
+	"github.com/prometheus/client_golang/prometheus/testutil"
+	"github.com/stretchr/testify/require"
+)
+
+func TestSystemHealthCollect(t *testing.T) {
+	var tests = []struct {
+		name   string
+		config *config.ArrConfig
+		path   string
+	}{
+		{
+			name: "radarr",
+			config: &config.ArrConfig{
+				App:        "radarr",
+				ApiVersion: "v3",
+			},
+			path: "/api/v3/health",
+		},
+		{
+			name: "sonarr",
+			config: &config.ArrConfig{
+				App:        "sonarr",
+				ApiVersion: "v3",
+			},
+			path: "/api/v3/health",
+		},
+		{
+			name: "lidarr",
+			config: &config.ArrConfig{
+				App:        "lidarr",
+				ApiVersion: "v1",
+			},
+			path: "/api/v1/health",
+		},
+		{
+			name: "readarr",
+			config: &config.ArrConfig{
+				App:        "readarr",
+				ApiVersion: "v1",
+			},
+			path: "/api/v1/health",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			require := require.New(t)
+			ts, err := test_util.NewTestSharedServer(t, func(w http.ResponseWriter, r *http.Request) {
+				require.Contains(r.URL.Path, tt.path)
+			})
+			require.NoError(err)
+
+			defer ts.Close()
+
+			tt.config.URL = ts.URL
+			tt.config.ApiKey = test_util.API_KEY
+
+			collector := NewSystemHealthCollector(tt.config)
+
+			b, err := os.ReadFile(test_util.COMMON_FIXTURES_PATH + "expected_health_metrics.txt")
+			require.NoError(err)
+
+			expected := strings.Replace(string(b), "SOMEURL", ts.URL, -1)
+			expected = strings.Replace(expected, "APP", tt.config.App, -1)
+
+			f := strings.NewReader(expected)
+
+			require.NotPanics(func() {
+				err = testutil.CollectAndCompare(collector, f)
+			})
+			require.NoError(err)
+		})
+	}
+}
+
+func TestSystemHealthCollect_FailureDoesntPanic(t *testing.T) {
+	require := require.New(t)
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer ts.Close()
+
+	config := &config.ArrConfig{
+		URL:    ts.URL,
+		ApiKey: test_util.API_KEY,
+	}
+	collector := NewSystemHealthCollector(config)
+
+	f := strings.NewReader("")
+
+	require.NotPanics(func() {
+		err := testutil.CollectAndCompare(collector, f)
+		require.Error(err)
+	}, "Collecting metrics should not panic on failure")
+}
diff --git a/internal/arr/collector/history_test.go b/internal/arr/collector/history_test.go
new file mode 100644
index 0000000..7f30e56
--- /dev/null
+++ b/internal/arr/collector/history_test.go
@@ -0,0 +1,107 @@
+package collector
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/onedr0p/exportarr/internal/arr/config"
+	"github.com/onedr0p/exportarr/internal/test_util"
+	"github.com/prometheus/client_golang/prometheus/testutil"
+	"github.com/stretchr/testify/require"
+)
+
+func TestHistoryCollect(t *testing.T) {
+	var tests = []struct {
+		name   string
+		config *config.ArrConfig
+		path   string
+	}{
+		{
+			name: "radarr",
+			config: &config.ArrConfig{
+				App:        "radarr",
+				ApiVersion: "v3",
+			},
+			path: "/api/v3/history",
+		},
+		{
+			name: "sonarr",
+			config: &config.ArrConfig{
+				App:        "sonarr",
+				ApiVersion: "v3",
+			},
+			path: "/api/v3/history",
+		},
+		{
+			name: "lidarr",
+			config: &config.ArrConfig{
+				App:        "lidarr",
+				ApiVersion: "v1",
+			},
+			path: "/api/v1/history",
+		},
+		{
+			name: "readarr",
+			config: &config.ArrConfig{
+				App:        "readarr",
+				ApiVersion: "v1",
+			},
+			path: "/api/v1/history",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			require := require.New(t)
+			ts, err := test_util.NewTestSharedServer(t, func(w http.ResponseWriter, r *http.Request) {
+				require.Contains(r.URL.Path, tt.path)
+			})
+			require.NoError(err)
+
+			defer ts.Close()
+
+			tt.config.URL = ts.URL
+			tt.config.ApiKey = test_util.API_KEY
+
+			collector := NewHistoryCollector(tt.config)
+
+			b, err := os.ReadFile(test_util.COMMON_FIXTURES_PATH + "expected_history_metrics.txt")
+			require.NoError(err)
+
+			expected := strings.Replace(string(b), "SOMEURL", ts.URL, -1)
+			expected = strings.Replace(expected, "APP", tt.config.App, -1)
+
+			f := strings.NewReader(expected)
+
+			require.NotPanics(func() {
+				err = testutil.CollectAndCompare(collector, f)
+			})
+			require.NoError(err)
+		})
+	}
+}
+
+func TestHistoryCollect_FailureDoesntPanic(t *testing.T) {
+	require := require.New(t)
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer ts.Close()
+
+	config := &config.ArrConfig{
+		URL:    ts.URL,
+		ApiKey: test_util.API_KEY,
+	}
+	collector := NewHistoryCollector(config)
+
+	f := strings.NewReader("")
+
+	require.NotPanics(func() {
+		err := testutil.CollectAndCompare(collector, f)
+		require.Error(err)
+	}, "Collecting metrics should not panic on failure")
+}
diff --git a/internal/arr/collector/queue_test.go b/internal/arr/collector/queue_test.go
new file mode 100644
index 0000000..ddea91f
--- /dev/null
+++ b/internal/arr/collector/queue_test.go
@@ -0,0 +1,107 @@
+package collector
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/onedr0p/exportarr/internal/arr/config"
+	"github.com/onedr0p/exportarr/internal/test_util"
+	"github.com/prometheus/client_golang/prometheus/testutil"
+	"github.com/stretchr/testify/require"
+)
+
+func TestQueueCollect(t *testing.T) {
+	var tests = []struct {
+		name   string
+		config *config.ArrConfig
+		path   string
+	}{
+		{
+			name: "radarr",
+			config: &config.ArrConfig{
+				App:        "radarr",
+				ApiVersion: "v3",
+			},
+			path: "/api/v3/queue",
+		},
+		{
+			name: "sonarr",
+			config: &config.ArrConfig{
+				App:        "sonarr",
+				ApiVersion: "v3",
+			},
+			path: "/api/v3/queue",
+		},
+		{
+			name: "lidarr",
+			config: &config.ArrConfig{
+				App:        "lidarr",
+				ApiVersion: "v1",
+			},
+			path: "/api/v1/queue",
+		},
+		{
+			name: "readarr",
+			config: &config.ArrConfig{
+				App:        "readarr",
+				ApiVersion: "v1",
+			},
+			path: "/api/v1/queue",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			require := require.New(t)
+			ts, err := test_util.NewTestSharedServer(t, func(w http.ResponseWriter, r *http.Request) {
+				require.Contains(r.URL.Path, tt.path)
+			})
+			require.NoError(err)
+
+			defer ts.Close()
+
+			tt.config.URL = ts.URL
+			tt.config.ApiKey = test_util.API_KEY
+
+			collector := NewQueueCollector(tt.config)
+
+			b, err := os.ReadFile(test_util.COMMON_FIXTURES_PATH + "expected_queue_metrics.txt")
+			require.NoError(err)
+
+			expected := strings.Replace(string(b), "SOMEURL", ts.URL, -1)
+			expected = strings.Replace(expected, "APP", tt.config.App, -1)
+
+			f := strings.NewReader(expected)
+
+			require.NotPanics(func() {
+				err = testutil.CollectAndCompare(collector, f)
+			})
+			require.NoError(err)
+		})
+	}
+}
+
+func TestQueueCollect_FailureDoesntPanic(t *testing.T) {
+	require := require.New(t)
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer ts.Close()
+
+	config := &config.ArrConfig{
+		URL:    ts.URL,
+		ApiKey: test_util.API_KEY,
+	}
+	collector := NewQueueCollector(config)
+
+	f := strings.NewReader("")
+
+	require.NotPanics(func() {
+		err := testutil.CollectAndCompare(collector, f)
+		require.Error(err)
+	}, "Collecting metrics should not panic on failure")
+}
diff --git a/internal/arr/collector/rootfolder_test.go b/internal/arr/collector/rootfolder_test.go
new file mode 100644
index 0000000..f203119
--- /dev/null
+++ b/internal/arr/collector/rootfolder_test.go
@@ -0,0 +1,107 @@
+package collector
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/onedr0p/exportarr/internal/arr/config"
+	"github.com/onedr0p/exportarr/internal/test_util"
+	"github.com/prometheus/client_golang/prometheus/testutil"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRootFolderCollect(t *testing.T) {
+	var tests = []struct {
+		name   string
+		config *config.ArrConfig
+		path   string
+	}{
+		{
+			name: "radarr",
+			config: &config.ArrConfig{
+				App:        "radarr",
+				ApiVersion: "v3",
+			},
+			path: "/api/v3/rootfolder",
+		},
+		{
+			name: "sonarr",
+			config: &config.ArrConfig{
+				App:        "sonarr",
+				ApiVersion: "v3",
+			},
+			path: "/api/v3/rootfolder",
+		},
+		{
+			name: "lidarr",
+			config: &config.ArrConfig{
+				App:        "lidarr",
+				ApiVersion: "v1",
+			},
+			path: "/api/v1/rootfolder",
+		},
+		{
+			name: "readarr",
+			config: &config.ArrConfig{
+				App:        "readarr",
+				ApiVersion: "v1",
+			},
+			path: "/api/v1/rootfolder",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			require := require.New(t)
+			ts, err := test_util.NewTestSharedServer(t, func(w http.ResponseWriter, r *http.Request) {
+				require.Contains(r.URL.Path, tt.path)
+			})
+			require.NoError(err)
+
+			defer ts.Close()
+
+			tt.config.URL = ts.URL
+			tt.config.ApiKey = test_util.API_KEY
+
+			collector := NewRootFolderCollector(tt.config)
+
+			b, err := os.ReadFile(test_util.COMMON_FIXTURES_PATH + "expected_rootfolder_metrics.txt")
+			require.NoError(err)
+
+			expected := strings.Replace(string(b), "SOMEURL", ts.URL, -1)
+			expected = strings.Replace(expected, "APP", tt.config.App, -1)
+
+			f := strings.NewReader(expected)
+
+			require.NotPanics(func() {
+				err = testutil.CollectAndCompare(collector, f)
+			})
+			require.NoError(err)
+		})
+	}
+}
+
+func TestRootFolderCollect_FailureDoesntPanic(t *testing.T) {
+	require := require.New(t)
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer ts.Close()
+
+	config := &config.ArrConfig{
+		URL:    ts.URL,
+		ApiKey: test_util.API_KEY,
+	}
+	collector := NewRootFolderCollector(config)
+
+	f := strings.NewReader("")
+
+	require.NotPanics(func() {
+		err := testutil.CollectAndCompare(collector, f)
+		require.Error(err)
+	}, "Collecting metrics should not panic on failure")
+}
diff --git a/internal/arr/collector/status_test.go b/internal/arr/collector/status_test.go
new file mode 100644
index 0000000..947f854
--- /dev/null
+++ b/internal/arr/collector/status_test.go
@@ -0,0 +1,107 @@
+package collector
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/onedr0p/exportarr/internal/arr/config"
+	"github.com/onedr0p/exportarr/internal/test_util"
+	"github.com/prometheus/client_golang/prometheus/testutil"
+	"github.com/stretchr/testify/require"
+)
+
+func TestStatusCollect(t *testing.T) {
+	var tests = []struct {
+		name   string
+		config *config.ArrConfig
+		path   string
+	}{
+		{
+			name: "radarr",
+			config: &config.ArrConfig{
+				App:        "radarr",
+				ApiVersion: "v3",
+			},
+			path: "/api/v3/system/status",
+		},
+		{
+			name: "sonarr",
+			config: &config.ArrConfig{
+				App:        "sonarr",
+				ApiVersion: "v3",
+			},
+			path: "/api/v3/system/status",
+		},
+		{
+			name: "lidarr",
+			config: &config.ArrConfig{
+				App:        "lidarr",
+				ApiVersion: "v1",
+			},
+			path: "/api/v1/system/status",
+		},
+		{
+			name: "readarr",
+			config: &config.ArrConfig{
+				App:        "readarr",
+				ApiVersion: "v1",
+			},
+			path: "/api/v1/system/status",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			require := require.New(t)
+			ts, err := test_util.NewTestSharedServer(t, func(w http.ResponseWriter, r *http.Request) {
+				require.Contains(r.URL.Path, tt.path)
+			})
+			require.NoError(err)
+
+			defer ts.Close()
+
+			tt.config.URL = ts.URL
+			tt.config.ApiKey = test_util.API_KEY
+
+			collector := NewSystemStatusCollector(tt.config)
+
+			b, err := os.ReadFile(test_util.COMMON_FIXTURES_PATH + "expected_status_metrics.txt")
+			require.NoError(err)
+
+			expected := strings.Replace(string(b), "SOMEURL", ts.URL, -1)
+			expected = strings.Replace(expected, "APP", tt.config.App, -1)
+
+			f := strings.NewReader(expected)
+
+			require.NotPanics(func() {
+				err = testutil.CollectAndCompare(collector, f)
+			})
+			require.NoError(err)
+		})
+	}
+}
+
+func TestStatusCollect_FailureDoesntPanic(t *testing.T) {
+	require := require.New(t)
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusInternalServerError)
+	}))
+	defer ts.Close()
+
+	config := &config.ArrConfig{
+		URL:    ts.URL,
+		ApiKey: test_util.API_KEY,
+	}
+	collector := NewSystemStatusCollector(config)
+
+	f := strings.NewReader("")
+
+	require.NotPanics(func() {
+		err := testutil.CollectAndCompare(collector, f)
+		require.Error(err)
+	}, "Collecting metrics should not panic on failure")
+}
diff --git a/internal/arr/test_fixtures/common/expected_health_metrics.txt b/internal/arr/test_fixtures/common/expected_health_metrics.txt
new file mode 100644
index 0000000..8832965
--- /dev/null
+++ b/internal/arr/test_fixtures/common/expected_health_metrics.txt
@@ -0,0 +1,3 @@
+# HELP APP_system_health_issues Total number of health issues by source, type, message and wikiurl
+# TYPE APP_system_health_issues gauge
+APP_system_health_issues{message="Indexers unavailable due to failures for more than 6 hours: SomeIndexer",source="IndexerLongTermStatusCheck",type="warning",url="SOMEURL",wikiurl="https://wiki.servarr.com/readarr/system#indexers-are-unavailable-due-to-failures"} 1
diff --git a/internal/arr/test_fixtures/common/expected_history_metrics.txt b/internal/arr/test_fixtures/common/expected_history_metrics.txt
new file mode 100644
index 0000000..d6461b8
--- /dev/null
+++ b/internal/arr/test_fixtures/common/expected_history_metrics.txt
@@ -0,0 +1,3 @@
+# HELP APP_history_total Total number of item in the history
+# TYPE APP_history_total gauge
+APP_history_total{url="SOMEURL"} 1368
diff --git a/internal/arr/test_fixtures/common/expected_queue_metrics.txt b/internal/arr/test_fixtures/common/expected_queue_metrics.txt
new file mode 100644
index 0000000..f691eb4
--- /dev/null
+++ b/internal/arr/test_fixtures/common/expected_queue_metrics.txt
@@ -0,0 +1,3 @@
+# HELP APP_queue_total Total number of items in the queue by status, download_status, and download_state
+# TYPE APP_queue_total gauge
+APP_queue_total{download_state="downloading",download_status="warning",status="completed",url="SOMEURL"} 1
diff --git a/internal/arr/test_fixtures/common/expected_rootfolder_metrics.txt b/internal/arr/test_fixtures/common/expected_rootfolder_metrics.txt
new file mode 100644
index 0000000..c754e0d
--- /dev/null
+++ b/internal/arr/test_fixtures/common/expected_rootfolder_metrics.txt
@@ -0,0 +1,3 @@
+# HELP APP_rootfolder_freespace_bytes Root folder space in bytes by path
+# TYPE APP_rootfolder_freespace_bytes gauge
+APP_rootfolder_freespace_bytes{path="/media/books/",url="SOMEURL"} 3.2147635175424e+13
diff --git a/internal/arr/test_fixtures/common/expected_status_metrics.txt b/internal/arr/test_fixtures/common/expected_status_metrics.txt
new file mode 100644
index 0000000..605bdc3
--- /dev/null
+++ b/internal/arr/test_fixtures/common/expected_status_metrics.txt
@@ -0,0 +1,3 @@
+# HELP APP_system_status System Status
+# TYPE APP_system_status gauge
+APP_system_status{url="SOMEURL"} 1
diff --git a/internal/arr/test_fixtures/common/v1_health.json b/internal/arr/test_fixtures/common/v1_health.json
new file mode 100644
index 0000000..155a404
--- /dev/null
+++ b/internal/arr/test_fixtures/common/v1_health.json
@@ -0,0 +1,8 @@
+[
+    {
+      "source": "IndexerLongTermStatusCheck",
+      "type": "warning",
+      "message": "Indexers unavailable due to failures for more than 6 hours: SomeIndexer",
+      "wikiUrl": "https://wiki.servarr.com/readarr/system#indexers-are-unavailable-due-to-failures"
+    }
+  ]
\ No newline at end of file
diff --git a/internal/arr/test_fixtures/common/v1_history.json b/internal/arr/test_fixtures/common/v1_history.json
new file mode 100644
index 0000000..7914f4e
--- /dev/null
+++ b/internal/arr/test_fixtures/common/v1_history.json
@@ -0,0 +1,8 @@
+{
+    "page": 1,
+    "pageSize": 10,
+    "sortKey": "date",
+    "sortDirection": "descending",
+    "totalRecords": 1368,
+    "records": []
+  }
\ No newline at end of file
diff --git a/internal/arr/test_fixtures/common/v1_queue.json b/internal/arr/test_fixtures/common/v1_queue.json
new file mode 100644
index 0000000..7c641e7
--- /dev/null
+++ b/internal/arr/test_fixtures/common/v1_queue.json
@@ -0,0 +1,69 @@
+{
+    "page": 1,
+    "pageSize": 10,
+    "sortKey": "timeleft",
+    "sortDirection": "ascending",
+    "totalRecords": 1,
+    "records": [
+      {
+        "movieId": 91,
+        "languages": [],
+        "quality": {
+          "quality": {
+            "id": 7,
+            "name": "Bluray-1080p",
+            "source": "bluray",
+            "resolution": 1080,
+            "modifier": "none"
+          },
+          "revision": {
+            "version": 2,
+            "real": 0,
+            "isRepack": false
+          }
+        },
+        "customFormats": [
+          {
+            "id": 6,
+            "name": "Special Edition"
+          },
+          {
+            "id": 8,
+            "name": "Repack/Proper"
+          },
+          {
+            "id": 27,
+            "name": "DTS-ES"
+          },
+          {
+            "id": 44,
+            "name": "HD Bluray Tier 02"
+          }
+        ],
+        "customFormatScore": 1880,
+        "size": 24397988960,
+        "title": "Some.Movie.1.Has.A.Title-1080P",
+        "sizeleft": 0,
+        "timeleft": "00:00:00",
+        "estimatedCompletionTime": "2023-10-17T23:23:09Z",
+        "status": "completed",
+        "trackedDownloadStatus": "warning",
+        "trackedDownloadState": "downloading",
+        "statusMessages": [
+          {
+            "title": "Some.Movie.1.Has.A.Title-1080P",
+            "messages": [
+              "Found matching movie via grab history, but release was matched to movie by ID. Manual Import required."
+            ]
+          }
+        ],
+        "errorMessage": "",
+        "downloadId": "SABnzbd_nzo_asdf1234",
+        "protocol": "usenet",
+        "downloadClient": "SabNZBd",
+        "indexer": "Some Indexer",
+        "outputPath": "/media/.downloads/complete/movies/Some.Movie.1.Has.A.Title-1080P",
+        "id": 8537983
+      }
+    ]
+  }
\ No newline at end of file
diff --git a/internal/arr/test_fixtures/common/v1_rootfolder.json b/internal/arr/test_fixtures/common/v1_rootfolder.json
new file mode 100644
index 0000000..27cfa47
--- /dev/null
+++ b/internal/arr/test_fixtures/common/v1_rootfolder.json
@@ -0,0 +1,6 @@
+[
+    {
+      "path": "/media/books/",
+      "freeSpace": 32147635175424
+    }
+  ]
\ No newline at end of file
diff --git a/internal/arr/test_fixtures/common/v1_system_status.json b/internal/arr/test_fixtures/common/v1_system_status.json
new file mode 100644
index 0000000..9b7b054
--- /dev/null
+++ b/internal/arr/test_fixtures/common/v1_system_status.json
@@ -0,0 +1,32 @@
+{
+    "appName": "Radarr",
+    "instanceName": "Radarr",
+    "version": "5.0.3.8127",
+    "buildTime": "2023-10-07T22:36:20Z",
+    "isDebug": false,
+    "isProduction": true,
+    "isAdmin": false,
+    "isUserInteractive": true,
+    "startupPath": "/app/bin",
+    "appData": "/config",
+    "osName": "alpine",
+    "osVersion": "3.18.4",
+    "isNetCore": true,
+    "isLinux": true,
+    "isOsx": false,
+    "isWindows": false,
+    "isDocker": false,
+    "mode": "console",
+    "branch": "develop",
+    "databaseType": "sqLite",
+    "databaseVersion": "3.41.2",
+    "authentication": "none",
+    "migrationVersion": 233,
+    "urlBase": "",
+    "runtimeVersion": "6.0.21",
+    "runtimeName": "netcore",
+    "startTime": "2023-10-13T20:45:26Z",
+    "packageVersion": "5.0.3.8127",
+    "packageAuthor": "[onedr0p](https://github.com/onedr0p)",
+    "packageUpdateMechanism": "docker"
+}
\ No newline at end of file
diff --git a/internal/arr/test_fixtures/common/v3_health.json b/internal/arr/test_fixtures/common/v3_health.json
new file mode 100644
index 0000000..155a404
--- /dev/null
+++ b/internal/arr/test_fixtures/common/v3_health.json
@@ -0,0 +1,8 @@
+[
+    {
+      "source": "IndexerLongTermStatusCheck",
+      "type": "warning",
+      "message": "Indexers unavailable due to failures for more than 6 hours: SomeIndexer",
+      "wikiUrl": "https://wiki.servarr.com/readarr/system#indexers-are-unavailable-due-to-failures"
+    }
+  ]
\ No newline at end of file
diff --git a/internal/arr/test_fixtures/common/v3_history.json b/internal/arr/test_fixtures/common/v3_history.json
new file mode 100644
index 0000000..7914f4e
--- /dev/null
+++ b/internal/arr/test_fixtures/common/v3_history.json
@@ -0,0 +1,8 @@
+{
+    "page": 1,
+    "pageSize": 10,
+    "sortKey": "date",
+    "sortDirection": "descending",
+    "totalRecords": 1368,
+    "records": []
+  }
\ No newline at end of file
diff --git a/internal/arr/test_fixtures/common/v3_queue.json b/internal/arr/test_fixtures/common/v3_queue.json
new file mode 100644
index 0000000..7c641e7
--- /dev/null
+++ b/internal/arr/test_fixtures/common/v3_queue.json
@@ -0,0 +1,69 @@
+{
+    "page": 1,
+    "pageSize": 10,
+    "sortKey": "timeleft",
+    "sortDirection": "ascending",
+    "totalRecords": 1,
+    "records": [
+      {
+        "movieId": 91,
+        "languages": [],
+        "quality": {
+          "quality": {
+            "id": 7,
+            "name": "Bluray-1080p",
+            "source": "bluray",
+            "resolution": 1080,
+            "modifier": "none"
+          },
+          "revision": {
+            "version": 2,
+            "real": 0,
+            "isRepack": false
+          }
+        },
+        "customFormats": [
+          {
+            "id": 6,
+            "name": "Special Edition"
+          },
+          {
+            "id": 8,
+            "name": "Repack/Proper"
+          },
+          {
+            "id": 27,
+            "name": "DTS-ES"
+          },
+          {
+            "id": 44,
+            "name": "HD Bluray Tier 02"
+          }
+        ],
+        "customFormatScore": 1880,
+        "size": 24397988960,
+        "title": "Some.Movie.1.Has.A.Title-1080P",
+        "sizeleft": 0,
+        "timeleft": "00:00:00",
+        "estimatedCompletionTime": "2023-10-17T23:23:09Z",
+        "status": "completed",
+        "trackedDownloadStatus": "warning",
+        "trackedDownloadState": "downloading",
+        "statusMessages": [
+          {
+            "title": "Some.Movie.1.Has.A.Title-1080P",
+            "messages": [
+              "Found matching movie via grab history, but release was matched to movie by ID. Manual Import required."
+            ]
+          }
+        ],
+        "errorMessage": "",
+        "downloadId": "SABnzbd_nzo_asdf1234",
+        "protocol": "usenet",
+        "downloadClient": "SabNZBd",
+        "indexer": "Some Indexer",
+        "outputPath": "/media/.downloads/complete/movies/Some.Movie.1.Has.A.Title-1080P",
+        "id": 8537983
+      }
+    ]
+  }
\ No newline at end of file
diff --git a/internal/arr/test_fixtures/common/v3_rootfolder.json b/internal/arr/test_fixtures/common/v3_rootfolder.json
new file mode 100644
index 0000000..27cfa47
--- /dev/null
+++ b/internal/arr/test_fixtures/common/v3_rootfolder.json
@@ -0,0 +1,6 @@
+[
+    {
+      "path": "/media/books/",
+      "freeSpace": 32147635175424
+    }
+  ]
\ No newline at end of file
diff --git a/internal/arr/test_fixtures/common/v3_system_status.json b/internal/arr/test_fixtures/common/v3_system_status.json
new file mode 100644
index 0000000..9b7b054
--- /dev/null
+++ b/internal/arr/test_fixtures/common/v3_system_status.json
@@ -0,0 +1,32 @@
+{
+    "appName": "Radarr",
+    "instanceName": "Radarr",
+    "version": "5.0.3.8127",
+    "buildTime": "2023-10-07T22:36:20Z",
+    "isDebug": false,
+    "isProduction": true,
+    "isAdmin": false,
+    "isUserInteractive": true,
+    "startupPath": "/app/bin",
+    "appData": "/config",
+    "osName": "alpine",
+    "osVersion": "3.18.4",
+    "isNetCore": true,
+    "isLinux": true,
+    "isOsx": false,
+    "isWindows": false,
+    "isDocker": false,
+    "mode": "console",
+    "branch": "develop",
+    "databaseType": "sqLite",
+    "databaseVersion": "3.41.2",
+    "authentication": "none",
+    "migrationVersion": 233,
+    "urlBase": "",
+    "runtimeVersion": "6.0.21",
+    "runtimeName": "netcore",
+    "startTime": "2023-10-13T20:45:26Z",
+    "packageVersion": "5.0.3.8127",
+    "packageAuthor": "[onedr0p](https://github.com/onedr0p)",
+    "packageUpdateMechanism": "docker"
+}
\ No newline at end of file
diff --git a/internal/test_util/const.go b/internal/test_util/const.go
new file mode 100644
index 0000000..b2b7d45
--- /dev/null
+++ b/internal/test_util/const.go
@@ -0,0 +1,5 @@
+package test_util
+
+const API_KEY = "abcdef0123456789abcdef0123456789"
+const FIXTURES_DIR = "../test_fixtures/"
+const COMMON_FIXTURES_PATH = FIXTURES_DIR + "common/"
diff --git a/internal/test_util/test_util.go b/internal/test_util/test_util.go
new file mode 100644
index 0000000..8246f72
--- /dev/null
+++ b/internal/test_util/test_util.go
@@ -0,0 +1,32 @@
+package test_util
+
+import (
+	"os"
+
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func NewTestServer(t *testing.T, fixture_dir string, fn func(http.ResponseWriter, *http.Request)) (*httptest.Server, error) {
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		fn(w, r)
+		require.NotEmpty(t, r.URL.Path)
+		// turns /api/some/path into some_path
+		endpoint := strings.Replace(strings.Replace(r.URL.Path, "/api/", "", -1), "/", "_", -1)
+		w.WriteHeader(http.StatusOK)
+		// NOTE: this assumes there is a file that matches the some_path
+		json, err := os.ReadFile(filepath.Join(fixture_dir, endpoint+".json"))
+		require.NoError(t, err)
+		_, err = w.Write(json)
+		require.NoError(t, err)
+	})), nil
+}
+
+func NewTestSharedServer(t *testing.T, fn func(http.ResponseWriter, *http.Request)) (*httptest.Server, error) {
+	return NewTestServer(t, COMMON_FIXTURES_PATH, fn)
+}