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:
Tobias Gesellchen
2026-06-06 19:11:24 +02:00
co-authored by Claude Opus 4.8
parent 41fccd6f1c
commit 6efad165f6
12 changed files with 319 additions and 41 deletions
+1
View File
@@ -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 \
+23
View File
@@ -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)
}
}
+23
View File
@@ -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,
+14
View File
@@ -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
View File
@@ -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 {
+3 -1
View File
@@ -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"
},
},
}
+84
View File
@@ -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)
}
+13
View File
@@ -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
}
+4 -4
View File
@@ -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 + "'");
});
%}