feat(server): /api/info aggregator + frontend version footer

Sprint 2 of autonomous trainer day 2026-05-05. Mistral-implemented
through ICM workspace ship-info-aggregator (bootstrapped backend +
BDD before hitting price limit at stage 02), Claude-completed for
frontend + Playwright + verifier + PR.

Backend:
- GET /api/info aggregator returning version, commit_short, build_date,
  uptime_seconds, cache_enabled, healthz_status (single round trip)
- Optional cache via existing cache service (X-Cache: HIT/MISS)
- BDD scenario @critical covers happy path + version regex; cache
  scenario kept under @skip @bdd-deferred until BDD harness gains a
  cache-enabled mode

Frontend:
- AppFooterView (dumb) + AppFooter (smart wrapper, useFetch) following
  the HealthDashboard / HealthDashboardView pattern
- layouts/default.vue auto-applied via NuxtLayout in app.vue
- humaniseUptime helper in utils/
- Playwright tests use route.fulfill mocking (decoupled from dev-proxy
  infra), assert visible AND content (PR #32 lesson)

Docs:
- documentation/API.md /api/info entry with schema and rationale
- ADR-0026 documents composite endpoint vs separate calls choice

Verifier verdict (skill-driven, audit at stage 04): APPROVE_WITH_NITS.
Nits: handleInfo is 51 lines (could split into builder + emitter);
X-Cache: DISABLED could improve ops clarity.

Out-of-scope follow-up: existing tests/e2e/health.spec.ts happy path
hits the same dev-proxy infra issue as my footer happy path before
mocking. Same fix (server: false + route.fulfill) would apply.
This commit is contained in:
2026-05-05 08:28:00 +02:00
parent 4a3f1bb138
commit 4d2e0c1a42
16 changed files with 587 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ package steps
import (
"fmt"
"regexp"
"strings"
"dance-lessons-coach/pkg/bdd/testserver"
@@ -99,3 +100,69 @@ func (s *CommonSteps) theFieldShouldEqual(field, expectedValue string) error {
}
return nil
}
// Regex field matching
func (s *CommonSteps) theFieldShouldMatch(field, pattern string) error {
body := string(s.client.GetLastBody())
// Extract the value of the field from JSON
// Look for "field":"value" and extract value
fieldPattern := `"` + field + `":"([^"]*)"`
re := regexp.MustCompile(fieldPattern)
matches := re.FindStringSubmatch(body)
if matches == nil {
// Try without quotes (for numbers)
fieldPatternNum := `"` + field + `":(\d+\.?\d*)`
reNum := regexp.MustCompile(fieldPatternNum)
matches = reNum.FindStringSubmatch(body)
if matches == nil {
return fmt.Errorf("field %q not found in response: %s", field, body)
}
}
// matches[1] contains the value
value := matches[1]
// Compile and match the pattern
regex, err := regexp.Compile(pattern)
if err != nil {
return fmt.Errorf("invalid regex pattern %q: %v", pattern, err)
}
if !regex.MatchString(value) {
return fmt.Errorf("field %q value %q does not match pattern %q", field, value, pattern)
}
return nil
}
// Response is JSON check
func (s *CommonSteps) theResponseShouldBeJSON() error {
body := string(s.client.GetLastBody())
// Simple check for JSON structure
body = strings.TrimSpace(body)
if !strings.HasPrefix(body, "{") && !strings.HasPrefix(body, "[") {
return fmt.Errorf("response is not JSON: %s", body)
}
return nil
}
// Response contains field (simple string containment in body)
func (s *CommonSteps) theResponseShouldContain(field string) error {
body := string(s.client.GetLastBody())
if !strings.Contains(body, `"`+field+`"`) {
return fmt.Errorf("response does not contain field %q: %s", field, body)
}
return nil
}
// Response header validation
func (s *CommonSteps) theResponseHeader(header, expectedValue string) error {
resp := s.client.GetLastResponse()
if resp == nil {
return fmt.Errorf("no response captured for header check")
}
headerValue := resp.Header.Get(header)
if headerValue != expectedValue {
return fmt.Errorf("header %q expected %q, got %q", header, expectedValue, headerValue)
}
return nil
}

View File

@@ -28,7 +28,19 @@ func (s *HealthSteps) iRequestTheHealthzEndpoint() error {
return s.client.Request("GET", "/api/healthz", nil)
}
func (s *HealthSteps) iRequestTheInfoEndpoint() error {
return s.client.Request("GET", "/api/info", nil)
}
func (s *HealthSteps) iRequestTheInfoEndpointAgain() error {
return s.client.Request("GET", "/api/info", nil)
}
func (s *HealthSteps) theServerIsRunning() error {
// Actually verify the server is running by checking the readiness endpoint
return s.client.Request("GET", "/api/ready", nil)
}
func (s *HealthSteps) theServerIsRunningWithCacheEnabled() error {
return s.client.Request("GET", "/api/ready", nil)
}

View File

@@ -89,6 +89,9 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client, s
// Health steps
ctx.Step(`^I request the health endpoint$`, sc.healthSteps.iRequestTheHealthEndpoint)
ctx.Step(`^I request the healthz endpoint$`, sc.healthSteps.iRequestTheHealthzEndpoint)
ctx.Step(`^I request the info endpoint$`, sc.healthSteps.iRequestTheInfoEndpoint)
ctx.Step(`^I request the info endpoint again$`, sc.healthSteps.iRequestTheInfoEndpointAgain)
ctx.Step(`^the server is running with cache enabled$`, sc.healthSteps.theServerIsRunningWithCacheEnabled)
ctx.Step(`^the server is running$`, sc.healthSteps.theServerIsRunning)
// Auth steps
@@ -314,4 +317,8 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client, s
ctx.Step(`^the status code should be (\d+)$`, sc.commonSteps.theStatusCodeShouldBe)
ctx.Step(`^the response should be JSON with fields "([^"]*)"$`, sc.commonSteps.theResponseShouldBeJSONWithFields)
ctx.Step(`^the "([^"]*)" field should equal "([^"]*)"$`, sc.commonSteps.theFieldShouldEqual)
ctx.Step(`^the "([^"]*)" field should match /([^/]+)/$`, sc.commonSteps.theFieldShouldMatch)
ctx.Step(`^the response should be JSON$`, sc.commonSteps.theResponseShouldBeJSON)
ctx.Step(`^the response should contain "([^"]*)"$`, sc.commonSteps.theResponseShouldContain)
ctx.Step(`^the response header "([^"]*)" should be "([^"]*)"$`, sc.commonSteps.theResponseHeader)
}

