mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
test(http-client): mock TuneIn upstream so playback tests run offline (refs #451)
Make the BMX TuneIn integration tests independent of the live TuneIn (radiotime.com) service, the same way Spotify/Amazon are already mocked. - pkg/service/bmx: the TuneIn upstream base URLs become configurable vars with a SetTuneInEndpoints(opmlBase, apiBase) setter that also registers the host in the outbound allowlist. Defaults are unchanged (real radiotime hosts), so production behaviour is identical; tests can redirect to a mock. - cmd/soundtouch-service: new --tunein-opml-url / --tunein-api-url flags (TUNEIN_OPML_URL / TUNEIN_API_URL) wired through to SetTuneInEndpoints. - cmd/mock-tunein + pkg/testutils/tunein: a mock TuneIn server serving Tune.ashx (stream URLs) and describe.ashx (name/logo) with RFC-5737 values; unmocked endpoints 404 so a test needing them fails loudly. - docker-compose.ci.yml: add the tunein-mock service and point the service at it. - tunein_playback_station.http now asserts the mock-served stream URL + name, proving the path is offline. tunein_favorite.http covers the local-only favorite add/remove (202). - TUNEIN-MOCK-MISSING.md lists the upstream captures still needed (episode / navigate / search) before those routes can be mocked + tested. make test-http-client: 61 requests, 0 failed. 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
41fccd6f1c
commit
6efad165f6
@@ -193,6 +193,7 @@ test-http-client:
|
||||
/workdir/get_provider_settings.http \
|
||||
/workdir/tunein_playback_station.http \
|
||||
/workdir/post_tunein_report.http \
|
||||
/workdir/tunein_favorite.http \
|
||||
/workdir/get_orion_station.http \
|
||||
/workdir/get_custom_playback.http \
|
||||
/workdir/get_media_ding.http \
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package main provides a mock TuneIn (radiotime.com) server for testing.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/testutils/tunein"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 8080, "Port to listen on")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
log.Printf("Starting mock TuneIn server on port %d", *port)
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), tunein.NewTuneInHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
@@ -375,6 +376,16 @@ func main() {
|
||||
Usage: "Amazon LWA profile URL (for testing)",
|
||||
EnvVars: []string{"AMAZON_PROFILE_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-opml-url",
|
||||
Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_OPML_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tunein-api-url",
|
||||
Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)",
|
||||
EnvVars: []string{"TUNEIN_API_URL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "tts-provider",
|
||||
Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect",
|
||||
@@ -500,6 +511,12 @@ func main() {
|
||||
initMusicServices(config, server)
|
||||
initTTSService(config, server)
|
||||
|
||||
// Redirect TuneIn upstream calls when overridden (e.g. to a local
|
||||
// mock in integration tests); empty values keep the real hosts.
|
||||
if config.tuneInOpmlURL != "" || config.tuneInAPIURL != "" {
|
||||
bmx.SetTuneInEndpoints(config.tuneInOpmlURL, config.tuneInAPIURL)
|
||||
}
|
||||
|
||||
// Load and set initial DNS discoveries
|
||||
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
|
||||
if err == nil && len(dnsDiscoveries) > 0 {
|
||||
@@ -656,6 +673,8 @@ type serviceConfig struct {
|
||||
amazonRedirectURI string
|
||||
amazonTokenURL string
|
||||
amazonProfileURL string
|
||||
tuneInOpmlURL string
|
||||
tuneInAPIURL string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
ttsProvider string
|
||||
@@ -740,6 +759,8 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
amazonRedirectURI := c.String("amazon-redirect-uri")
|
||||
amazonTokenURL := c.String("amazon-token-url")
|
||||
amazonProfileURL := c.String("amazon-profile-url")
|
||||
tuneInOpmlURL := c.String("tunein-opml-url")
|
||||
tuneInAPIURL := c.String("tunein-api-url")
|
||||
mgmtUsername := c.String("mgmt-username")
|
||||
mgmtPassword := c.String("mgmt-password")
|
||||
ttsProvider := c.String("tts-provider")
|
||||
@@ -785,6 +806,8 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
amazonRedirectURI: amazonRedirectURI,
|
||||
amazonTokenURL: amazonTokenURL,
|
||||
amazonProfileURL: amazonProfileURL,
|
||||
tuneInOpmlURL: tuneInOpmlURL,
|
||||
tuneInAPIURL: tuneInAPIURL,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
ttsProvider: ttsProvider,
|
||||
|
||||
@@ -16,6 +16,8 @@ services:
|
||||
- AMAZON_CLIENT_SECRET=mock-amazon-secret
|
||||
- AMAZON_TOKEN_URL=http://amazon-mock:8080/auth/o2/token
|
||||
- AMAZON_PROFILE_URL=http://amazon-mock:8080/user/profile
|
||||
- TUNEIN_OPML_URL=http://tunein-mock:8080
|
||||
- TUNEIN_API_URL=http://tunein-mock:8080
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.4-alpine
|
||||
@@ -41,6 +43,18 @@ services:
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
|
||||
tunein-mock:
|
||||
image: golang:1.26.4-alpine
|
||||
container_name: tunein-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
command: go run ./cmd/mock-tunein/main.go -port 8080
|
||||
ports:
|
||||
- "8083:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
|
||||
networks:
|
||||
soundtouch-test-net:
|
||||
name: soundtouch-test-net
|
||||
|
||||
+51
-21
@@ -13,23 +13,10 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// TuneIn endpoint templates used to resolve station and stream URLs.
|
||||
// TuneIn endpoint constants. The base URLs themselves are configurable vars
|
||||
// (see tuneInOpmlTuneBase and friends below, set via SetTuneInEndpoints); only
|
||||
// the format-list default is a fixed constant.
|
||||
const (
|
||||
TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s"
|
||||
TuneInNavigateAshx = "http://opml.radiotime.com/?render=json"
|
||||
TuneInSearchAPI = "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query="
|
||||
|
||||
// TuneInProfileContents is the modern JSON API that lists a
|
||||
// program's (`p<N>`) episodes. The legacy OPML endpoints can't —
|
||||
// `Tune.ashx?id=p<N>` returns `#STATUS: 400`, `Browse.ashx?id=p<N>`
|
||||
// only surfaces related genres + networks. Same payload is served
|
||||
// from api.tunein.com and api.radiotime.com; we use radiotime
|
||||
// because TuneInNavigateProfile already navigates there via
|
||||
// Pivots.Contents.Url, so all program-related traffic stays on the
|
||||
// same host that's already in allowedTuneInHosts. See
|
||||
// `_/i226/tunein-api-findings.md` for the full endpoint map.
|
||||
TuneInProfileContents = "https://api.radiotime.com/profiles/%s/contents?version=1.3"
|
||||
|
||||
// DefaultTuneInStreamFormats is the comma-separated format list
|
||||
// AfterTouch sends to TuneIn's Tune.ashx by default. Matches the
|
||||
// pre-2026-05-10 behaviour from before PR #249 added "hls"
|
||||
@@ -53,7 +40,7 @@ func TuneInStream(stationID, formats string) string {
|
||||
formats = DefaultTuneInStreamFormats
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://opml.radiotime.com/Tune.ashx?id=%s&formats=%s", stationID, formats)
|
||||
return fmt.Sprintf("%s/Tune.ashx?id=%s&formats=%s", tuneInOpmlTuneBase, stationID, formats)
|
||||
}
|
||||
|
||||
// allowedTuneInHosts restricts outbound fetches to known TuneIn domains.
|
||||
@@ -62,6 +49,45 @@ var allowedTuneInHosts = map[string]bool{
|
||||
"api.radiotime.com": true,
|
||||
}
|
||||
|
||||
// TuneIn endpoint base URLs. They default to the real TuneIn hosts (matching the
|
||||
// constants above) but can be redirected with SetTuneInEndpoints, e.g. to point
|
||||
// the playback / describe / search calls at a local mock so an integration suite
|
||||
// does not depend on the live TuneIn service. opmlBase covers the
|
||||
// opml.radiotime.com endpoints (Tune.ashx, describe.ashx, navigate); apiBase
|
||||
// covers the api.radiotime.com endpoints (search, profile contents).
|
||||
var (
|
||||
tuneInOpmlTuneBase = "http://opml.radiotime.com"
|
||||
tuneInOpmlDescribeBase = "https://opml.radiotime.com"
|
||||
tuneInOpmlNavigateBase = "http://opml.radiotime.com"
|
||||
tuneInAPIBase = "https://api.radiotime.com"
|
||||
)
|
||||
|
||||
// SetTuneInEndpoints overrides the TuneIn upstream base URLs and registers their
|
||||
// hosts in the outbound allowlist. Empty arguments leave the corresponding
|
||||
// default in place. Intended for tests and local mocks; production leaves the
|
||||
// real TuneIn hosts.
|
||||
func SetTuneInEndpoints(opmlBase, apiBase string) {
|
||||
if opmlBase != "" {
|
||||
b := strings.TrimRight(opmlBase, "/")
|
||||
tuneInOpmlTuneBase = b
|
||||
tuneInOpmlDescribeBase = b
|
||||
tuneInOpmlNavigateBase = b
|
||||
|
||||
if u, err := url.Parse(b); err == nil && u.Hostname() != "" {
|
||||
allowedTuneInHosts[u.Hostname()] = true
|
||||
}
|
||||
}
|
||||
|
||||
if apiBase != "" {
|
||||
b := strings.TrimRight(apiBase, "/")
|
||||
tuneInAPIBase = b
|
||||
|
||||
if u, err := url.Parse(b); err == nil && u.Hostname() != "" {
|
||||
allowedTuneInHosts[u.Hostname()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isTuneInOpmlURI returns true when the URL's host is opml.radiotime.com,
|
||||
// used to select the OPML/ashx parser over the JSON API parser.
|
||||
func isTuneInOpmlURI(rawURL string) bool {
|
||||
@@ -94,7 +120,7 @@ func tuneInRenderJSONURI(rawURL string) string {
|
||||
|
||||
// tuneInSearchURI returns the TuneIn search API URL with the query properly URL-encoded.
|
||||
func tuneInSearchURI(query string) string {
|
||||
return TuneInSearchAPI + url.QueryEscape(query)
|
||||
return tuneInAPIBase + "/profiles?fulltextsearch=true&version=1.3&query=" + url.QueryEscape(query)
|
||||
}
|
||||
|
||||
func fetchJSON(fetchURL string) (map[string]interface{}, error) {
|
||||
@@ -117,7 +143,7 @@ func TuneInNavigate(encodedURI string, subsection *int) (*models.BmxNavResponse,
|
||||
|
||||
tuneInURI = decoded
|
||||
} else {
|
||||
tuneInURI = TuneInNavigateAshx
|
||||
tuneInURI = tuneInOpmlNavigateBase + "/?render=json"
|
||||
templated := true
|
||||
bmxSearchLink = &models.Link{
|
||||
Filters: []interface{}{},
|
||||
@@ -725,7 +751,11 @@ func parseTuneInProgramContents(body []byte, programID string) (episodeID string
|
||||
}
|
||||
|
||||
func resolveTuneInProgramLatestEpisode(programID string) (episodeID string, err error) {
|
||||
fetchURL := fmt.Sprintf(TuneInProfileContents, programID)
|
||||
// The modern JSON API lists a program's (`p<N>`) episodes; the legacy OPML
|
||||
// endpoints can't (`Tune.ashx?id=p<N>` returns `#STATUS: 400`). We use the
|
||||
// api.radiotime.com host (tuneInAPIBase) because TuneInNavigateProfile already
|
||||
// navigates there. See `_/i226/tunein-api-findings.md` for the endpoint map.
|
||||
fetchURL := fmt.Sprintf("%s/profiles/%s/contents?version=1.3", tuneInAPIBase, programID)
|
||||
|
||||
resp, err := defaultClient.Get(fetchURL)
|
||||
if err != nil {
|
||||
@@ -748,7 +778,7 @@ func resolveTuneInProgramLatestEpisode(programID string) (episodeID string, err
|
||||
|
||||
// TuneInDescribeMeta fetches the name and logo for a TuneIn guide ID.
|
||||
func TuneInDescribeMeta(id string) (name, logo string, err error) {
|
||||
fetchURL := fmt.Sprintf(TuneInDescribe, id)
|
||||
fetchURL := fmt.Sprintf("%s/describe.ashx?id=%s", tuneInOpmlDescribeBase, id)
|
||||
|
||||
resp, err := defaultClient.Get(fetchURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -91,7 +91,9 @@ func TestTuneInSearchURI(t *testing.T) {
|
||||
{
|
||||
name: "plain query is appended to base URL",
|
||||
query: "jazz",
|
||||
check: func(u string) bool { return u == TuneInSearchAPI+"jazz" },
|
||||
check: func(u string) bool {
|
||||
return u == "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query=jazz"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Package tunein provides shared handlers for mocking the TuneIn
|
||||
// (radiotime.com) upstream API, so integration tests for the BMX TuneIn
|
||||
// endpoints do not depend on the live TuneIn service.
|
||||
//
|
||||
// It covers the OPML endpoints the service calls for station playback:
|
||||
// - GET /Tune.ashx?id=<guideID>&formats=... -> stream URLs (JSON body[])
|
||||
// - GET /describe.ashx?id=<guideID> -> station name + logo (OPML XML)
|
||||
//
|
||||
// Responses use only documentation-safe values (RFC-5737 192.0.2.0/24 hosts).
|
||||
// Endpoints that are not yet mocked (navigate, search, profile contents) return
|
||||
// 404 so a test that needs them fails loudly and we know to add a fixture; see
|
||||
// tests/integration/http-client/TUNEIN-MOCK-MISSING.md.
|
||||
package tunein
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// NewTuneInHandler returns an http.Handler configured with the mocked TuneIn
|
||||
// OPML endpoints.
|
||||
func NewTuneInHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/Tune.ashx", HandleTune)
|
||||
mux.HandleFunc("/describe.ashx", HandleDescribe)
|
||||
mux.HandleFunc("/", HandleCatchAll)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// HandleTune simulates TuneIn's Tune.ashx stream-resolution endpoint. The
|
||||
// service parses the JSON body[] array for {url} entries
|
||||
// (bmx.parseTuneInStreamBody); we return two documentation-safe stream URLs so
|
||||
// the multi-stream failover path is exercised too.
|
||||
func HandleTune(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
log.Printf("[TuneIn Mock] Tune.ashx id=%s formats=%s", sanitizeLog(id), sanitizeLog(r.URL.Query().Get("formats")))
|
||||
|
||||
if id == "" {
|
||||
http.Error(w, `{"head":{"status":"400"}}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(`{"head":{"status":"200"},"body":[`+
|
||||
`{"url":"http://192.0.2.20:8000/%s/stream-1.mp3","media_type":"mp3","reliability":99,"bitrate":128,"is_direct":true},`+
|
||||
`{"url":"http://192.0.2.20:8000/%s/stream-2.mp3","media_type":"mp3","reliability":95,"bitrate":128,"is_direct":true}`+
|
||||
`]}`, id, id)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
|
||||
// HandleDescribe simulates TuneIn's describe.ashx metadata endpoint. The service
|
||||
// reads the first <outline> element's text + image attributes
|
||||
// (bmx.TuneInDescribeMeta).
|
||||
func HandleDescribe(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
log.Printf("[TuneIn Mock] describe.ashx id=%s", sanitizeLog(id))
|
||||
|
||||
if id == "" {
|
||||
http.Error(w, "missing id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>`+
|
||||
`<opml version="1">`+
|
||||
`<head><title>%s</title><status>200</status></head>`+
|
||||
`<body><outline type="object" text="Mock Radio %s" image="http://192.0.2.20:8000/%s/logo.png"/></body>`+
|
||||
`</opml>`, id, id, id)
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
|
||||
// HandleCatchAll logs and 404s any TuneIn endpoint that is not mocked yet
|
||||
// (navigate, search, profile contents), making the gap visible to a failing
|
||||
// test rather than silently returning wrong data.
|
||||
func HandleCatchAll(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[TuneIn Mock] UNMOCKED %s %s — add a fixture (see TUNEIN-MOCK-MISSING.md)",
|
||||
sanitizeLog(r.Method), sanitizeLog(r.URL.RequestURI()))
|
||||
http.Error(w, "tunein mock: endpoint not implemented", http.StatusNotFound)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package tunein
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
|
||||
// external APIs may contain attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -60,10 +60,10 @@ Legend: ✅ covered · ⬜ gap · 〰️ partial (some status/variant uncovered)
|
||||
| POST | `/alexa/certificate` | 501 (rare 200) | — | ⬜ (edge) |
|
||||
| GET | `/bmx/registry/v1/services` | 200 | `get_bmx_services.http` | ✅ |
|
||||
| POST | `/bmx/tunein/v1/token` | 200 | `tunein_playback_station.http` | ✅ |
|
||||
| GET | `/bmx/tunein/v1/playback/station/{stationID}` | 200, 401 | `tunein_playback_station.http` | ✅ |
|
||||
| GET | `/bmx/tunein/v1/playback/episode(s)/{episodeID}` | 200 | — | ⬜ (TuneIn live) |
|
||||
| POST | `/bmx/tunein/v1/report` | 200 | — | ⬜ |
|
||||
| POST/DELETE | `/bmx/tunein/v1/favorite/{stationID}` | 202 | — | ⬜ (TuneIn live) |
|
||||
| GET | `/bmx/tunein/v1/playback/station/{stationID}` | 200, 401 | `tunein_playback_station.http` | ✅ (offline via mock-tunein) |
|
||||
| GET | `/bmx/tunein/v1/playback/episode(s)/{episodeID}` | 200 | — | ⬜ (needs mock fixture, see TUNEIN-MOCK-MISSING.md) |
|
||||
| POST | `/bmx/tunein/v1/report` | 200 | `post_tunein_report.http` | ✅ |
|
||||
| POST/DELETE | `/bmx/tunein/v1/favorite/{stationID}` | 202 | `tunein_favorite.http` | ✅ (local-only) |
|
||||
| GET | `/core02/svc-bmx-adapter-orion/prod/orion/station` | 200 | — | ⬜ |
|
||||
| GET | `/custom/v1/playback/{encodedURL}` | 200 | — | ⬜ |
|
||||
| GET | `/media/aftertouch-ding.wav` | 200 (binary) | — | ⬜ |
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# TuneIn mock: missing upstream fixtures
|
||||
|
||||
The `mock-tunein` service (`cmd/mock-tunein`, `pkg/testutils/tunein`) lets the
|
||||
integration suite exercise the BMX TuneIn endpoints without hitting the live
|
||||
TuneIn (radiotime.com) service. The service is pointed at it in CI via
|
||||
`TUNEIN_OPML_URL` / `TUNEIN_API_URL` (see `docker-compose.ci.yml`).
|
||||
|
||||
## Mocked today (hermetic)
|
||||
|
||||
- `GET /Tune.ashx?id=...&formats=...` (station stream resolution)
|
||||
- `GET /describe.ashx?id=...` (station name + logo)
|
||||
|
||||
These back `GET /bmx/tunein/v1/playback/station/{stationID}`
|
||||
(`tunein_playback_station.http`), which is therefore fully offline.
|
||||
|
||||
Local-only BMX routes (no upstream at all, already hermetic):
|
||||
`/bmx/tunein/v1/favorite/{id}` (POST/DELETE), `/bmx/tunein/v1/token`,
|
||||
`/bmx/tunein/v1/report`.
|
||||
|
||||
## NOT mocked yet — need real upstream captures
|
||||
|
||||
The following BMX TuneIn routes call `api.radiotime.com` / `opml.radiotime.com`
|
||||
endpoints we do not have recorded upstream responses for. The mock returns 404
|
||||
for these (so a test that needs them fails loudly). To cover them we need to
|
||||
capture the **raw radiotime responses** (service -> TuneIn), not just the final
|
||||
BMX responses the speaker received:
|
||||
|
||||
| BMX route (speaker-facing) | Upstream call the service makes | Needed capture |
|
||||
|----------------------------|----------------------------------|----------------|
|
||||
| `GET /bmx/tunein/v1/playback/episode/{id}` | `GET api.radiotime.com/profiles/{p<N>}/contents?version=1.3` then `Tune.ashx?id=<episode>` | `profiles/<id>/contents` JSON |
|
||||
| `GET /bmx/tunein/v1/playback/episodes/{id}` | same profile-contents JSON | `profiles/<id>/contents` JSON |
|
||||
| `GET /bmx/tunein/v1/navigate` | `GET opml.radiotime.com/?render=json` (and `Browse.ashx`) | OPML-as-JSON browse pages |
|
||||
| `GET /bmx/tunein/v1/search` | `GET api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query=...` | search-results JSON |
|
||||
| `GET /bmx/tunein/v1/search/next` | opaque cursor URL from a prior search | search next-page JSON |
|
||||
|
||||
### How to collect
|
||||
|
||||
Run the service against live TuneIn with `RECORD_INTERACTIONS` on and the
|
||||
proxy/recorder capturing the **upstream** category, drive the speaker (or the
|
||||
`/api/tunein/*` UI) through podcast playback / browse / search, then copy the
|
||||
sanitized radiotime responses here. Once captured, extend
|
||||
`pkg/testutils/tunein/handlers.go` with the matching `/profiles*`, `/?render=json`
|
||||
handlers and add the corresponding `.http` flows.
|
||||
|
||||
Keep captured fixtures sanitized (no real account ids/tokens); the radiotime
|
||||
station/program ids themselves are public catalog ids and are fine to keep.
|
||||
@@ -0,0 +1,30 @@
|
||||
### POST /bmx/tunein/v1/favorite/{stationID} (mark a TuneIn station as favorite)
|
||||
###
|
||||
### Frozen BMX route. The speaker favorites/unfavorites a TuneIn station; the
|
||||
### service persists it locally and acknowledges with 202 + empty object
|
||||
### (HandleTuneInFavorite). Local datastore only, no upstream TuneIn call.
|
||||
POST {{host}}/bmx/tunein/v1/favorite/s166521
|
||||
User-Agent: Bose_Lisa/27.0.6
|
||||
Accept: */*
|
||||
X-Bmx-Api-Key: {{bmxApiKey}}
|
||||
Content-Type: application/json
|
||||
|
||||
> {%
|
||||
client.test("Favorite accepted (202)", function () {
|
||||
client.assert(response.status === 202, "Response status is not 202, got " + response.status);
|
||||
client.assert(response.contentType.mimeType === "application/json",
|
||||
"Expected application/json, got '" + response.contentType.mimeType + "'");
|
||||
});
|
||||
%}
|
||||
|
||||
### DELETE /bmx/tunein/v1/favorite/{stationID} (remove the favorite)
|
||||
DELETE {{host}}/bmx/tunein/v1/favorite/s166521
|
||||
User-Agent: Bose_Lisa/27.0.6
|
||||
Accept: */*
|
||||
X-Bmx-Api-Key: {{bmxApiKey}}
|
||||
|
||||
> {%
|
||||
client.test("Favorite removal accepted (202)", function () {
|
||||
client.assert(response.status === 202, "Response status is not 202, got " + response.status);
|
||||
});
|
||||
%}
|
||||
@@ -1,12 +1,18 @@
|
||||
### GET /bmx/tunein/v1/playback/station/_station_ (no Authorization)
|
||||
### GET /bmx/tunein/v1/playback/station/{stationID} (no Authorization)
|
||||
###
|
||||
### Frozen BMX route. Resolving a TuneIn station goes upstream to TuneIn's
|
||||
### Tune.ashx (stream URLs) + describe.ashx (name/logo); in CI those calls are
|
||||
### served by mock-tunein (TUNEIN_OPML_URL, see docker-compose.ci.yml), so this
|
||||
### test is fully offline. The mock returns RFC-5737 stream URLs keyed by the
|
||||
### station id, which we assert below to prove the response came from the mock.
|
||||
###
|
||||
### Auth gate temporarily disabled — see handlers_bmx.go (writeBMXUnauthorized
|
||||
### is kept as the future-restore point). When the gate is re-enabled,
|
||||
### swap the 200/audio assertions below back to the 401/Unauthorized ones
|
||||
### that were here historically.
|
||||
GET {{host}}/bmx/tunein/v1/playback/station/_station_
|
||||
### is kept as the future-restore point). When the gate is re-enabled, swap the
|
||||
### 200/audio assertions below back to the 401/Unauthorized ones.
|
||||
GET {{host}}/bmx/tunein/v1/playback/station/s166521
|
||||
Accept: */*
|
||||
Accept-Language: en
|
||||
X-Bmx-Api-Key: bmx-api-key-dummy
|
||||
X-Bmx-Api-Key: {{bmxApiKey}}
|
||||
X-Bmx-Device-Id: bmx-device-id-dummy
|
||||
User-Agent: Bose_Lisa/27.0.6
|
||||
|
||||
@@ -15,18 +21,23 @@ User-Agent: Bose_Lisa/27.0.6
|
||||
client.assert(response.status === 200, "Response status is not 200");
|
||||
});
|
||||
|
||||
client.test("Response contains audio information", function() {
|
||||
client.test("Response carries the mock-served stream (offline)", function() {
|
||||
client.assert(response.body.hasOwnProperty("audio"), "Response missing 'audio'");
|
||||
client.assert(response.body.audio.hasOwnProperty("streamUrl"), "Response missing 'streamUrl'");
|
||||
client.assert(response.body.audio.streamUrl === "http://192.0.2.20:8000/s166521/stream-1.mp3",
|
||||
"audio.streamUrl should be the mock stream, got '" + response.body.audio.streamUrl + "'");
|
||||
client.assert(response.body.audio.streams.length === 2,
|
||||
"expected 2 failover streams from the mock, got " + response.body.audio.streams.length);
|
||||
client.assert(response.body.name === "Mock Radio s166521",
|
||||
"name should come from the mock describe.ashx, got '" + response.body.name + "'");
|
||||
});
|
||||
%}
|
||||
|
||||
### POST /bmx/tunein/v1/token
|
||||
### POST /bmx/tunein/v1/token (local canned token, no upstream)
|
||||
POST {{host}}/bmx/tunein/v1/token
|
||||
User-Agent: Bose_Lisa/27.0.6
|
||||
Accept: */*
|
||||
Accept-Language: en
|
||||
X-Bmx-Api-Key: bmx-api-key-dummy
|
||||
X-Bmx-Api-Key: {{bmxApiKey}}
|
||||
X-Bmx-Device-Id: bmx-device-id-dummy
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -45,13 +56,13 @@ Content-Type: application/json
|
||||
client.global.set("bmx_access_token", response.body.access_token);
|
||||
%}
|
||||
|
||||
### GET /bmx/tunein/v1/playback/station/_station_ (Authorized)
|
||||
GET {{host}}/bmx/tunein/v1/playback/station/_station_
|
||||
### GET /bmx/tunein/v1/playback/station/{stationID} (Authorized)
|
||||
GET {{host}}/bmx/tunein/v1/playback/station/s166521
|
||||
User-Agent: Bose_Lisa/27.0.6
|
||||
Accept: */*
|
||||
Accept-Language: en
|
||||
Authorization: {{bmx_access_token}}
|
||||
X-Bmx-Api-Key: bmx-api-key-dummy
|
||||
X-Bmx-Api-Key: {{bmxApiKey}}
|
||||
X-Bmx-Device-Id: bmx-device-id-dummy
|
||||
|
||||
> {%
|
||||
@@ -59,8 +70,9 @@ X-Bmx-Device-Id: bmx-device-id-dummy
|
||||
client.assert(response.status === 200, "Response status is not 200");
|
||||
});
|
||||
|
||||
client.test("Response contains audio information", function() {
|
||||
client.test("Response carries the mock-served stream (offline)", function() {
|
||||
client.assert(response.body.hasOwnProperty("audio"), "Response missing 'audio'");
|
||||
client.assert(response.body.audio.hasOwnProperty("streamUrl"), "Response missing 'streamUrl'");
|
||||
client.assert(response.body.audio.streamUrl === "http://192.0.2.20:8000/s166521/stream-1.mp3",
|
||||
"audio.streamUrl should be the mock stream, got '" + response.body.audio.streamUrl + "'");
|
||||
});
|
||||
%}
|
||||
|
||||
Reference in New Issue
Block a user