✨ 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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user