diff --git a/Makefile b/Makefile index 3cba997..5e44329 100644 --- a/Makefile +++ b/Makefile @@ -178,6 +178,8 @@ test-http-client: /workdir/amazon_registration.http \ /workdir/create_account.http \ /workdir/register_device.http \ + /workdir/post_scmudc_event.http \ + /workdir/get_speaker_auth.http \ /workdir/spotify_full_flow.http \ /workdir/customer_support.http \ /workdir/power_on.http \ @@ -190,6 +192,11 @@ test-http-client: /workdir/post_oauth_token_amazon.http \ /workdir/get_provider_settings.http \ /workdir/tunein_playback_station.http \ + /workdir/post_tunein_report.http \ + /workdir/get_orion_station.http \ + /workdir/get_custom_playback.http \ + /workdir/get_media_ding.http \ + /workdir/get_bmx_icon.http \ /workdir/set_preset_6.http \ /workdir/get_presets.http \ /workdir/delete_preset_6.http \ diff --git a/tests/integration/http-client/COVERAGE.md b/tests/integration/http-client/COVERAGE.md new file mode 100644 index 0000000..96c534b --- /dev/null +++ b/tests/integration/http-client/COVERAGE.md @@ -0,0 +1,98 @@ +# Speaker-contract coverage (.http integration suite) + +This is the coverage checklist for the JetBrains `.http` integration suite +(`make test-http-client`). Its purpose is the regression net described in +`docs/content/docs/architecture/API-ROUTE-LAYOUT.md` ("Regression safety: +contract tests from the frozen recordings"): pin the **frozen speaker contract** +(category 1a/1b routes) so the staged route refactor for issue #451 cannot +silently change the wire. + +## Method + +The inventory below was mined (read-only) from real recorded speaker traffic +(`User-Agent: Bose_Lisa/*` / `Bose/*`, `self/` category) across the local +recording corpus (`_/backup/*`, `tests/integration/testdata/interactions/`). +Recordings are used as a **reference for route + request/response shape only**; +no raw recorded bodies (which carry real ids/IPs/MACs/tokens) are committed. +Authored flows use the RFC-5737 / placeholder values from `http-client.env.json`. + +Variable path segments are templated: `{stationID}`, `{episodeID}`, `{hash}`, +`{encodedURL}`, `{provider}/{file}`. + +Legend: ✅ covered · ⬜ gap · 〰️ partial (some status/variant uncovered). + +## Frozen speaker routes + +| Method | Route | Status(es) observed | Covered by | State | +|--------|-------|--------------------|------------|-------| +| GET | `/streaming/account/{a}/full` | 200, 304 | `get_full_account.http` | 〰️ (304 conditional gap) | +| GET | `/streaming/account/{a}/devices` | 200 | `get_account_devices.http` | ✅ | +| GET | `/streaming/account/{a}/sources` | 200 | `get_account_sources.http` | ✅ | +| GET | `/streaming/account/{a}/presets/all` | 200 | `get_account_presets.http` | ✅ | +| GET | `/streaming/account/{a}/provider_settings` | 200 | `get_provider_settings.http` | ✅ | +| POST | `/streaming/account` (+ `/login`) | 201/200 | `create_account.http` | ✅ | +| POST | `/streaming/account/{a}/device/` | 201 | `register_device.http` | ✅ | +| PUT | `/streaming/account/{a}/device/{d}` | 200, 401 | `rename_device.http` | 〰️ (401 gap) | +| DELETE | `/streaming/account/{a}/device/{d}` | 200 | `unregister_device.http` | ✅ | +| GET | `/streaming/account/{a}/device/{d}/group/` | 200 | `get_group.http` | ✅ | +| GET | `/streaming/account/{a}/device/{d}/presets` | 200, 304 | `get_presets.http` | 〰️ (304 conditional gap) | +| PUT | `/streaming/account/{a}/device/{d}/preset/{n}` | 200 | `set_preset_5/6.http` | ✅ | +| DELETE | `/streaming/account/{a}/device/{d}/preset/{n}` | 200 | `delete_preset_6.http` | ✅ | +| POST | `/streaming/account/{a}/device/{d}/recent` | 201 | `post_recent.http` | ✅ | +| GET | `/streaming/account/{a}/device/{d}/recents` | 200 | `get_recents.http` | ✅ | +| POST | `/streaming/account/{a}/source` | 200 | `set_preset_5.http` | ✅ | +| POST | `/streaming/account/{a}/group/` | 201 | `create_group.http` | ✅ | +| DELETE | `/streaming/account/{a}/group/` | 405 | (method-not-allowed edge) | ⬜ | +| DELETE | `/streaming/account/{a}/group/{id}` | 200 | — | ⬜ | +| GET | `/streaming/device/{d}/streaming_token` | 200 | `get_streaming_token.http` | ✅ | +| GET | `/streaming/software/update/account/{a}` | 200 | `get_software_update.http` | ✅ | +| GET | `/streaming/sourceproviders` | 200 | `get_sourceproviders.http` | ✅ | +| GET | `/streaming/resources/api_versions.xml` | 200 | `get_api_versions.http` | ✅ | +| POST | `/streaming/support/power_on` | 200 | `power_on.http` | ✅ | +| POST | `/streaming/support/customersupport` | 200 | `customer_support.http` | ✅ | +| POST | `/streaming/music/musicprovider/{id}/is_eligible` | 200 | `post_musicprovider_is_eligible.http` | ✅ | +| POST | `/accounts/{a}/devices` | 201 | `register_device.http` | ✅ | +| DELETE | `/accounts/{a}/devices/{d}` | 200 | `unregister_device.http` | ✅ | +| GET | `/updates/soundtouch` | 200 | `get_soundtouch_updates.http` | ✅ | +| GET | `/v1/auth` | 200, 403, 404 | — | ⬜ (Go unit: `auth_probe_test.go`) | +| POST | `/v1/scmudc/{d}` | 200 | — | ⬜ | +| GET | `/v1/blacklist/{d}` | 405 | — | ⬜ (edge) | +| 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 | `/core02/svc-bmx-adapter-orion/prod/orion/station` | 200 | — | ⬜ | +| GET | `/custom/v1/playback/{encodedURL}` | 200 | — | ⬜ | +| GET | `/media/aftertouch-ding.wav` | 200 (binary) | — | ⬜ | +| GET | `/media/bmx-icons/{provider}/{file}` | 200 (binary) | — | ⬜ | +| GET | `/media/tts/{hash}.mp3` | 200, 404 (binary) | — | ⬜ (depends on prior TTS) | +| GET | `/ced/utilities/audio/take5.mp3` | 404 | — | ⬜ (edge: CED static miss) | +| POST | `/oauth/device/{d}/.../15/token/cs3` | 200 | `post_oauth_token.http` | ✅ | +| POST | `/oauth/device/{d}/.../20/token/cs1` | 200 | `post_oauth_token_amazon.http` | ✅ | + +## Not observed from the speaker (lower priority / different audience) + +- `/bmx/tunein/v1/navigate`, `/search`, `/search/next` — registered (frozen), + but in the corpus the speaker uses `/playback/*`; the search/navigate layer is + driven by the app/UI (`/api/tunein/*`), not the speaker. Covered conceptually, + no speaker recording to replay. +- `/core02/svc-bmx-adapter-siriusxm-*` — registered, but not present in this + corpus (no SiriusXM device). Left as a known blank. +- App / provisioning surface (`/customer/account*`, account profile/password, + `/streaming/account/login` beyond create) — app-called, not the speaker + data-plane; out of scope for the speaker-contract net. + +## Gap-fill priority + +1. **High (pure service, no external dep):** `/v1/auth`, `/v1/scmudc/{d}`, + `/core02/.../orion/station`, `/custom/v1/playback/{encodedURL}`, + `/bmx/tunein/v1/report`, `/media/aftertouch-ding.wav`, + `/media/bmx-icons/{...}`, group delete lifecycle. +2. **Medium (TuneIn live dep, like the existing playback test):** + `/bmx/tunein/v1/playback/episode(s)/{id}`, `/bmx/tunein/v1/favorite/{id}`. +3. **Low / edge:** 304 conditional GETs (`/full`, `/presets`), PUT-device 401, + `/v1/blacklist` 405, `/ced/*` 404, `/alexa/certificate` 501, + `/media/tts/{hash}`. diff --git a/tests/integration/http-client/get_bmx_icon.http b/tests/integration/http-client/get_bmx_icon.http new file mode 100644 index 0000000..99a3cf1 --- /dev/null +++ b/tests/integration/http-client/get_bmx_icon.http @@ -0,0 +1,17 @@ +### GET /media/bmx-icons/{provider}/{file} (BMX source icon) +### +### Frozen route. The BMX registry advertises {MEDIA_SERVER}/bmx-icons; the +### speaker fetches source/provider icons from this static base (served by +### HandleMedia from the embedded asset tree). Binary body, so assert status + +### content type only (never commit the bytes). +GET {{host}}/media/bmx-icons/tunein/monochromePng.png +User-Agent: Bose_Lisa/27.0.6 +Accept: */* + +> {% + client.test("Icon served as image/png 200", function() { + client.assert(response.status === 200, "Response status is not 200, got " + response.status); + client.assert(response.contentType.mimeType === "image/png", + "Expected image/png, got '" + response.contentType.mimeType + "'"); + }); +%} diff --git a/tests/integration/http-client/get_custom_playback.http b/tests/integration/http-client/get_custom_playback.http new file mode 100644 index 0000000..9d41243 --- /dev/null +++ b/tests/integration/http-client/get_custom_playback.http @@ -0,0 +1,25 @@ +### GET /custom/v1/playback/{encodedURL} (LOCAL_INTERNET_RADIO / ding / TTS replay) +### +### Frozen route. The speaker replays presets/notifications whose stored location +### is /custom/v1/playback/; the service decodes it and returns +### playable audio JSON pointing at that URL (HandleCustomPlayback -> +### bmx.BuildCustomStreamResponse). Pure decode, no upstream call. +### +### encodedURL decodes to: http://192.0.2.10:8000/stream +GET {{host}}/custom/v1/playback/aHR0cDovLzE5Mi4wLjIuMTA6ODAwMC9zdHJlYW0=?name=Doc%20Radio +User-Agent: Bose_Lisa/27.0.6 +Accept: */* + +> {% + client.test("Response is 200 OK with JSON", function() { + client.assert(response.status === 200, "Response status is not 200, got " + response.status); + client.assert(response.contentType.mimeType === "application/json", + "Expected application/json, got '" + response.contentType.mimeType + "'"); + }); + + client.test("Response echoes the decoded stream URL as playable audio", function() { + client.assert(response.body.hasOwnProperty("audio"), "Response missing 'audio'"); + client.assert(response.body.audio.streamUrl === "http://192.0.2.10:8000/stream", + "audio.streamUrl should echo the decoded URL, got '" + response.body.audio.streamUrl + "'"); + }); +%} diff --git a/tests/integration/http-client/get_media_ding.http b/tests/integration/http-client/get_media_ding.http new file mode 100644 index 0000000..3666468 --- /dev/null +++ b/tests/integration/http-client/get_media_ding.http @@ -0,0 +1,17 @@ +### GET /media/aftertouch-ding.wav (notification chime) +### +### Frozen route advertised as part of the {MEDIA_SERVER} base. The speaker +### fetches the ding before playing a notification; the service renders a WAV +### (HandleDing). Binary body, so assert status + content type only (never commit +### the bytes). +GET {{host}}/media/aftertouch-ding.wav +User-Agent: Bose_Lisa/27.0.6 +Accept: */* + +> {% + client.test("Ding served as audio/wav 200", function() { + client.assert(response.status === 200, "Response status is not 200, got " + response.status); + client.assert(response.contentType.mimeType === "audio/wav", + "Expected audio/wav, got '" + response.contentType.mimeType + "'"); + }); +%} diff --git a/tests/integration/http-client/get_orion_station.http b/tests/integration/http-client/get_orion_station.http new file mode 100644 index 0000000..7f5c76a --- /dev/null +++ b/tests/integration/http-client/get_orion_station.http @@ -0,0 +1,30 @@ +### GET /core02/svc-bmx-adapter-orion/prod/orion/station (Orion custom-stream adapter) +### +### Frozen BMX adapter route. Presets that store a LOCAL_INTERNET_RADIO / Orion +### location replay this exact path later; the speaker passes the station detail +### as a base64 JSON ?data= blob and expects playable audio JSON back +### (HandleOrionPlayback -> bmx.PlayCustomStream). Pure decode, no upstream call. +### +### data decodes to: +### {"name":"Doc Radio","imageUrl":"","streamUrl":"http://192.0.2.10:8000/stream"} +GET {{host}}/core02/svc-bmx-adapter-orion/prod/orion/station?data=eyJuYW1lIjoiRG9jIFJhZGlvIiwiaW1hZ2VVcmwiOiIiLCJzdHJlYW1VcmwiOiJodHRwOi8vMTkyLjAuMi4xMDo4MDAwL3N0cmVhbSJ9 +User-Agent: Bose_Lisa/27.0.6 +Accept: */* +Accept-Language: de + +> {% + client.test("Response is 200 OK with JSON", function() { + client.assert(response.status === 200, "Response status is not 200, got " + response.status); + client.assert(response.contentType.mimeType === "application/json", + "Expected application/json, got '" + response.contentType.mimeType + "'"); + }); + + client.test("Response carries playable audio for the decoded stream", function() { + client.assert(response.body.hasOwnProperty("audio"), "Response missing 'audio'"); + client.assert(response.body.audio.hasOwnProperty("streamUrl"), "Response missing 'audio.streamUrl'"); + client.assert(response.body.audio.streamUrl === "http://192.0.2.10:8000/stream", + "audio.streamUrl should echo the decoded streamUrl, got '" + response.body.audio.streamUrl + "'"); + client.assert(response.body.name === "Doc Radio", + "name should echo the decoded name, got '" + response.body.name + "'"); + }); +%} diff --git a/tests/integration/http-client/get_speaker_auth.http b/tests/integration/http-client/get_speaker_auth.http new file mode 100644 index 0000000..5c64344 --- /dev/null +++ b/tests/integration/http-client/get_speaker_auth.http @@ -0,0 +1,17 @@ +### GET /v1/auth (speaker app-key validation probe) +### +### The speaker issues this to the dead Bose host audionotification.api.bosecm.com +### (DNS-intercepted to AfterTouch) before playing a /speaker notification, with +### its app_key in the "Apikeyheader" header. As the cloud replacement we accept +### any key with an empty 200 so the speaker proceeds (HandleSpeakerAuth). +### Returns 403 only while an active DNS-path probe nonce matches; none runs here. +GET {{host}}/v1/auth +User-Agent: Bose_Lisa/27.0.6 +Accept: */* +Apikeyheader: example-app-key + +> {% + client.test("Speaker app-key is accepted (200)", function() { + client.assert(response.status === 200, "Response status is not 200, got " + response.status); + }); +%} diff --git a/tests/integration/http-client/post_scmudc_event.http b/tests/integration/http-client/post_scmudc_event.http new file mode 100644 index 0000000..51b3ef7 --- /dev/null +++ b/tests/integration/http-client/post_scmudc_event.http @@ -0,0 +1,18 @@ +### POST /v1/scmudc/{deviceId} (speaker telemetry / event upload) +### +### Speakers POST SCMUDC event envelopes (volume changes, presets, taps, ...) to +### the dead Bose host events.api.bosecm.com, DNS-intercepted to AfterTouch. The +### service accepts and records them (HandleAppEvents) and returns an empty 200. +### Body shape mirrors a real envelope; all ids are placeholders. +POST {{host}}/v1/scmudc/{{deviceId}} +User-Agent: Bose_Lisa/27.0.6 +Accept: */* +Content-Type: text/json; charset=utf-8 + +{"envelope":{"monoTime":35173,"payloadProtocolVersion":"3.1","payloadType":"scmudc","protocolVersion":"1.0","time":"2026-01-01T00:00:00.000000+00:00","uniqueId":"{{deviceId}}"},"payload":{"deviceInfo":{"boseID":"{{accountId}}","deviceID":"{{deviceId}}","deviceType":"SoundTouch test","serialNumber":"{{serialNumber}}","softwareVersion":"27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29","systemSerialNumber":"{{productSerialNumber}}"},"events":[{"data":{"startTime":"2026-01-01T00:00:00.000000+00:00","volume-change":[28,28,28]},"monoTime":35173,"time":"2026-01-01T00:00:00.000000+00:00","type":"volume-change"}]}} + +> {% + client.test("SCMUDC event accepted (200)", function() { + client.assert(response.status === 200, "Response status is not 200, got " + response.status); + }); +%} diff --git a/tests/integration/http-client/post_tunein_report.http b/tests/integration/http-client/post_tunein_report.http new file mode 100644 index 0000000..068e528 --- /dev/null +++ b/tests/integration/http-client/post_tunein_report.http @@ -0,0 +1,39 @@ +### POST /bmx/tunein/v1/report (TuneIn playback reporting) +### +### Frozen BMX route. The speaker reports playback lifecycle events (START / STOP) +### for TuneIn content. STOP and most events return an empty object; START returns +### a polling hint (HandleTuneInReport). Pure local handling, no upstream call. + +### STOP report -> empty object +POST {{host}}/bmx/tunein/v1/report?guide_id=s166521&listen_id=1234567890&stream_type=onDemand +User-Agent: Bose_Lisa/27.0.6 +Accept: */* +X-Bmx-Api-Key: {{bmxApiKey}} +Content-Type: application/json + +{"timeStamp":"2026-01-01T00:00:00+0000","eventType":"STOP","reason":"USER_STOP","timeIntoTrack":5,"playbackDelay":0} + +> {% + client.test("STOP report is acknowledged with JSON 200", function() { + client.assert(response.status === 200, "Response status is not 200, got " + response.status); + client.assert(response.contentType.mimeType === "application/json", + "Expected application/json, got '" + response.contentType.mimeType + "'"); + }); +%} + +### START report -> polling hint +POST {{host}}/bmx/tunein/v1/report?guide_id=s166521&listen_id=1234567890&stream_type=onDemand +User-Agent: Bose_Lisa/27.0.6 +Accept: */* +X-Bmx-Api-Key: {{bmxApiKey}} +Content-Type: application/json + +{"timeStamp":"2026-01-01T00:00:00+0000","eventType":"START","timeIntoTrack":0,"playbackDelay":0} + +> {% + client.test("START report returns a nextReportIn polling hint", function() { + client.assert(response.status === 200, "Response status is not 200, got " + response.status); + client.assert(response.body.hasOwnProperty("nextReportIn"), + "START response should carry 'nextReportIn'"); + }); +%}