mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
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:
co-authored by
Claude Opus 4.8
parent
59f7ed5543
commit
603765b644
@@ -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 \
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
%}
|
||||
Reference in New Issue
Block a user