From 37d19a33147ce225de61c89d848bb2d6179b49cf Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 18:48:26 +0200 Subject: [PATCH] test(01-02): add red-gated handler and pool tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handlers_test.go (//go:build red_gate): TestHealthz_OK, TestHealthz_Down, TestIndex_RendersHxGet, TestDemoTime_Fragment, TestRequestID_HeaderSet, TestSlog_HandlerSwitch — reference web.HealthzHandler / NewRouter / NewSlogHandler / Pinger to be implemented in Plan 01-03 - pool_test.go (//go:build red_gate): TestPool_Connects with t.Skip fallback when DATABASE_URL is unset - Build tag isolates the RED state from default 'go test ./...' (Codex #3) --- backend/internal/db/pool_test.go | 33 ++++++ backend/internal/web/handlers_test.go | 150 ++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 backend/internal/db/pool_test.go create mode 100644 backend/internal/web/handlers_test.go diff --git a/backend/internal/db/pool_test.go b/backend/internal/db/pool_test.go new file mode 100644 index 0000000..afcd3ff --- /dev/null +++ b/backend/internal/db/pool_test.go @@ -0,0 +1,33 @@ +//go:build red_gate + +package db + +import ( + "context" + "os" + "testing" + "time" +) + +// TestPool_Connects is an integration test that requires a live Postgres +// reachable via DATABASE_URL. Skipped in unit-test runs without the env var +// set. DSN value itself is never logged (info-disclosure T-01-06). +func TestPool_Connects(t *testing.T) { + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + t.Skip("DATABASE_URL not set — integration test skipped") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + pool, err := NewPool(ctx, dsn) + if err != nil { + t.Fatalf("NewPool: unexpected error: %v", err) + } + defer pool.Close() + + if err := pool.Ping(ctx); err != nil { + t.Fatalf("pool.Ping: unexpected error: %v", err) + } +} diff --git a/backend/internal/web/handlers_test.go b/backend/internal/web/handlers_test.go new file mode 100644 index 0000000..934cb25 --- /dev/null +++ b/backend/internal/web/handlers_test.go @@ -0,0 +1,150 @@ +//go:build red_gate + +package web + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" +) + +// stubPinger satisfies the web.Pinger interface that Plan 01-03 will declare. +// Local to this test file — never reachable from production code. +type stubPinger struct { + err error +} + +func (s stubPinger) Ping(ctx context.Context) error { return s.err } + +func TestHealthz_OK(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + + HealthzHandler(stubPinger{err: nil}).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Errorf("Content-Type = %q; want application/json", ct) + } + body := rec.Body.String() + if !strings.Contains(body, `"status":"ok"`) { + t.Errorf("body missing status:ok; got: %s", body) + } + if !strings.Contains(body, `"db":"ok"`) { + t.Errorf("body missing db:ok; got: %s", body) + } +} + +func TestHealthz_Down(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + + HealthzHandler(stubPinger{err: errors.New("conn refused")}).ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d; want 503", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, `"status":"degraded"`) { + t.Errorf("body missing status:degraded; got: %s", body) + } + if !strings.Contains(body, `"db":"down"`) { + t.Errorf("body missing db:down; got: %s", body) + } +} + +func TestIndex_RendersHxGet(t *testing.T) { + router := NewRouter(stubPinger{}, "./static") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("Content-Type = %q; want text/html", ct) + } + body := rec.Body.String() + for _, want := range []string{ + `hx-get="/demo/time"`, + `hx-target="#demo-out"`, + `ui-button-solid-default-md`, + `Fetch server time`, + } { + if !strings.Contains(body, want) { + t.Errorf("body missing %q", want) + } + } +} + +func TestDemoTime_Fragment(t *testing.T) { + router := NewRouter(stubPinger{}, "./static") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/demo/time", nil) + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("Content-Type = %q; want text/html", ct) + } + body := rec.Body.String() + if strings.Contains(body, "