From 51ad72adcf116bcc22472215292369975e53d074 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 6 Jun 2026 15:29:45 +0200 Subject: [PATCH] test(service): add frozen-route contract-coverage guard (refs #451) TestFrozenRouteContractCoverage walks the service router for frozen speaker/app contract routes (the /streaming, /accounts, /customer, /bmx, /core02, /oauth, /custom, /media, /updates, /v1, /alexa, /ced prefixes) and checks each is hit by at least one .http integration test. The set of uncovered frozen routes is golden-filed (testdata/frozen_routes_uncovered.txt), mirroring the existing router_routes.txt pattern: adding a frozen route without a test, or a test that newly covers one, changes the set and fails the guard, forcing a conscious update. This makes COVERAGE.md a machine-checked invariant rather than a doc that can silently drift. Restricted to GET/POST/PUT/DELETE (chi HandleFunc-registered routes otherwise add CONNECT/TRACE/... noise). golangci-lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/soundtouch-service/coverage_guard_test.go | 200 ++++++++++++++++++ .../testdata/frozen_routes_uncovered.txt | 49 +++++ tests/integration/http-client/COVERAGE.md | 8 + 3 files changed, 257 insertions(+) create mode 100644 cmd/soundtouch-service/coverage_guard_test.go create mode 100644 cmd/soundtouch-service/testdata/frozen_routes_uncovered.txt diff --git a/cmd/soundtouch-service/coverage_guard_test.go b/cmd/soundtouch-service/coverage_guard_test.go new file mode 100644 index 0000000..f900767 --- /dev/null +++ b/cmd/soundtouch-service/coverage_guard_test.go @@ -0,0 +1,200 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/handlers" + "github.com/go-chi/chi/v5" +) + +// frozenFirstSegments are the top-level path prefixes that belong to the frozen +// speaker / app contract (category 1a/1b in +// docs/content/docs/architecture/API-ROUTE-LAYOUT.md). Routes under these must +// not change shape across the issue #451 refactor, so each should have at least +// one .http contract test (the suite under tests/integration/http-client/, run +// by `make test-http-client`). Movable surfaces (/setup, /mgmt, /web) and infra +// (/, /health, /docs, /favicon.ico) are intentionally excluded. +var frozenFirstSegments = map[string]bool{ + "streaming": true, + "accounts": true, + "customer": true, + "bmx": true, + "bmx-icons": true, + "core02": true, + "oauth": true, + "custom": true, + "media": true, + "updates": true, + "v1": true, + "alexa": true, + "ced": true, +} + +func coverageFirstSegment(p string) string { + p = strings.TrimPrefix(p, "/") + if i := strings.IndexByte(p, '/'); i >= 0 { + return p[:i] + } + + return p +} + +// patternToRegexp converts a chi route pattern into an anchored regexp: +// `{param}` becomes a single path segment (`[^/]+`) and `*` becomes `.*`. +func patternToRegexp(pattern string) *regexp.Regexp { + var b strings.Builder + + b.WriteString("^") + + for i, seg := range strings.Split(pattern, "/") { + if i > 0 { + b.WriteString("/") + } + + switch { + case seg == "*": + b.WriteString(".*") + case strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}"): + b.WriteString("[^/]+") + default: + b.WriteString(regexp.QuoteMeta(seg)) + } + } + + b.WriteString("$") + + return regexp.MustCompile(b.String()) +} + +// loadHTTPClientRequests extracts (method, path) pairs from every .http file in +// the integration suite. `{{host}}` is stripped (leaving a leading `/`), query +// strings are dropped, and `{{var}}` template segments are left intact (they +// contain no slash, so they match a `[^/]+` route segment). +func loadHTTPClientRequests(t *testing.T, dir string) [][2]string { + t.Helper() + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read http-client dir %s: %v", dir, err) + } + + reqLine := regexp.MustCompile(`^\s*(GET|POST|PUT|DELETE|PATCH|HEAD)\s+(\S+)`) + + var out [][2]string + + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".http") { + continue + } + + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + t.Fatalf("read %s: %v", e.Name(), err) + } + + for _, line := range strings.Split(string(data), "\n") { + m := reqLine.FindStringSubmatch(line) + if m == nil { + continue + } + + url := strings.ReplaceAll(m[2], "{{host}}", "") + if i := strings.IndexByte(url, '?'); i >= 0 { + url = url[:i] + } + + if !strings.HasPrefix(url, "/") { + continue + } + + out = append(out, [2]string{m[1], url}) + } + } + + return out +} + +// TestFrozenRouteContractCoverage enforces that every frozen-contract route the +// service registers is exercised by at least one .http integration test. The +// set of *uncovered* frozen routes is golden-filed: adding a new frozen route +// without a test (or adding a test that newly covers one) changes the set and +// fails this test, forcing a conscious update of the golden file. It is the +// machine-checked companion to tests/integration/http-client/COVERAGE.md. +func TestFrozenRouteContractCoverage(t *testing.T) { + server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true) + r := setupRouter(server, nil) + + httpRequests := loadHTTPClientRequests(t, filepath.Join("..", "..", "tests", "integration", "http-client")) + + // Only the request methods the contract suite actually exercises. Routes + // registered via chi HandleFunc carry every method (CONNECT/TRACE/...); those + // extra verbs are noise for coverage purposes. + meaningfulMethods := map[string]bool{ + http.MethodGet: true, http.MethodPost: true, http.MethodPut: true, http.MethodDelete: true, + } + + var uncovered []string + + walkFunc := func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + if !meaningfulMethods[method] { + return nil + } + + if !frozenFirstSegments[coverageFirstSegment(route)] { + return nil + } + + re := patternToRegexp(route) + for _, req := range httpRequests { + if req[0] == method && re.MatchString(req[1]) { + return nil + } + } + + uncovered = append(uncovered, fmt.Sprintf("%-7s %s", method, route)) + + return nil + } + + if err := chi.Walk(r, walkFunc); err != nil { + t.Fatalf("walk routes: %v", err) + } + + sort.Strings(uncovered) + output := strings.Join(uncovered, "\n") + "\n" + + const goldenPath = "testdata/frozen_routes_uncovered.txt" + + actualPath := "testdata/frozen_routes_uncovered.actual.txt" + if err := os.WriteFile(actualPath, []byte(output), 0644); err != nil { + t.Fatalf("write actual: %v", err) + } + + golden, err := os.ReadFile(goldenPath) + if os.IsNotExist(err) { + if err := os.WriteFile(goldenPath, []byte(output), 0644); err != nil { + t.Fatalf("create golden: %v", err) + } + + t.Logf("created golden %s with %d uncovered frozen routes", goldenPath, len(uncovered)) + + return + } + + if err != nil { + t.Fatalf("read golden: %v", err) + } + + if string(golden) != output { + t.Errorf("Frozen-route contract coverage changed.\n"+ + "A frozen route either lost its .http test or a new one was added without one.\n"+ + "Review and, if intended, update %s from %s.", goldenPath, actualPath) + } +} diff --git a/cmd/soundtouch-service/testdata/frozen_routes_uncovered.txt b/cmd/soundtouch-service/testdata/frozen_routes_uncovered.txt new file mode 100644 index 0000000..dc0b423 --- /dev/null +++ b/cmd/soundtouch-service/testdata/frozen_routes_uncovered.txt @@ -0,0 +1,49 @@ +DELETE /accounts/{account}/group +DELETE /accounts/{account}/group/ +DELETE /accounts/{account}/group/{groupId} +DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter +DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* +DELETE /streaming/account/{account}/group +GET /accounts/{account}/devices +GET /accounts/{account}/devices/{device}/group +GET /accounts/{account}/devices/{device}/group/ +GET /accounts/{account}/devices/{device}/group/member +GET /accounts/{account}/devices/{device}/group/server +GET /accounts/{account}/devices/{device}/recents +GET /accounts/{account}/full +GET /bmx-icons/* +GET /bmx/tunein/v1/navigate +GET /bmx/tunein/v1/navigate/* +GET /bmx/tunein/v1/playback/episode/{podcastID} +GET /bmx/tunein/v1/playback/episodes/{podcastID} +GET /bmx/tunein/v1/search +GET /bmx/tunein/v1/search/next +GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter +GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* +GET /media/tts/{id} +GET /streaming/account/{account}/device/{device}/group +GET /streaming/account/{account}/device/{device}/group/member +GET /streaming/account/{account}/device/{device}/group/server +GET /streaming/account/{account}/device/{device}/recent +GET /streaming/account/{account}/presets +GET /streaming/device_setting/account/{account}/device/{device}/device_settings +POST /accounts/{account}/devices/{device}/recents +POST /accounts/{account}/group +POST /accounts/{account}/group/ +POST /accounts/{account}/group/{groupId} +POST /core02/svc-bmx-adapter-orion/prod/orion/token +POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter +POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* +POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs +POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token +POST /streaming/account/{account}/device/{device} +POST /streaming/account/{account}/device/{device}/presets/{presetNumber} +POST /streaming/account/{account}/group +POST /streaming/account/{account}/group/{groupId} +POST /streaming/device_setting/account/{account}/device/{device}/device_settings +POST /streaming/music/musicprovider/{providerID}/trial/is_eligible +POST /streaming/stats/error +POST /streaming/stats/usage +POST /v1/stapp/{deviceId} +PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter +PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* diff --git a/tests/integration/http-client/COVERAGE.md b/tests/integration/http-client/COVERAGE.md index 3350242..a6a2c30 100644 --- a/tests/integration/http-client/COVERAGE.md +++ b/tests/integration/http-client/COVERAGE.md @@ -24,6 +24,14 @@ Variable path segments are templated: `{stationID}`, `{episodeID}`, `{hash}`, Legend: ✅ covered · ⬜ gap · 〰️ partial (some status/variant uncovered). +**Enforced by:** `TestFrozenRouteContractCoverage` +(`cmd/soundtouch-service/coverage_guard_test.go`). It walks the router for +frozen-contract routes, matches each against the `.http` request lines, and +golden-files the set of *uncovered* frozen routes +(`cmd/soundtouch-service/testdata/frozen_routes_uncovered.txt`). Adding a new +frozen route without a test, or a test that newly covers one, changes that set +and fails the test, so this checklist can't silently drift from the code. + ## Frozen speaker routes | Method | Route | Status(es) observed | Covered by | State |