View File

@@ -171,6 +171,9 @@ func (s *Server) setupRoutes() {
// Kubernetes-style health endpoint at root level
s.router.Get("/api/healthz", s.handleHealthz)
// Info endpoint - composite aggregator
s.router.Get("/api/info", s.handleInfo)
// API routes
s.router.Route("/api/v1", func(r chi.Router) {
r.Use(s.getAllMiddlewares()...)
@@ -436,6 +439,16 @@ type HealthzResponse struct {
Timestamp time.Time `json:"timestamp"`
}
// InfoResponse represents the JSON response for /api/info
type InfoResponse struct {
Version string `json:"version"`
CommitShort string `json:"commit_short"`
BuildDate string `json:"build_date"`
UptimeSeconds int64 `json:"uptime_seconds"`
CacheEnabled bool `json:"cache_enabled"`
HealthzStatus string `json:"healthz_status"`
}
// handleHealthz godoc
//
// @Summary Kubernetes-style health check
@@ -456,6 +469,66 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp)
}
// handleInfo godoc
//
// @Summary Get composite info
// @Description Returns aggregated version, build, uptime, cache, and health info
// @Tags System/Info
// @Produce json
// @Success 200 {object} InfoResponse
// @Router /info [get]
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
log.Trace().Msg("Info endpoint requested")
// Build commit_short from version.Commit (first 8 chars if available)
commitShort := version.Commit
if len(commitShort) > 8 {
commitShort = commitShort[:8]
}
// Build response
resp := InfoResponse{
Version: version.Version,
CommitShort: commitShort,
BuildDate: version.Date,
UptimeSeconds: int64(time.Since(s.startedAt).Seconds()),
CacheEnabled: s.cacheService != nil,
HealthzStatus: "healthy",
}
// Cache key
cacheKey := "info:json"
// Check cache if enabled
if s.cacheService != nil {
if cached, ok := s.cacheService.Get(cacheKey); ok {
log.Trace().Str("cache_key", cacheKey).Msg("Cache hit for info")
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Cache", "HIT")
w.Write([]byte(cached.(string)))
return
}
}
// Marshal response
data, err := json.Marshal(resp)
if err != nil {
http.Error(w, `{"error":"server_error"}`, http.StatusInternalServerError)
return
}
// Cache the response
if s.cacheService != nil {
s.cacheService.Set(cacheKey, string(data),
time.Duration(s.config.GetCacheDefaultTTLSeconds())*time.Second)
w.Header().Set("X-Cache", "MISS")
log.Trace().Str("cache_key", cacheKey).Msg("Cached info response")
}
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
// handleGreetQuery godoc
//
// @Summary Get greeting with cache