package shared import ( "encoding/json" "errors" "net" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) // Closes R-002: internal/shared was load-bearing-but-untested per the // audit. These tests cover the pieces server.go exposes that DON'T // require running Run() under a signal — bind error surfacing, JSON // shape of /health, and the register-callback contract. func TestNewListener_ValidAddr(t *testing.T) { // Port 0 = "let the OS pick" — the listener should bind cleanly. ln, err := newListener("127.0.0.1:0") if err != nil { t.Fatalf("expected success on :0, got %v", err) } defer ln.Close() if _, _, err := net.SplitHostPort(ln.Addr().String()); err != nil { t.Errorf("listener returned unparseable addr %q: %v", ln.Addr(), err) } } func TestNewListener_InvalidAddr(t *testing.T) { cases := []struct { name string addr string }{ // Note: net.Listen("tcp", "") binds an OS-picked address — NOT // an error — so empty string is excluded here. That quirk is // captured in TestNewListener_EmptyAddrIsValid below. {"non-numeric port", "127.0.0.1:notaport"}, {"port out of range", "127.0.0.1:999999"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ln, err := newListener(tc.addr) if err == nil { ln.Close() t.Fatalf("expected error on %q, got success", tc.addr) } }) } } // Documents the net.Listen empty-string quirk so a future reader // doesn't waste time wondering whether it should be a hard error. // stdlib treats "" as ":0" → bind to all addrs, OS-picked port. func TestNewListener_EmptyAddrIsValid(t *testing.T) { ln, err := newListener("") if err != nil { t.Fatalf("net.Listen quirk changed: empty addr now errors with %v", err) } defer ln.Close() } func TestNewListener_PortAlreadyInUse(t *testing.T) { // Bind first to occupy a real port. first, err := newListener("127.0.0.1:0") if err != nil { t.Fatalf("setup listener: %v", err) } defer first.Close() // Second bind to the same address should fail synchronously — // this is the contract Run depends on per the "race-safe startup" // comment in server.go. second, err := newListener(first.Addr().String()) if err == nil { second.Close() t.Fatalf("expected EADDRINUSE-like error, got success") } } func TestHealthResponse_JSONShape(t *testing.T) { hr := HealthResponse{Status: "ok", Service: "test-svc"} out, err := json.Marshal(hr) if err != nil { t.Fatalf("marshal: %v", err) } expected := `{"status":"ok","service":"test-svc"}` if string(out) != expected { t.Errorf("got %q, want %q", string(out), expected) } // And round-trip — important because /health consumers depend on // the field names being stable; a struct rename would break them. var back HealthResponse if err := json.Unmarshal(out, &back); err != nil { t.Fatalf("unmarshal: %v", err) } if back != hr { t.Errorf("round-trip got %#v, want %#v", back, hr) } } // TestHealthHandler_Behavior reconstructs the /health handler's // behavior in isolation — same wiring as Run uses, exercised via // httptest.Server. Confirms the JSON shape AND the Content-Type // header AND the service-name echo are all stable. func TestHealthHandler_Behavior(t *testing.T) { r := chi.NewRouter() r.Use(middleware.RequestID) const svcName = "probe-svc" r.Get("/health", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(HealthResponse{Status: "ok", Service: svcName}) }) srv := httptest.NewServer(r) defer srv.Close() resp, err := http.Get(srv.URL + "/health") if err != nil { t.Fatalf("GET /health: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("status = %d, want 200", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { t.Errorf("Content-Type = %q, want application/json prefix", ct) } var got HealthResponse if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { t.Fatalf("decode body: %v", err) } if got.Status != "ok" || got.Service != svcName { t.Errorf("body = %+v, want {Status:ok Service:%s}", got, svcName) } } // TestRegisterRoutes_CallbackInvoked verifies that the per-service // register callback receives a chi.Router we can mount routes on. // This is the contract every cmd//main.go relies on. func TestRegisterRoutes_CallbackInvoked(t *testing.T) { called := false var capturedRouter chi.Router cb := RegisterRoutes(func(r chi.Router) { called = true capturedRouter = r r.Get("/extra", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("extra-route")) }) }) r := chi.NewRouter() cb(r) if !called { t.Fatal("RegisterRoutes callback was not invoked") } if capturedRouter == nil { t.Fatal("callback received nil router") } // Verify the route mounted via the callback is reachable. srv := httptest.NewServer(r) defer srv.Close() resp, err := http.Get(srv.URL + "/extra") if err != nil { t.Fatalf("GET /extra: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("status = %d, want 200", resp.StatusCode) } } // TestRun_BindFailureSurfacedSynchronously is the audit's deepest // concern about server.go: bind errors must come back as Run's // return value, not be swallowed by the goroutine. We verify by // occupying a port first, then expect the second Run call (via the // listener factory) to fail loudly. func TestRun_BindFailureSurfacedSynchronously(t *testing.T) { occupier, err := newListener("127.0.0.1:0") if err != nil { t.Fatalf("setup listener: %v", err) } defer occupier.Close() // We don't call Run() directly because it blocks on signal; we // test the synchronous-error path by calling newListener with the // same addr — which is exactly what Run does first thing. _, err = newListener(occupier.Addr().String()) if err == nil { t.Fatal("expected bind error on occupied port, got nil") } // Smoke that this is a "real" net error, not e.g. nil pointer. var opErr *net.OpError if !errors.As(err, &opErr) { t.Errorf("expected *net.OpError, got %T", err) } }