test(http-client): pin the ETag conditional-GET (304) contract (refs #451)

The speaker re-polls /full and the device presets with the ETag it last saw and
expects 304 Not Modified when nothing changed. Two self-contained flows capture
the current ETag and replay it via If-None-Match, asserting 304. This pins the
conditional-GET behaviour and the case-sensitive ETag header path (CLAUDE.md).

make test-http-client: 65 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 6efad165f6
commit a974862b07
4 changed files with 70 additions and 5 deletions
+2
View File
@@ -200,6 +200,7 @@ test-http-client:
/workdir/get_bmx_icon.http \
/workdir/set_preset_6.http \
/workdir/get_presets.http \
/workdir/get_presets_conditional.http \
/workdir/delete_preset_6.http \
/workdir/set_preset_5.http \
/workdir/post_recent.http \
@@ -210,6 +211,7 @@ test-http-client:
/workdir/get_api_versions.http \
/workdir/post_musicprovider_is_eligible.http \
/workdir/get_full_account.http \
/workdir/get_full_account_conditional.http \
/workdir/create_group.http \
/workdir/get_group.http \
/workdir/delete_group.http \
+5 -5
View File
@@ -25,7 +25,7 @@ Legend: ✅ covered · ⬜ gap · 〰️ partial (some status/variant uncovered)
| 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}/full` | 200, 304 | `get_full_account.http`, `get_full_account_conditional.http` | ✅ |
| 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` | ✅ |
@@ -35,7 +35,7 @@ Legend: ✅ covered · ⬜ gap · 〰️ partial (some status/variant uncovered)
| 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) |
| GET | `/streaming/account/{a}/device/{d}/presets` | 200, 304 | `get_presets.http`, `get_presets_conditional.http` | ✅ |
| 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` | ✅ |
@@ -93,6 +93,6 @@ Legend: ✅ covered · ⬜ gap · 〰️ partial (some status/variant uncovered)
`/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}`.
3. **Low / edge (still open):** PUT-device 401, `/v1/blacklist` 405,
`/ced/*` 404, `/alexa/certificate` 501, `/media/tts/{hash}`. Quirky-status
pins; add on demand.
@@ -0,0 +1,32 @@
### GET /streaming/account/{accountId}/full then re-GET with If-None-Match -> 304
###
### The speaker polls /full with the ETag it last saw; when nothing changed the
### service answers 304 Not Modified so the speaker skips re-parsing. This pins
### the ETag / conditional-GET contract (the ETag header is case-sensitive on the
### wire; see CLAUDE.md). Self-contained: capture the current ETag, then replay.
### Prime: fetch /full and capture its ETag
GET {{host}}/streaming/account/{{accountId}}/full
User-Agent: Bose_Lisa/27.0.6
Accept: application/vnd.bose.streaming-v1.2+xml
> {%
client.test("Initial /full is 200 with an ETag", function() {
client.assert(response.status === 200, "Response status is not 200, got " + response.status);
client.assert(response.headers.valueOf("ETag") !== null, "Response should carry an ETag header");
});
client.global.set("fullEtag", response.headers.valueOf("ETag"));
%}
### Conditional GET with the captured ETag -> 304 Not Modified
GET {{host}}/streaming/account/{{accountId}}/full
User-Agent: Bose_Lisa/27.0.6
Accept: application/vnd.bose.streaming-v1.2+xml
If-None-Match: {{fullEtag}}
> {%
client.test("Conditional /full returns 304 Not Modified", function() {
client.assert(response.status === 304, "Response status is not 304, got " + response.status);
});
%}
@@ -0,0 +1,31 @@
### GET .../device/{deviceId}/presets then re-GET with If-None-Match -> 304
###
### Same conditional-GET contract as /full, for the device presets resource: the
### speaker re-polls with its last ETag and the service answers 304 when the
### preset list is unchanged. Self-contained: capture the current ETag, replay.
### Prime: fetch presets and capture the ETag
GET {{host}}/streaming/account/{{accountId}}/device/{{deviceId}}/presets
User-Agent: Bose_Lisa/27.0.6
Accept: application/vnd.bose.streaming-v1.2+xml
> {%
client.test("Initial presets fetch is 200 with an ETag", function() {
client.assert(response.status === 200, "Response status is not 200, got " + response.status);
client.assert(response.headers.valueOf("ETag") !== null, "Response should carry an ETag header");
});
client.global.set("presetsEtag", response.headers.valueOf("ETag"));
%}
### Conditional GET with the captured ETag -> 304 Not Modified
GET {{host}}/streaming/account/{{accountId}}/device/{{deviceId}}/presets
User-Agent: Bose_Lisa/27.0.6
Accept: application/vnd.bose.streaming-v1.2+xml
If-None-Match: {{presetsEtag}}
> {%
client.test("Conditional presets fetch returns 304 Not Modified", function() {
client.assert(response.status === 304, "Response status is not 304, got " + response.status);
});
%}