976f5238f8
- internal .env loader (real env wins) + strictly validated config struct - ULID package: 48-bit ms timestamp + 80-bit crypto randomness, sortable - HTTP server with recovery/request-id/trusted-proxy/access-log middleware - /healthz, /readyz (pluggable checkers), /metricsz (counter registry) - multi-stage Dockerfile (alpine, non-root) + compose with healthchecks, app bound to 127.0.0.1:8787, mongo unpublished, graceful 15s shutdown Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
173 lines
4.4 KiB
Go
173 lines
4.4 KiB
Go
package httpx
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"bountyboard/internal/config"
|
|
"bountyboard/internal/metrics"
|
|
"bountyboard/internal/ulid"
|
|
)
|
|
|
|
func newTestServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
t.Setenv("SESSION_SECRET", "0123456789abcdef0123456789abcdef")
|
|
t.Setenv("CREDENTIALS_ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
t.Fatalf("config: %v", err)
|
|
}
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
return New(cfg, log, metrics.NewRegistry())
|
|
}
|
|
|
|
func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func TestHealthz(t *testing.T) {
|
|
s := newTestServer(t)
|
|
rec := get(t, s.Handler(), "/healthz")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
var body map[string]string
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["status"] != "ok" {
|
|
t.Errorf("status field = %q", body["status"])
|
|
}
|
|
if id := rec.Header().Get("X-Request-Id"); !ulid.IsValid(id) {
|
|
t.Errorf("X-Request-Id %q is not a ULID", id)
|
|
}
|
|
}
|
|
|
|
func TestReadyzAggregation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
checks []ReadinessCheck
|
|
wantStatus string
|
|
wantCode int
|
|
}{
|
|
{
|
|
name: "no checks",
|
|
wantStatus: "ok",
|
|
wantCode: http.StatusOK,
|
|
},
|
|
{
|
|
name: "all passing",
|
|
checks: []ReadinessCheck{
|
|
{Name: "mongo", Required: true, Probe: func(context.Context) error { return nil }},
|
|
{Name: "atomizer", Probe: func(context.Context) error { return nil }},
|
|
},
|
|
wantStatus: "ok",
|
|
wantCode: http.StatusOK,
|
|
},
|
|
{
|
|
name: "optional failing is non-fatal",
|
|
checks: []ReadinessCheck{
|
|
{Name: "mongo", Required: true, Probe: func(context.Context) error { return nil }},
|
|
{Name: "atomizer", Probe: func(context.Context) error { return errors.New("down") }},
|
|
},
|
|
wantStatus: "degraded",
|
|
wantCode: http.StatusOK,
|
|
},
|
|
{
|
|
name: "required failing gates readiness",
|
|
checks: []ReadinessCheck{
|
|
{Name: "mongo", Required: true, Probe: func(context.Context) error { return errors.New("no reachable servers") }},
|
|
},
|
|
wantStatus: "unavailable",
|
|
wantCode: http.StatusServiceUnavailable,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
s := newTestServer(t)
|
|
for _, c := range tt.checks {
|
|
s.AddReadinessCheck(c)
|
|
}
|
|
rec := get(t, s.Handler(), "/readyz")
|
|
if rec.Code != tt.wantCode {
|
|
t.Fatalf("code = %d, want %d", rec.Code, tt.wantCode)
|
|
}
|
|
var body struct {
|
|
Status string `json:"status"`
|
|
Checks map[string]checkResult `json:"checks"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Status != tt.wantStatus {
|
|
t.Errorf("status = %q, want %q", body.Status, tt.wantStatus)
|
|
}
|
|
if len(body.Checks) != len(tt.checks) {
|
|
t.Errorf("checks = %d, want %d", len(body.Checks), len(tt.checks))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMetricszCountsRequests(t *testing.T) {
|
|
s := newTestServer(t)
|
|
get(t, s.Handler(), "/healthz")
|
|
get(t, s.Handler(), "/healthz")
|
|
rec := get(t, s.Handler(), "/metricsz")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
var body struct {
|
|
Counters map[string]int64 `json:"counters"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Counters["http_requests_total"] != 2 {
|
|
t.Errorf("http_requests_total = %d, want 2", body.Counters["http_requests_total"])
|
|
}
|
|
}
|
|
|
|
func TestPanicRecoveryEnvelope(t *testing.T) {
|
|
s := newTestServer(t)
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /boom", func(http.ResponseWriter, *http.Request) { panic("kaboom") })
|
|
h := s.withMiddleware(mux)
|
|
|
|
rec := get(t, h, "/boom")
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d", rec.Code)
|
|
}
|
|
var env struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if env.Error.Code != "internal" {
|
|
t.Errorf("error code = %q", env.Error.Code)
|
|
}
|
|
}
|
|
|
|
func TestUnknownRouteNotFound(t *testing.T) {
|
|
s := newTestServer(t)
|
|
rec := get(t, s.Handler(), "/nope")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rec.Code)
|
|
}
|
|
}
|