Files
Tobias GesellchenandClaude Opus 4.8 3693cfa65b refactor(web): make the web surface self-contained for embedding (refs #451)
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>
2026-06-07 15:02:54 +02:00

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)
}
}
}