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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-06 19:11:24 +02:00
co-authored by Claude Opus 4.8
parent 7e0573032c
commit 51ad72adcf
3 changed files with 257 additions and 0 deletions
@@ -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)
}
}
@@ -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/*
@@ -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 |