Files
dance-lessons-coach/pkg/server/v2_gate_test.go
Gabriel Radureau 7fef564ba9 feat(server): api.v2_enabled hot-reload via middleware gate (ADR-0023 Phase 4)
Closes ADR-0023 — all 4 phases now shipped. Final field: api.v2_enabled.

Approach: always-register-with-middleware-gate.

- /api/v2/* routes are now registered UNCONDITIONALLY at startup
- new Server.v2EnabledGate middleware reads the live config on every
  request and returns 404 + {"error":"not_found","message":"v2 API is
  currently disabled"} when api.v2_enabled is false
- the existing Config.WatchAndApply hot-reload pipeline already keeps
  the config struct fresh — no extra plumbing needed
- flag flip takes effect on the NEXT request (not in-flight ones)
- no router rebuild, no restart

Tested via 3 unit tests in pkg/server/v2_gate_test.go:
- blocked-when-disabled: 404 + correct error message + JSON content-type
- passes-when-enabled: NOT 404, gate message ABSENT (handler executed)
- hot-reload-mid-life: same Server, same router, config flipped between
  two requests → 404 then 200, proves the gate reads live config

Race detector clean. Full BDD suite green. ADR-0023 status promoted to
"Implemented" (no more parenthetical phase tracking).

The original "deferred" rationale in ADR-0023 listed router refactor as
the cost. Turns out the cost was minimal: ~25 lines of middleware + 3
tests + an `if` block deletion.
2026-05-05 10:34:43 +02:00

85 lines
3.3 KiB
Go

package server
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"dance-lessons-coach/pkg/config"
"github.com/stretchr/testify/assert"
)
// TestV2EnabledGate_BlocksWhenDisabled verifies the ADR-0023 Phase 4
// hot-reload security property: when api.v2_enabled is false, ANY request
// to /api/v2/* returns 404 with a JSON body, not a 200, not a panic.
func TestV2EnabledGate_BlocksWhenDisabled(t *testing.T) {
cfg := &config.Config{}
cfg.API.V2Enabled = false // explicit, even though it is the zero value
s := NewServer(cfg, context.Background())
req := httptest.NewRequest(http.MethodPost, "/api/v2/greet", strings.NewReader(`{"name":"world"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code, "v2 disabled should 404")
assert.Contains(t, w.Body.String(), "v2 API is currently disabled",
"response should explain why")
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
}
// TestV2EnabledGate_PassesWhenEnabled verifies the gate lets requests
// through to the actual v2 handler when api.v2_enabled is true. We use
// a v2 endpoint that exists and responds with a 2xx so we can assert
// "got past the gate, hit the handler".
func TestV2EnabledGate_PassesWhenEnabled(t *testing.T) {
cfg := &config.Config{}
cfg.API.V2Enabled = true
s := NewServer(cfg, context.Background())
req := httptest.NewRequest(http.MethodPost, "/api/v2/greet", strings.NewReader(`{"name":"world"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
// 200 = v2 handler executed. Anything other than 404 with the gate's
// message proves the gate let the request through.
assert.NotEqual(t, http.StatusNotFound, w.Code, "v2 enabled should not return 404 from gate")
assert.NotContains(t, w.Body.String(), "v2 API is currently disabled",
"gate message must NOT appear when enabled")
}
// TestV2EnabledGate_HotReloadEffect simulates the ADR-0023 Phase 4
// scenario: the same Server (same router) sees opposite responses
// before and after a config flip — proving the gate reads the live
// config rather than a snapshot from setupRoutes.
func TestV2EnabledGate_HotReloadEffect(t *testing.T) {
cfg := &config.Config{}
cfg.API.V2Enabled = false
s := NewServer(cfg, context.Background())
// Round 1: disabled
req1 := httptest.NewRequest(http.MethodPost, "/api/v2/greet", strings.NewReader(`{"name":"a"}`))
req1.Header.Set("Content-Type", "application/json")
w1 := httptest.NewRecorder()
s.router.ServeHTTP(w1, req1)
assert.Equal(t, http.StatusNotFound, w1.Code, "round 1 (disabled) should 404")
// Flip the config. In production, Config.WatchAndApply does this on
// file change; here we set the field directly to simulate the result.
cfg.API.V2Enabled = true
// Round 2: enabled — same Server, same router, just the config flipped
req2 := httptest.NewRequest(http.MethodPost, "/api/v2/greet", strings.NewReader(`{"name":"b"}`))
req2.Header.Set("Content-Type", "application/json")
w2 := httptest.NewRecorder()
s.router.ServeHTTP(w2, req2)
assert.NotEqual(t, http.StatusNotFound, w2.Code, "round 2 (enabled) should NOT 404")
assert.NotContains(t, w2.Body.String(), "v2 API is currently disabled")
}