mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
Prepare soundtouch-web to be folded into soundtouch-service as an additive mount. Two changes, no behaviour change for the standalone binary: - Move the embedded assets from /static/* to /app/static/*, so the whole web UI lives under /api/control + /app and nothing contends with a host router's own /static (e.g. the optional Stockholm bridge's root catch-all). index.html and app.js asset references are updated in lockstep. - Split Mount into a portable core and a standalone wrapper. MountWeb registers only the portable surface (/app/static/*, /api/control/*, /app/*) and nothing outside those subtrees (no /, no /health), so it can be mounted into another router additively. Mount (used by cmd/soundtouch-web) now calls MountWeb and adds the standalone-only /health and /->/app redirect. mount_test.go exercises MountWeb (asserts the portable surface owns nothing outside /api/control + /app) and Mount (asserts it adds / and /health). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
144 lines
4.0 KiB
Go
144 lines
4.0 KiB
Go
package soundtouchweb
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// walkRoutes returns the set of route patterns registered on r.
|
|
func walkRoutes(t *testing.T, r chi.Router) map[string]bool {
|
|
t.Helper()
|
|
|
|
routes := map[string]bool{}
|
|
|
|
err := chi.Walk(r, func(_, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
|
routes[route] = true
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("walk routes: %v", err)
|
|
}
|
|
|
|
return routes
|
|
}
|
|
|
|
// TestMountWebControlAPIShape verifies the issue #451 web API restructure on
|
|
// the portable surface (MountWeb): building must not panic (catches any chi
|
|
// route-registration ambiguity), and every web /api/* route must live under
|
|
// /api/control/* (the post-merge canonical namespace).
|
|
func TestMountWebControlAPIShape(t *testing.T) {
|
|
app := NewWebApp()
|
|
|
|
r := chi.NewRouter()
|
|
app.MountWeb(r, nil) // must not panic while registering routes
|
|
|
|
registered := walkRoutes(t, r)
|
|
|
|
var apiRoutes []string
|
|
|
|
for route := range registered {
|
|
if strings.HasPrefix(route, "/api/") {
|
|
apiRoutes = append(apiRoutes, route)
|
|
}
|
|
}
|
|
|
|
if len(apiRoutes) == 0 {
|
|
t.Fatal("no /api/* routes registered")
|
|
}
|
|
|
|
// Invariant: the whole web API is under /api/control/* (no flat /api/devices,
|
|
// /api/zone, /api/control/{id}/{action}, ... left behind).
|
|
for _, route := range apiRoutes {
|
|
if !strings.HasPrefix(route, "/api/control/") {
|
|
t.Errorf("web API route %q is not under /api/control/ after the #451 restructure", route)
|
|
}
|
|
}
|
|
|
|
// The provider infix (#451): browsable providers expose global browse
|
|
// routes; every provider play nests under devices/{id}/providers/. The
|
|
// app-wide socket moved from top-level /ws to /api/control/ws, and assets
|
|
// live under /app/static.
|
|
mustExist := []string{
|
|
"/app/static/*",
|
|
"/api/control/version",
|
|
"/api/control/ws",
|
|
"/api/control/providers/tunein/search",
|
|
"/api/control/providers/radiobrowser/search",
|
|
"/api/control/devices/{id}/providers/tunein/play",
|
|
"/api/control/devices/{id}/providers/radiobrowser/play",
|
|
"/api/control/devices/{id}/providers/url/play",
|
|
"/api/control/devices/{id}/providers/tts/play",
|
|
}
|
|
for _, want := range mustExist {
|
|
if !registered[want] {
|
|
t.Errorf("expected route %q to be registered", want)
|
|
}
|
|
}
|
|
|
|
// The pre-infix flat paths are gone, the app-wide socket no longer sits at
|
|
// top-level /ws, and the portable surface owns nothing outside
|
|
// /api/control + /app (no /, no /health, no top-level /static).
|
|
mustNotExist := []string{
|
|
"/",
|
|
"/ws",
|
|
"/health",
|
|
"/static/*",
|
|
"/api/control/tunein/search",
|
|
"/api/control/radiobrowser/search",
|
|
"/api/control/devices/{id}/play-url",
|
|
"/api/control/devices/{id}/speak",
|
|
"/api/control/devices/{id}/tunein/play",
|
|
"/api/control/devices/{id}/radiobrowser/play",
|
|
}
|
|
for _, gone := range mustNotExist {
|
|
if registered[gone] {
|
|
t.Errorf("portable surface should not register %q", gone)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMountWebSPARoutes verifies the issue #451 SPA move: the web UI is served
|
|
// under /app/* and the old top-level page paths are gone. The portable surface
|
|
// does not register /.
|
|
func TestMountWebSPARoutes(t *testing.T) {
|
|
app := NewWebApp()
|
|
|
|
r := chi.NewRouter()
|
|
app.MountWeb(r, nil)
|
|
|
|
routes := walkRoutes(t, r)
|
|
|
|
for _, want := range []string{"/app", "/app/devices", "/app/tunein"} {
|
|
if !routes[want] {
|
|
t.Errorf("expected SPA route %q under /app to be registered", want)
|
|
}
|
|
}
|
|
|
|
for _, gone := range []string{"/", "/devices", "/device/*", "/tunein", "/radiobrowser", "/playurl", "/tts"} {
|
|
if routes[gone] {
|
|
t.Errorf("top-level route %q should not be registered by the portable surface", gone)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMountStandalone verifies that the standalone entry point (Mount) adds the
|
|
// / redirect and /health liveness endpoint on top of the portable surface.
|
|
func TestMountStandalone(t *testing.T) {
|
|
app := NewWebApp()
|
|
|
|
r := chi.NewRouter()
|
|
app.Mount(r, nil)
|
|
|
|
routes := walkRoutes(t, r)
|
|
|
|
for _, want := range []string{"/", "/health", "/app", "/api/control/version"} {
|
|
if !routes[want] {
|
|
t.Errorf("expected standalone Mount to register %q", want)
|
|
}
|
|
}
|
|
}
|