From f068a751661a7f5b28fbe6d9bc9188c1ac2a57d6 Mon Sep 17 00:00:00 2001 From: martabal <74269598+martabal@users.noreply.github.com> Date: Wed, 30 Oct 2024 11:51:21 +0100 Subject: [PATCH] tests: more tests --- .gitignore | 1 + Makefile | 6 ++ src/app/app.go | 29 ++++---- src/app/default_test.go | 152 ++++++++++++++++++++++++++++++++++++++++ src/main.go | 8 ++- src/main_test.go | 84 ++++++++++++++++++++++ src/qbit/qbit.go | 4 ++ src/qbit/qbit_test.go | 131 ++++++++++++++++++++++++++++++++++ 8 files changed, 400 insertions(+), 15 deletions(-) create mode 100644 src/app/default_test.go create mode 100644 src/main_test.go create mode 100644 src/qbit/qbit_test.go diff --git a/.gitignore b/.gitignore index 62c1a4b..45fc216 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out +cover.html # Dependency directories (remove the comment below to include it) # vendor/ diff --git a/Makefile b/Makefile index c7d2c64..3f0804a 100644 --- a/Makefile +++ b/Makefile @@ -16,5 +16,11 @@ lint: test: cd src && go test -v ./... +test-count: + cd src && go test ./... -v | grep -c RUN + +test-coverage: + cd src && go test ./... -coverprofile=cover.out && go tool cover -html=cover.out && rm cover.out + update: cd src && go get -u . && go mod tidy \ No newline at end of file diff --git a/src/app/app.go b/src/app/app.go index b743372..9c9145b 100644 --- a/src/app/app.go +++ b/src/app/app.go @@ -7,11 +7,14 @@ import ( "qbit-exp/logger" "strconv" "strings" + "sync" "time" "github.com/joho/godotenv" ) +var loadEnvOnce sync.Once + var ( QBittorrent QBittorrentSettings Exporter ExporterSettings @@ -66,19 +69,21 @@ func SetVar(port int, enableTracker bool, loglevel string, baseUrl string, usern } func LoadEnv() { - var envfile bool - flag.BoolVar(&envfile, "e", false, "Use .env file") - flag.Parse() - _, err := os.Stat(".env") - UsingEnvFile = false - if !os.IsNotExist(err) && !envfile { - UsingEnvFile = true - err := godotenv.Load(".env") - if err != nil { - errormessage := "Error loading .env file:" + err.Error() - panic(errormessage) + loadEnvOnce.Do(func() { + var envfile bool + flag.BoolVar(&envfile, "e", false, "Use .env file") + flag.Parse() + _, err := os.Stat(".env") + UsingEnvFile = false + if !os.IsNotExist(err) && !envfile { + UsingEnvFile = true + err := godotenv.Load(".env") + if err != nil { + errormessage := "Error loading .env file:" + err.Error() + panic(errormessage) + } } - } + }) loglevel := logger.SetLogLevel(getEnv(defaultLogLevel)) qbitUsername := getEnv(defaultUsername) diff --git a/src/app/default_test.go b/src/app/default_test.go new file mode 100644 index 0000000..d649852 --- /dev/null +++ b/src/app/default_test.go @@ -0,0 +1,152 @@ +package app + +import ( + "bytes" + "log/slog" + "os" + "strconv" + "strings" + "testing" + + "qbit-exp/logger" +) + +var buff = &bytes.Buffer{} + +func init() { + logger.Log = &logger.Logger{Logger: slog.New(slog.NewTextHandler(buff, &slog.HandlerOptions{}))} +} + +func TestGetEnvReturnsEnvValue(t *testing.T) { + envVar := "EXPORTER_PORT" + expectedValue := "9090" + os.Setenv(envVar, expectedValue) + defer os.Unsetenv(envVar) + value := getEnv(defaultPort) + + if value != expectedValue { + t.Errorf("Expected %s, got %s", expectedValue, value) + } +} + +func TestGetEnvReturnsDefaultWhenEnvNotSet(t *testing.T) { + envVar := "EXPORTER_PORT" + expectedValue := strconv.Itoa(DEFAULT_PORT) + os.Unsetenv(envVar) + value := getEnv(defaultPort) + + if value != expectedValue { + t.Errorf("Expected default %s, got %s", expectedValue, value) + } +} + +func TestGetEnvLogsWarningIfHelpMessagePresent(t *testing.T) { + envVar := "QBITTORRENT_USERNAME" + os.Unsetenv(envVar) + expectedLogMessage := defaultUsername.Help + getEnv(defaultUsername) + + if !strings.Contains(buff.String(), expectedLogMessage) { + t.Errorf("Expected log message to contain '%s', got '%s'", expectedLogMessage, buff.String()) + } +} + +func TestGetEnvWithDifferentDefaults(t *testing.T) { + tests := []struct { + name string + env Env + expectedValue string + }{ + {"DefaultLogLevel", defaultLogLevel, "INFO"}, + {"DefaultPort", defaultPort, strconv.Itoa(DEFAULT_PORT)}, + {"DefaultTimeout", defaultTimeout, strconv.Itoa(DEFAULT_TIMEOUT)}, + {"DefaultUsername", defaultUsername, "admin"}, + {"DefaultPassword", defaultPassword, "adminadmin"}, + {"DefaultBaseUrl", defaultBaseUrl, "http://localhost:8080"}, + {"DefaultDisableTracker", defaultDisableTracker, "true"}, + {"DefaultHighCardinality", defaultHighCardinality, "false"}, + {"DefaultLabelWithHash", defaultLabelWithHash, "false"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Unsetenv(tt.env.Key) + value := getEnv(tt.env) + if value != tt.expectedValue { + t.Errorf("Expected %s, got %s", tt.expectedValue, value) + } + }) + } +} + +func TestGetEnvReturnsBooleanValues(t *testing.T) { + tests := []struct { + envVar Env + setValue string + expectVal string + }{ + {defaultDisableTracker, "false", "false"}, + {defaultHighCardinality, "true", "true"}, + {defaultLabelWithHash, "true", "true"}, + } + + for _, tt := range tests { + t.Run(tt.envVar.Key, func(t *testing.T) { + cleanup := setAndClearEnv(tt.envVar.Key, tt.setValue) + defer cleanup() + + value := getEnv(tt.envVar) + if value != tt.expectVal { + t.Errorf("Expected %s, got %s", tt.expectVal, value) + } + }) + } +} + +func TestGetEnvHandlesEmptyEnvVarGracefully(t *testing.T) { + // Arrange + envVar := "QBITTORRENT_USERNAME" + os.Setenv(envVar, "") + defer os.Unsetenv(envVar) + expectedValue := defaultUsername.DefaultValue + + // Act + value := getEnv(defaultUsername) + + // Assert + if value != expectedValue { + t.Errorf("Expected %s, got %s", expectedValue, value) + } +} + +func TestGetEnvLogsWarningsCorrectly(t *testing.T) { + tests := []struct { + name string + env Env + expectedLog string + }{ + {"UsernameWarning", defaultUsername, "Qbittorrent username is not set. Using default username"}, + {"PasswordWarning", defaultPassword, "Qbittorrent password is not set. Using default password"}, + {"BaseUrlWarning", defaultBaseUrl, "Qbittorrent base_url is not set. Using default base_url"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + os.Unsetenv(tt.env.Key) + + getEnv(tt.env) + + if !strings.Contains(buff.String(), tt.expectedLog) { + t.Errorf("Expected log message to contain '%s', got '%s'", tt.expectedLog, buff.String()) + } + }) + } +} + +func setAndClearEnv(key, value string) func() { + os.Setenv(key, value) + return func() { + os.Unsetenv(key) + } +} diff --git a/src/main.go b/src/main.go index b391756..955bba1 100644 --- a/src/main.go +++ b/src/main.go @@ -41,7 +41,9 @@ func main() { qbit.Auth() - http.HandleFunc("/metrics", metrics) + http.HandleFunc("/metrics", func(w http.ResponseWriter, req *http.Request) { + metrics(w, req, qbit.AllRequests) + }) addr := ":" + strconv.Itoa(app.Exporter.Port) if app.Exporter.Port != app.DEFAULT_PORT { logger.Log.Info("Listening on port " + strconv.Itoa(app.Exporter.Port)) @@ -53,7 +55,7 @@ func main() { } } -func metrics(w http.ResponseWriter, req *http.Request) { +func metrics(w http.ResponseWriter, req *http.Request, allRequestsFunc func(*prometheus.Registry) error) { ip, _, err := net.SplitHostPort(req.RemoteAddr) if err == nil { logger.Log.Trace("New request from " + ip) @@ -62,7 +64,7 @@ func metrics(w http.ResponseWriter, req *http.Request) { } registry := prometheus.NewRegistry() - err = qbit.AllRequests(registry) + err = allRequestsFunc(registry) if err != nil { http.Error(w, "", http.StatusServiceUnavailable) runtime.GC() diff --git a/src/main_test.go b/src/main_test.go new file mode 100644 index 0000000..604bd79 --- /dev/null +++ b/src/main_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "bytes" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "qbit-exp/logger" + + "github.com/prometheus/client_golang/prometheus" +) + +var buff = &bytes.Buffer{} + +func init() { + logger.Log = &logger.Logger{Logger: slog.New(slog.NewTextHandler(buff, &slog.HandlerOptions{}))} +} + +func TestMetricsFailureResponse(t *testing.T) { + + req, err := http.NewRequest("GET", "/metrics", nil) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + + metrics(rec, req, func(registry *prometheus.Registry) error { + return fmt.Errorf("mock error") + }) + + if status := rec.Code; status != http.StatusServiceUnavailable { + t.Errorf("expected status code 503, got %d", status) + } +} + +func TestMetricsReturnMetric(t *testing.T) { + + buff.Reset() + opts := &slog.HandlerOptions{ + Level: slog.Level(logger.Trace), + } + + logger.Log = &logger.Logger{Logger: slog.New(slog.NewTextHandler(buff, opts))} + + req, err := http.NewRequest("GET", "/metrics", nil) + req.RemoteAddr = "127.0.0.1:80" + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + + metrics(rec, req, func(registry *prometheus.Registry) error { + + qbittorrent_app_version := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "qbittorrent_app_version", + Help: "The current qBittorrent version", + ConstLabels: map[string]string{ + "version": string("1.0"), + }, + }) + registry.MustRegister(qbittorrent_app_version) + qbittorrent_app_version.Set(1) + return nil + }) + + if status := rec.Code; status != http.StatusOK { + t.Errorf("expected status code 200, got %d", status) + } + + expectedBody := "# HELP qbittorrent_app_version The current qBittorrent version\n# TYPE qbittorrent_app_version gauge\nqbittorrent_app_version{version=\"1.0\"} 1\n" + + if rec.Body.String() != expectedBody { + t.Errorf("expected \n%s, got \n%s", expectedBody, rec.Body.String()) + } + + traceMessage := "New request from" + if !strings.Contains(buff.String(), traceMessage) { + t.Errorf("expected %s, got %s", traceMessage, buff.String()) + } +} diff --git a/src/qbit/qbit.go b/src/qbit/qbit.go index 871df57..33a9067 100644 --- a/src/qbit/qbit.go +++ b/src/qbit/qbit.go @@ -241,6 +241,10 @@ func errorHelper(body []byte, err error, unmarshErr string) { logger.Log.Error(unmarshErr) } +// returns: +// - body (content of the http response) +// - retry (if it should retry that query) +// - err (the error if there was one during the request) func apiRequest(uri string, method string, queryParams *[]QueryParams) ([]byte, bool, error) { ctx, cancel := context.WithTimeout(context.Background(), app.QBittorrent.Timeout) defer cancel() diff --git a/src/qbit/qbit_test.go b/src/qbit/qbit_test.go new file mode 100644 index 0000000..811efc7 --- /dev/null +++ b/src/qbit/qbit_test.go @@ -0,0 +1,131 @@ +package qbit + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + app "qbit-exp/app" + "testing" + "time" +) + +func setupMockApp() { + app.QBittorrent.BaseUrl = "http://mockserver" + app.QBittorrent.Timeout = 2 * time.Second + app.QBittorrent.Cookie = "mockSID" + app.ShouldShowError = true +} + +func TestApiRequest_Success(t *testing.T) { + setupMockApp() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("success")) + })) + defer server.Close() + + app.QBittorrent.BaseUrl = server.URL + + body, reAuth, err := apiRequest("/test", "GET", nil) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if reAuth { + t.Fatalf("Expected reAuth to be false, got %v", reAuth) + } + if string(body) != "success" { + t.Fatalf("Expected body to be 'success', got %s", body) + } +} + +func TestApiRequest_Forbidden(t *testing.T) { + setupMockApp() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + app.QBittorrent.BaseUrl = server.URL // Override URL to use mock server + + _, reAuth, err := apiRequest("/test", "GET", nil) + if err == nil || err.Error() != "403" { + t.Fatalf("Expected error '403', got %v", err) + } + if !reAuth { + t.Fatalf("Expected reAuth to be true, got %v", reAuth) + } +} + +func TestApiRequest_Timeout(t *testing.T) { + setupMockApp() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(3 * time.Second) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + app.QBittorrent.BaseUrl = server.URL + + body, reAuth, err := apiRequest("/test", "GET", nil) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Expected DeadlineExceeded error, got %v", err) + } + if body != nil { + t.Fatalf("Expected no body, got %v", body) + } + if reAuth { + t.Fatalf("Expected reAuth to be false, got %v", reAuth) + } +} + +func TestApiRequest_WithQueryParams(t *testing.T) { + setupMockApp() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "param1=value1¶m2=value2" { + t.Fatalf("Expected query params 'param1=value1¶m2=value2', got %s", r.URL.RawQuery) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("query success")) + })) + defer server.Close() + + app.QBittorrent.BaseUrl = server.URL + + queryParams := []QueryParams{ + {"param1", "value1"}, + {"param2", "value2"}, + } + + body, retry, err := apiRequest("/test", "GET", &queryParams) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if retry { + t.Fatalf("Expected no retry, got %v", retry) + } + if string(body) != "query success" { + t.Fatalf("Expected body to be 'query success', got %s", body) + } +} + +func TestApiRequest_Non200Status(t *testing.T) { + setupMockApp() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + app.QBittorrent.BaseUrl = server.URL + + body, retry, err := apiRequest("/test", "GET", nil) + if err == nil || err.Error() != "500" { + t.Fatalf("Expected error '500', got %v", err) + } + if body != nil { + t.Fatalf("Expected no body, got %v", body) + } + if retry { + t.Fatalf("Expected reAuth to be false, got %v", retry) + } +}