test(http-client): broaden speaker-contract coverage from recordings (refs #451)

Build the regression net the API route layout note calls for, before any
route refactoring: mine real recorded speaker traffic (Bose_Lisa UA) into a
coverage checklist and fill the high-priority, dependency-free gaps.

- COVERAGE.md: inventory of frozen speaker routes (method + status) mapped to
  covering .http files, with the remaining gaps classified by priority.
- New flows, all asserting status/content-type/structure with the firmware UA:
  - GET  /v1/auth                              (app-key probe)
  - POST /v1/scmudc/{deviceId}                 (telemetry upload)
  - GET  /core02/.../orion/station             (Orion custom-stream adapter)
  - GET  /custom/v1/playback/{encodedURL}      (LOCAL_INTERNET_RADIO / ding)
  - POST /bmx/tunein/v1/report                 (STOP -> {}, START -> nextReportIn)
  - GET  /media/aftertouch-ding.wav            (binary: status + content-type)
  - GET  /media/bmx-icons/{provider}/{file}    (binary: status + content-type)

All request/response values use placeholder / RFC-5737 data; no recorded
bodies are committed. make test-http-client: 57 requests, 0 failed.

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 59f7ed5543
commit 603765b644
9 changed files with 268 additions and 0 deletions
+7
View File
@@ -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 \
+98
View File
@@ -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}`.
@@ -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 + "'");
});
%}
@@ -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/<base64 stream URL>; 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 + "'");
});
%}
@@ -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 + "'");
});
%}
@@ -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 + "'");
});
%}
@@ -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);
});
%}
@@ -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);
});
%}
@@ -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'");
});
%}