mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Opus 4.8
parent
7e0573032c
commit
51ad72adcf
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user