diff --git a/Makefile b/Makefile index 69bcf33..b6d33e0 100644 --- a/Makefile +++ b/Makefile @@ -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 \ diff --git a/cmd/mock-tunein/main.go b/cmd/mock-tunein/main.go new file mode 100644 index 0000000..8e22ed2 --- /dev/null +++ b/cmd/mock-tunein/main.go @@ -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) + } +} diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 7fe66e1..5353e36 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -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, diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index 153da09..b79d7e4 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -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 diff --git a/pkg/service/bmx/tunein.go b/pkg/service/bmx/tunein.go index 5ad4740..4b15faa 100644 --- a/pkg/service/bmx/tunein.go +++ b/pkg/service/bmx/tunein.go @@ -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`) episodes. The legacy OPML endpoints can't — - // `Tune.ashx?id=p` returns `#STATUS: 400`, `Browse.ashx?id=p` - // 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`) episodes; the legacy OPML + // endpoints can't (`Tune.ashx?id=p` 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 { diff --git a/pkg/service/bmx/tunein_test.go b/pkg/service/bmx/tunein_test.go index 1620d11..ab07c7a 100644 --- a/pkg/service/bmx/tunein_test.go +++ b/pkg/service/bmx/tunein_test.go @@ -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" + }, }, } diff --git a/pkg/testutils/tunein/handlers.go b/pkg/testutils/tunein/handlers.go new file mode 100644 index 0000000..3645454 --- /dev/null +++ b/pkg/testutils/tunein/handlers.go @@ -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=&formats=... -> stream URLs (JSON body[]) +// - GET /describe.ashx?id= -> 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 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(``+ + ``+ + `%s200`+ + ``+ + ``, 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) +} diff --git a/pkg/testutils/tunein/logutil.go b/pkg/testutils/tunein/logutil.go new file mode 100644 index 0000000..ea3165b --- /dev/null +++ b/pkg/testutils/tunein/logutil.go @@ -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 +} diff --git a/tests/integration/http-client/COVERAGE.md b/tests/integration/http-client/COVERAGE.md index a2f82ab..9324a82 100644 --- a/tests/integration/http-client/COVERAGE.md +++ b/tests/integration/http-client/COVERAGE.md @@ -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) | — | ⬜ | diff --git a/tests/integration/http-client/TUNEIN-MOCK-MISSING.md b/tests/integration/http-client/TUNEIN-MOCK-MISSING.md new file mode 100644 index 0000000..a366dd6 --- /dev/null +++ b/tests/integration/http-client/TUNEIN-MOCK-MISSING.md @@ -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}/contents?version=1.3` then `Tune.ashx?id=` | `profiles//contents` JSON | +| `GET /bmx/tunein/v1/playback/episodes/{id}` | same profile-contents JSON | `profiles//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. diff --git a/tests/integration/http-client/tunein_favorite.http b/tests/integration/http-client/tunein_favorite.http new file mode 100644 index 0000000..abc4d9e --- /dev/null +++ b/tests/integration/http-client/tunein_favorite.http @@ -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); + }); +%} diff --git a/tests/integration/http-client/tunein_playback_station.http b/tests/integration/http-client/tunein_playback_station.http index 0bed8fc..1cde3bb 100644 --- a/tests/integration/http-client/tunein_playback_station.http +++ b/tests/integration/http-client/tunein_playback_station.http @@ -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 + "'"); }); %}