xtablo-source/backend/internal/web/handlers_test.go
Arthur Belleville 36e96015f5
feat(01-03): pgxpool wrapper, RequestID/slog middleware, slog handler switch
- Remove //go:build red_gate tag from internal/web/handlers_test.go and
  internal/db/pool_test.go now that consumer symbols are about to exist
- go mod tidy after real importers land (deferred from Plan 01-01 per
  Codex concern #1) — chi/v5, templ, pgx/v5, google/uuid now in require list
- internal/db/pool.go: NewPool(ctx, dsn) builds a pgxpool.Pool with
  MaxConns=10, MinConns=1; no eager Ping (RESEARCH Pitfall 2)
- internal/web/slog.go: NewSlogHandler returns JSON when env='production',
  text otherwise; pure helper, no slog.SetDefault side effect
- internal/web/middleware.go: RequestIDMiddleware (UUIDv4 → ctx +
  X-Request-ID header), LoggerFromContext helper, SlogLoggerMiddleware
  factory using chi WrapResponseWriter; field allowlist per V7/T-01-09
2026-05-14 19:24:16 +02:00

148 lines
4.2 KiB
Go

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, "<html") {
t.Errorf("body looks like full page (contains <html); want fragment\nbody: %s", body)
}
iso := regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`)
if !iso.MatchString(body) {
t.Errorf("body missing ISO-8601 UTC timestamp; got: %s", body)
}
}
func TestRequestID_HeaderSet(t *testing.T) {
router := NewRouter(stubPinger{}, "./static")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
router.ServeHTTP(rec, req)
rid := rec.Header().Get("X-Request-ID")
if rid == "" {
t.Fatal("X-Request-ID header is empty")
}
uuidv4 := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
if !uuidv4.MatchString(rid) {
t.Errorf("X-Request-ID %q does not match UUIDv4 regex", rid)
}
}
func TestSlog_HandlerSwitch(t *testing.T) {
var prodBuf bytes.Buffer
prodHandler := NewSlogHandler("production", &prodBuf)
slog.New(prodHandler).Info("x")
var prodLine map[string]any
if err := json.Unmarshal(bytes.TrimSpace(prodBuf.Bytes()), &prodLine); err != nil {
t.Fatalf("production handler should emit JSON; parse error: %v\nbuf: %s", err, prodBuf.String())
}
if prodLine["msg"] != "x" {
t.Errorf("production log msg = %v; want \"x\"", prodLine["msg"])
}
var devBuf bytes.Buffer
devHandler := NewSlogHandler("development", &devBuf)
slog.New(devHandler).Info("x")
var devLine map[string]any
if err := json.Unmarshal(bytes.TrimSpace(devBuf.Bytes()), &devLine); err == nil {
t.Errorf("development handler should NOT emit JSON; got parseable JSON: %s", devBuf.String())
}
}