From 2e227424ba3a5bcc703936c35bcadba8cda0931f Mon Sep 17 00:00:00 2001 From: mihna123 Date: Fri, 4 Sep 2026 01:13:26 +0200 Subject: config: add tests for config loading and page handlers Cover the defaults/system/user cascade, partial overrides, malformed files and base-url validation, plus DefaultPath resolution. An empty value in an ini file counts as unset, so that is pinned down too. Add the first handlers tests: routing, the instance name reaching the template, and escaping of operator-supplied config in the page. --- internal/config/config_test.go | 208 ++++++++++++++++++++++++++++++ internal/config/testdata/full.conf | 7 + internal/config/testdata/malformed.conf | 2 + internal/config/testdata/partial.conf | 2 + internal/config/testdata/system.conf | 7 + internal/server/handlers/handlers_test.go | 99 ++++++++++++++ 6 files changed, 325 insertions(+) create mode 100644 internal/config/config_test.go create mode 100644 internal/config/testdata/full.conf create mode 100644 internal/config/testdata/malformed.conf create mode 100644 internal/config/testdata/partial.conf create mode 100644 internal/config/testdata/system.conf create mode 100644 internal/server/handlers/handlers_test.go diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..36b2e97 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,208 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func noSystemConfig(t *testing.T) { + t.Helper() + old := systemConfigPath + systemConfigPath = filepath.Join(t.TempDir(), "absent.conf") + t.Cleanup(func() { systemConfigPath = old }) +} + +func writeConf(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "openstore.conf") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write conf: %v", err) + } + return path +} + +func TestLoadMissingFileUsesDefaults(t *testing.T) { + noSystemConfig(t) + + cfg, err := Load(filepath.Join(t.TempDir(), "nonexistent.conf")) + if err != nil { + t.Fatalf("load with no config file failed: %v", err) + } + + if cfg.Serve.Addr != ":3000" { + t.Errorf("addr: got %q, want \":3000\"", cfg.Serve.Addr) + } + if cfg.Instance.Name != "Openstore" { + t.Errorf("name: got %q, want \"Openstore\"", cfg.Instance.Name) + } +} + +func TestLoadFullFileOverridesDefaults(t *testing.T) { + noSystemConfig(t) + + cfg, err := Load("testdata/full.conf") + if err != nil { + t.Fatalf("load: %v", err) + } + + tests := []struct { + field string + got string + want string + }{ + {"addr", cfg.Serve.Addr, "127.0.0.1:8080"}, + {"name", cfg.Instance.Name, "Full Store"}, + {"contact-email", cfg.Instance.ContactEmail, "full@store.com"}, + {"base-url", cfg.Instance.BaseURL, "https://full.store.com"}, + } + + for _, tt := range tests { + if tt.got != tt.want { + t.Errorf("%s: got %q, want %q", tt.field, tt.got, tt.want) + } + } +} + +func TestLoadPartialFileKeepsDefaults(t *testing.T) { + noSystemConfig(t) + + cfg, err := Load("testdata/partial.conf") + if err != nil { + t.Fatalf("load: %v", err) + } + + if cfg.Instance.Name != "Only Name Set" { + t.Errorf("name: got %q, want \"Only Name Set\"", cfg.Instance.Name) + } + if cfg.Serve.Addr != ":3000" { + t.Errorf("unset addr should keep default, got %q", cfg.Serve.Addr) + } + if cfg.Instance.BaseURL != "https://www.example.com" { + t.Errorf("unset base-url should keep default, got %q", cfg.Instance.BaseURL) + } +} + +func TestLoadUserConfigWinsOverSystem(t *testing.T) { + old := systemConfigPath + systemConfigPath = "testdata/system.conf" + t.Cleanup(func() { systemConfigPath = old }) + + cfg, err := Load("testdata/partial.conf") + if err != nil { + t.Fatalf("load: %v", err) + } + + if cfg.Instance.Name != "Only Name Set" { + t.Errorf("user config should win, got name %q", cfg.Instance.Name) + } + if cfg.Instance.ContactEmail != "system@store.com" { + t.Errorf("key absent from user config should fall back to system, got %q", + cfg.Instance.ContactEmail) + } + if cfg.Serve.Addr != "127.0.0.1:9999" { + t.Errorf("section absent from user config should fall back to system, got %q", + cfg.Serve.Addr) + } +} + +func TestLoadMalformedFile(t *testing.T) { + noSystemConfig(t) + + if _, err := Load("testdata/malformed.conf"); err == nil { + t.Error("malformed config file loaded without error") + } +} + +func TestLoadRejectsBadBaseURL(t *testing.T) { + noSystemConfig(t) + + tests := map[string]string{ + "not a url": "garbage", + "no scheme": "example.com/path", + "scheme only": "https://", + "path only": "/listings", + "bad url syntax": "://x", + } + + for name, badURL := range tests { + t.Run(name, func(t *testing.T) { + path := writeConf(t, "[instance]\nbase-url = "+badURL+"\n") + + _, err := Load(path) + if err == nil { + t.Fatalf("bad base-url %q accepted", badURL) + } + if !strings.Contains(err.Error(), "base-url") { + t.Errorf("error should mention base-url, got: %v", err) + } + }) + } +} + +func TestLoadEmptyValueKeepsDefault(t *testing.T) { + noSystemConfig(t) + + path := writeConf(t, "[instance]\nname = \nbase-url = \n") + + cfg, err := Load(path) + if err != nil { + t.Fatalf("load: %v", err) + } + + if cfg.Instance.Name != "Openstore" { + t.Errorf("empty value should leave default, got name %q", cfg.Instance.Name) + } + if cfg.Instance.BaseURL != "https://www.example.com" { + t.Errorf("empty value should leave default, got base-url %q", cfg.Instance.BaseURL) + } +} + +func TestLoadAcceptsGoodBaseURL(t *testing.T) { + noSystemConfig(t) + + urls := []string{ + "https://example.com", + "http://example.com", + "https://example.com/subpath", + "https://sub.example.com:8443", + } + + for _, u := range urls { + t.Run(u, func(t *testing.T) { + path := writeConf(t, "[instance]\nbase-url = "+u+"\n") + + cfg, err := Load(path) + if err != nil { + t.Fatalf("good base-url %q rejected: %v", u, err) + } + if cfg.Instance.BaseURL != u { + t.Errorf("base-url: got %q, want %q", cfg.Instance.BaseURL, u) + } + }) + } +} + +func TestDefaultPath(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-test") + + path, err := DefaultPath() + if err != nil { + t.Fatalf("DefaultPath: %v", err) + } + + want := filepath.Join("/tmp/xdg-test", "openstore", "openstore.conf") + if path != want { + t.Errorf("got %q, want %q", path, want) + } +} + +func TestDefaultPathWithoutConfigDir(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + t.Setenv("HOME", "") + + if _, err := DefaultPath(); err == nil { + t.Error("DefaultPath returned no error with no config dir available") + } +} diff --git a/internal/config/testdata/full.conf b/internal/config/testdata/full.conf new file mode 100644 index 0000000..1f703f0 --- /dev/null +++ b/internal/config/testdata/full.conf @@ -0,0 +1,7 @@ +[serve] +addr = 127.0.0.1:8080 + +[instance] +name = Full Store +contact-email = full@store.com +base-url = https://full.store.com diff --git a/internal/config/testdata/malformed.conf b/internal/config/testdata/malformed.conf new file mode 100644 index 0000000..327aace --- /dev/null +++ b/internal/config/testdata/malformed.conf @@ -0,0 +1,2 @@ +[instance +name = Unclosed Section diff --git a/internal/config/testdata/partial.conf b/internal/config/testdata/partial.conf new file mode 100644 index 0000000..e84544d --- /dev/null +++ b/internal/config/testdata/partial.conf @@ -0,0 +1,2 @@ +[instance] +name = Only Name Set diff --git a/internal/config/testdata/system.conf b/internal/config/testdata/system.conf new file mode 100644 index 0000000..f2533a8 --- /dev/null +++ b/internal/config/testdata/system.conf @@ -0,0 +1,7 @@ +[serve] +addr = 127.0.0.1:9999 + +[instance] +name = System Store +contact-email = system@store.com +base-url = https://system.store.com diff --git a/internal/server/handlers/handlers_test.go b/internal/server/handlers/handlers_test.go new file mode 100644 index 0000000..b2ae43e --- /dev/null +++ b/internal/server/handlers/handlers_test.go @@ -0,0 +1,99 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "codeberg.org/mihna123/openstore/internal/config" +) + +func newTestConfig() *config.Config { + return &config.Config{ + Serve: config.Serve{Addr: ":3000"}, + Instance: config.Instance{ + Name: "TestStore", + ContactEmail: "test@store.com", + BaseURL: "https://test.store.com", + }, + } +} + +func get(t *testing.T, mux *http.ServeMux, method, path string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(method, path, nil)) + return rec +} + +func TestRoutes(t *testing.T) { + mux := Routes(newTestConfig()) + + tests := []struct { + name string + method string + path string + want int + }{ + {"home", http.MethodGet, "/", http.StatusOK}, + {"unknown path", http.MethodGet, "/nonsense", http.StatusNotFound}, + {"wrong method on home", http.MethodPost, "/", http.StatusMethodNotAllowed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := get(t, mux, tt.method, tt.path) + if rec.Code != tt.want { + t.Errorf("%s %s: got status %d, want %d", tt.method, tt.path, rec.Code, tt.want) + } + }) + } +} + +func TestHomeRendersInstanceName(t *testing.T) { + cfg := newTestConfig() + cfg.Instance.Name = "Prodavnica123" + mux := Routes(cfg) + + rec := get(t, mux, http.MethodGet, "/") + if rec.Code != http.StatusOK { + t.Fatalf("got status %d, want 200", rec.Code) + } + + body := rec.Body.String() + if strings.Count(body, "Prodavnica123") != 2 { + t.Errorf("instance name should appear in title and heading, got:\n%s", body) + } + + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("got Content-Type %q, want text/html", ct) + } +} + +func TestHomeEscapesInstanceName(t *testing.T) { + cfg := newTestConfig() + cfg.Instance.Name = `` + mux := Routes(cfg) + + body := get(t, mux, http.MethodGet, "/").Body.String() + + if strings.Contains(body, "