From 81463e305c9771d275c8485d850d9d4fc9993407db022f95d7ea952cd61318f1 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Tue, 29 Sep 2026 01:22:14 -0400 Subject: [PATCH] v0.19.1 dep update, test helper package --- cmd/logwisp/reload_test.go | 38 +-------- cmd/logwisp/test_helpers_test.go | 32 +++++++ go.mod | 2 +- go.sum | 12 +-- internal/config/loader_test.go | 42 +++------- internal/config/test_helpers_test.go | 15 ++++ internal/sink/http/http.go | 5 ++ internal/sink/http/http_test.go | 106 +++++++----------------- internal/sink/http/test_helpers_test.go | 48 +++++++++++ internal/source/file/file_test.go | 9 +- internal/testutil/fixtures.go | 31 +++++++ 11 files changed, 179 insertions(+), 161 deletions(-) create mode 100644 cmd/logwisp/test_helpers_test.go create mode 100644 internal/config/test_helpers_test.go create mode 100644 internal/sink/http/test_helpers_test.go create mode 100644 internal/testutil/fixtures.go diff --git a/cmd/logwisp/reload_test.go b/cmd/logwisp/reload_test.go index 8c2d674..fc4d727 100644 --- a/cmd/logwisp/reload_test.go +++ b/cmd/logwisp/reload_test.go @@ -2,23 +2,14 @@ package main import ( "context" - "os" "path/filepath" - "strings" "testing" - "logwisp/internal/config" + "logwisp/internal/testutil" lconfig "github.com/lixenwraith/config" - "github.com/lixenwraith/log" ) -func testLogger(t *testing.T) { - t.Helper() - logger = log.NewLogger() - t.Cleanup(func() { logger = nil }) -} - func TestWatchErrorsDoNotTriggerReloadAndChangesAreCoalesced(t *testing.T) { testLogger(t) for _, event := range []string{lconfig.EventFileDeleted, lconfig.EventPermissionsChanged, lconfig.EventReloadTimeout, lconfig.EventReloadError + ":invalid TOML"} { @@ -39,29 +30,6 @@ func TestWatchErrorsDoNotTriggerReloadAndChangesAreCoalesced(t *testing.T) { } } -func loadTestConfig(t *testing.T, path string) *config.Config { - t.Helper() - for _, entry := range os.Environ() { - key, value, _ := strings.Cut(entry, "=") - if strings.HasPrefix(key, "LOGWISP_") { - t.Setenv(key, value) - if err := os.Unsetenv(key); err != nil { - t.Fatal(err) - } - } - } - m, err := config.Load([]string{"-c", path, "--quiet", "--status_reporter=false"}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(m.Close) - cfg, err := m.Snapshot() - if err != nil { - t.Fatal(err) - } - return cfg -} - func TestShippedConfigurationBuildsWithCheckedPluginDecoder(t *testing.T) { testLogger(t) path, err := filepath.Abs(filepath.Join("..", "..", "config", "logwisp.toml")) @@ -87,9 +55,7 @@ func TestShippedConfigurationBuildsWithCheckedPluginDecoder(t *testing.T) { func TestInvalidReloadLeavesStatusReporterRunning(t *testing.T) { testLogger(t) path := filepath.Join(t.TempDir(), "config.toml") - if err := os.WriteFile(path, []byte("[logging]\nlevel = \"info\"\n"), 0600); err != nil { - t.Fatal(err) - } + testutil.WriteFile(t, path, "[logging]\nlevel = \"info\"\n") for _, mode := range []string{"empty-pipelines", "duplicate-names", "fractional-plugin-integer", "plugin-overflow"} { t.Run(mode, func(t *testing.T) { cfg := loadTestConfig(t, path) diff --git a/cmd/logwisp/test_helpers_test.go b/cmd/logwisp/test_helpers_test.go new file mode 100644 index 0000000..3b91c88 --- /dev/null +++ b/cmd/logwisp/test_helpers_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "testing" + + "logwisp/internal/config" + "logwisp/internal/testutil" + + "github.com/lixenwraith/log" +) + +func testLogger(t *testing.T) { + t.Helper() + previous := logger + logger = log.NewLogger() + t.Cleanup(func() { logger = previous }) +} + +func loadTestConfig(t *testing.T, path string) *config.Config { + t.Helper() + testutil.ClearEnvPrefix(t, "LOGWISP_") + m, err := config.Load([]string{"-c", path, "--quiet", "--status_reporter=false"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(m.Close) + cfg, err := m.Snapshot() + if err != nil { + t.Fatal(err) + } + return cfg +} diff --git a/go.mod b/go.mod index 4a17c71..14136b2 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module logwisp go 1.27.1 require ( - github.com/lixenwraith/config v0.1.1-0.20260928232812-d89d4e7c8167 + github.com/lixenwraith/config v0.1.1-0.20260929045030-c10752fdd22d github.com/lixenwraith/log v0.1.1-0.20260801090951-2c40643523b3 ) diff --git a/go.sum b/go.sum index 4bc237c..e1181d6 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,6 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/lixenwraith/config v0.1.1-0.20260928232812-d89d4e7c8167 h1:WOB0DbCxGEXksprSpatdsHWeV2+koKDNc5OtNE3knJA= -github.com/lixenwraith/config v0.1.1-0.20260928232812-d89d4e7c8167/go.mod h1:vGA41x85GxmJqYTCwg7cOlHUtqBh7AK/wPTRXuFQIYI= +github.com/lixenwraith/config v0.1.1-0.20260929045030-c10752fdd22d h1:43qJzqVtyYT3EpO9hcazcogJ/EYGLjLRj2Uqoa5b/zw= +github.com/lixenwraith/config v0.1.1-0.20260929045030-c10752fdd22d/go.mod h1:yfJQwKS+/6Dg70GIoHeYc0eYztOPETpqe6nUIbakcyg= github.com/lixenwraith/log v0.1.1-0.20260801090951-2c40643523b3 h1:5ojkxyuOKiUBRYhqPqKJS1lrqkjckYqYMNGBB49kdY0= github.com/lixenwraith/log v0.1.1-0.20260801090951-2c40643523b3/go.mod h1:fvGT3IxlJQLJ1is4OP8Iet8gVzTJTGcuT90FFB2WBtc= github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4 h1:9qZU0iBIsqintDh39vieio0ygLiRrPmCEo6Ra45EQyY= github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4/go.mod h1:itcT/Vt5mwmX9KVGCMsWZr6vbU/JgZ24wV/I0J5cMt0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 1814f00..690ddab 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -2,39 +2,17 @@ package config import ( "errors" - "os" "path/filepath" "reflect" "strings" "testing" "time" + "logwisp/internal/testutil" + lconfig "github.com/lixenwraith/config" ) -func isolateConfig(t *testing.T) { - t.Helper() - for _, entry := range os.Environ() { - key, value, _ := strings.Cut(entry, "=") - if strings.HasPrefix(key, "LOGWISP_") { - t.Setenv(key, value) - if err := os.Unsetenv(key); err != nil { - t.Fatal(err) - } - } - } - dir := t.TempDir() - t.Setenv("HOME", dir) - t.Chdir(dir) -} - -func writeConfig(t *testing.T, path, contents string) { - t.Helper() - if err := os.WriteFile(path, []byte(contents), 0600); err != nil { - t.Fatal(err) - } -} - func TestResolveConfigArguments(t *testing.T) { isolateConfig(t) for _, args := range [][]string{{"-c", "chosen.toml"}, {"--config", "chosen.toml"}, {"--config=chosen.toml"}, {"-c=chosen.toml"}, {"-c", "old.toml", "--config=chosen.toml"}} { @@ -63,7 +41,7 @@ func TestResolveConfigArguments(t *testing.T) { func TestLoadPrecedenceAndDetachedSnapshots(t *testing.T) { isolateConfig(t) - writeConfig(t, "selected.toml", "config_file = \"ignored.toml\"\n[logging]\nlevel = \"error\"\n") + testutil.WriteFile(t, "selected.toml", "config_file = \"ignored.toml\"\n[logging]\nlevel = \"error\"\n") t.Setenv("LOGGING_LEVEL", "invalid-bare-variable") t.Setenv("LOGWISP_LOGGING_LEVEL", "debug") m, err := Load([]string{"--config", "selected.toml", "--logging.level=warn", "--quiet"}) @@ -116,7 +94,7 @@ func TestMissingFilesDoNotHideInvalidOverrides(t *testing.T) { func TestReloadReadsDiskValidatesAndPreservesOldSnapshots(t *testing.T) { isolateConfig(t) - writeConfig(t, "reload.toml", "status_reporter = true\n") + testutil.WriteFile(t, "reload.toml", "status_reporter = true\n") m, err := Load([]string{"-c", "reload.toml"}) if err != nil { t.Fatal(err) @@ -126,24 +104,24 @@ func TestReloadReadsDiskValidatesAndPreservesOldSnapshots(t *testing.T) { if err != nil { t.Fatal(err) } - writeConfig(t, "reload.toml", "status_reporter = false\n") + testutil.WriteFile(t, "reload.toml", "status_reporter = false\n") next, err := m.Reload() if err != nil || next.StatusReporter || !old.StatusReporter || next.ConfigFile != old.ConfigFile { t.Fatalf("disk reload/snapshot ownership: %+v %v", next, err) } // Builder validators do not run on LoadFile; Snapshot must run them again. - writeConfig(t, "reload.toml", "pipelines = []\n") + testutil.WriteFile(t, "reload.toml", "pipelines = []\n") if _, err := m.Reload(); err == nil || !strings.Contains(err.Error(), "no pipelines") { t.Fatalf("semantically invalid reload accepted: %v", err) } if len(next.Pipelines) == 0 || !old.StatusReporter { t.Fatal("invalid edit changed a published snapshot") } - writeConfig(t, "reload.toml", "[logging.file]\nmax_size_mb = 1.5\n") + testutil.WriteFile(t, "reload.toml", "[logging.file]\nmax_size_mb = 1.5\n") if _, err := m.Reload(); err == nil { t.Fatal("fractional integer accepted") } - writeConfig(t, "reload.toml", "# remove the status override\n") + testutil.WriteFile(t, "reload.toml", "# remove the status override\n") recovered, err := m.Reload() if err != nil || !recovered.StatusReporter { t.Fatalf("override removal/recovery: %+v %v", recovered, err) @@ -152,7 +130,7 @@ func TestReloadReadsDiskValidatesAndPreservesOldSnapshots(t *testing.T) { func TestWatchStartsOnSubscriptionAndCloses(t *testing.T) { isolateConfig(t) - writeConfig(t, "watched.toml", "auto_reload = true\n") + testutil.WriteFile(t, "watched.toml", "auto_reload = true\n") m, err := Load([]string{"-c", "watched.toml"}) if err != nil { t.Fatal(err) @@ -178,7 +156,7 @@ func TestWatchStartsOnSubscriptionAndCloses(t *testing.T) { func TestNestedPluginOptionsKeepDefaultsAndNumericTypes(t *testing.T) { isolateConfig(t) - writeConfig(t, "plugins.toml", ` + testutil.WriteFile(t, "plugins.toml", ` [[pipelines]] name = "nested" [[pipelines.plugin_sources]] diff --git a/internal/config/test_helpers_test.go b/internal/config/test_helpers_test.go new file mode 100644 index 0000000..78d974f --- /dev/null +++ b/internal/config/test_helpers_test.go @@ -0,0 +1,15 @@ +package config + +import ( + "testing" + + "logwisp/internal/testutil" +) + +func isolateConfig(t *testing.T) { + t.Helper() + testutil.ClearEnvPrefix(t, "LOGWISP_") + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Chdir(dir) +} diff --git a/internal/sink/http/http.go b/internal/sink/http/http.go index 14852cb..eec7b84 100644 --- a/internal/sink/http/http.go +++ b/internal/sink/http/http.go @@ -202,7 +202,12 @@ func (h *HTTPSink) Start(ctx context.Context) error { if err != nil { return fmt.Errorf("http sink bind %s: %w", h.addr, err) } + return h.serve(ctx, ln) +} +// serve owns an already-bound listener, allowing tests to reserve an ephemeral +// port and exercise the same routing and worker lifecycle as Start. +func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error { mux := http.NewServeMux() // Method-scoped patterns: mux answers 405 with Allow header on non-GET mux.HandleFunc(http.MethodGet+" "+h.config.StreamPath, h.handleStream) diff --git a/internal/sink/http/http_test.go b/internal/sink/http/http_test.go index 669f030..0d3ee64 100644 --- a/internal/sink/http/http_test.go +++ b/internal/sink/http/http_test.go @@ -1,42 +1,22 @@ package http import ( - "context" + "bufio" "encoding/json" "net/http" "net/http/httptest" "testing" "time" - "logwisp/internal/session" "logwisp/internal/sink" - - "github.com/lixenwraith/log" ) func TestStatusReportsQueueAndConnectionBounds(t *testing.T) { - manager := session.NewManager(time.Hour) - defer manager.Stop() - created, err := NewHTTPSinkPlugin( - "stream", - map[string]any{ - "host": "127.0.0.1", - "port": int64(8081), - "buffer_size": int64(4096), - "client_buffer_size": int64(512), - "max_connections": int64(32), - "write_timeout_ms": int64(5000), - }, - log.NewLogger(), - session.NewProxy(manager, "stream"), - ) - if err != nil { - t.Fatal(err) - } - httpSink, ok := created.(*HTTPSink) - if !ok { - t.Fatalf("sink type = %T", created) - } + httpSink, _ := newTestHTTPSink(t, map[string]any{ + "buffer_size": int64(4096), + "client_buffer_size": int64(512), + "max_connections": int64(32), + }) recorder := httptest.NewRecorder() httpSink.handleStatus(recorder, httptest.NewRequest("GET", "/status", nil)) @@ -80,32 +60,14 @@ func TestStatusReportsQueueAndConnectionBounds(t *testing.T) { // bumps activity otherwise, so a quiet source would idle-expire a healthy client // and the broker would evict it on the next entry. func TestQuietStreamRefreshesItsSession(t *testing.T) { - manager := session.NewManager(time.Hour) - defer manager.Stop() - created, err := NewHTTPSinkPlugin( - "stream", - map[string]any{"host": "127.0.0.1", "port": int64(18191), "write_timeout_ms": int64(5000)}, - log.NewLogger(), - session.NewProxy(manager, "stream"), - ) - if err != nil { - t.Fatal(err) - } - httpSink := created.(*HTTPSink) + httpSink, manager := newTestHTTPSink(t, nil) httpSink.keepalive = 100 * time.Millisecond - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - if err := httpSink.Start(ctx); err != nil { - t.Fatal(err) - } - defer httpSink.Stop() - - resp, err := http.Get("http://127.0.0.1:18191/stream") + client, baseURL := serveTestHTTPSink(t, httpSink) + resp, err := client.Get(baseURL + "/stream") if err != nil { t.Fatal(err) } - defer resp.Body.Close() + t.Cleanup(func() { _ = resp.Body.Close() }) activity := func() time.Time { for _, s := range manager.GetActiveSessions() { @@ -115,42 +77,34 @@ func TestQuietStreamRefreshesItsSession(t *testing.T) { return time.Time{} } - deadline := time.Now().Add(2 * time.Second) - for activity().IsZero() && time.Now().Before(deadline) { - time.Sleep(10 * time.Millisecond) - } before := activity() - - // No events are sent for several keepalive periods. - time.Sleep(350 * time.Millisecond) + if before.IsZero() { + t.Fatal("stream session was not registered") + } + scanner := bufio.NewScanner(resp.Body) + heartbeats := 0 + for heartbeats < 2 && scanner.Scan() { + if scanner.Text() == ":" { + heartbeats++ + } + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + if heartbeats != 2 { + t.Fatal("stream ended before two keepalives") + } if after := activity(); !after.After(before) { - t.Fatalf("last activity %v did not advance on a silent stream", after) + t.Fatalf("last activity %v did not advance after keepalives from %v", after, before) } } // HEAD on the stream path is refused rather than served from the GET pattern: // its body writes are discarded, so the client it would register never reads. func TestHeadOnStreamPathIsRefused(t *testing.T) { - manager := session.NewManager(time.Hour) - defer manager.Stop() - created, err := NewHTTPSinkPlugin( - "stream", - map[string]any{"host": "127.0.0.1", "port": int64(18192)}, - log.NewLogger(), - session.NewProxy(manager, "stream"), - ) - if err != nil { - t.Fatal(err) - } - httpSink := created.(*HTTPSink) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - if err := httpSink.Start(ctx); err != nil { - t.Fatal(err) - } - defer httpSink.Stop() - - resp, err := http.Head("http://127.0.0.1:18192/stream") + httpSink, manager := newTestHTTPSink(t, nil) + client, baseURL := serveTestHTTPSink(t, httpSink) + resp, err := client.Head(baseURL + "/stream") if err != nil { t.Fatal(err) } diff --git a/internal/sink/http/test_helpers_test.go b/internal/sink/http/test_helpers_test.go new file mode 100644 index 0000000..b047e07 --- /dev/null +++ b/internal/sink/http/test_helpers_test.go @@ -0,0 +1,48 @@ +package http + +import ( + "context" + "maps" + "net" + "net/http" + "testing" + "time" + + "logwisp/internal/session" + + "github.com/lixenwraith/log" +) + +func newTestHTTPSink(t *testing.T, overrides map[string]any) (*HTTPSink, *session.Manager) { + t.Helper() + manager := session.NewManager(time.Hour) + t.Cleanup(manager.Stop) + opts := map[string]any{"host": "127.0.0.1", "port": int64(8081), "write_timeout_ms": int64(5000)} + maps.Copy(opts, overrides) + created, err := NewHTTPSinkPlugin("stream", opts, log.NewLogger(), session.NewProxy(manager, "stream")) + if err != nil { + t.Fatal(err) + } + h, ok := created.(*HTTPSink) + if !ok { + t.Fatalf("sink type = %T", created) + } + return h, manager +} + +func serveTestHTTPSink(t *testing.T, h *HTTPSink) (*http.Client, string) { + t.Helper() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(func() { cancel(); h.Stop() }) + if err := h.serve(ctx, listener); err != nil { + t.Fatal(err) + } + client := &http.Client{Timeout: 3 * time.Second} + t.Cleanup(client.CloseIdleConnections) + return client, "http://" + listener.Addr().String() +} diff --git a/internal/source/file/file_test.go b/internal/source/file/file_test.go index eded121..895b890 100644 --- a/internal/source/file/file_test.go +++ b/internal/source/file/file_test.go @@ -9,13 +9,12 @@ import ( "time" "logwisp/internal/core" + "logwisp/internal/testutil" ) func TestStoppedWatcherReturnsNormally(t *testing.T) { path := filepath.Join(t.TempDir(), "session.jsonl") - if err := os.WriteFile(path, nil, 0o600); err != nil { - t.Fatal(err) - } + testutil.WriteFile(t, path, "") watcher := newFileWatcher(path, true, true, func(_ core.LogEntry) {}, nil) watcher.stop() @@ -52,9 +51,7 @@ func TestRemoveWatcherPreservesReplacement(t *testing.T) { func TestRotatedFileResumesInsteadOfReplaying(t *testing.T) { dir := t.TempDir() active := filepath.Join(dir, "session.jsonl") - if err := os.WriteFile(active, []byte("one\ntwo\n"), 0o600); err != nil { - t.Fatal(err) - } + testutil.WriteFile(t, active, "one\ntwo\n") info, err := os.Stat(active) if err != nil { t.Fatal(err) diff --git a/internal/testutil/fixtures.go b/internal/testutil/fixtures.go new file mode 100644 index 0000000..8e2361a --- /dev/null +++ b/internal/testutil/fixtures.go @@ -0,0 +1,31 @@ +// Package testutil provides standard-library fixtures shared by logwisp tests. +// Application packages must not import it. +package testutil + +import ( + "os" + "strings" + "testing" +) + +// ClearEnvPrefix isolates overrides while preserving the caller's environment. +// Like testing.Setenv, it must not be used by parallel tests. +func ClearEnvPrefix(t testing.TB, prefix string) { + t.Helper() + for _, entry := range os.Environ() { + key, value, _ := strings.Cut(entry, "=") + if strings.HasPrefix(key, prefix) { + t.Setenv(key, value) // Register restoration before unsetting it. + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset fixture variable %s: %v", key, err) + } + } + } +} + +func WriteFile(t testing.TB, path, contents string) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatalf("write fixture %q: %v", path, err) + } +}