From a974862b07ccbd174480f463516d3bb1f50e2794 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 6 Jun 2026 14:43:52 +0200 Subject: [PATCH] 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) --- Makefile | 2 ++ tests/integration/http-client/COVERAGE.md | 10 +++--- .../get_full_account_conditional.http | 32 +++++++++++++++++++ .../http-client/get_presets_conditional.http | 31 ++++++++++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 tests/integration/http-client/get_full_account_conditional.http create mode 100644 tests/integration/http-client/get_presets_conditional.http diff --git a/Makefile b/Makefile index b6d33e0..f8afe9d 100644 --- a/Makefile +++ b/Makefile @@ -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 \ diff --git a/tests/integration/http-client/COVERAGE.md b/tests/integration/http-client/COVERAGE.md index 9324a82..ec13997 100644 --- a/tests/integration/http-client/COVERAGE.md +++ b/tests/integration/http-client/COVERAGE.md @@ -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. diff --git a/tests/integration/http-client/get_full_account_conditional.http b/tests/integration/http-client/get_full_account_conditional.http new file mode 100644 index 0000000..57bc495 --- /dev/null +++ b/tests/integration/http-client/get_full_account_conditional.http @@ -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); + }); +%} diff --git a/tests/integration/http-client/get_presets_conditional.http b/tests/integration/http-client/get_presets_conditional.http new file mode 100644 index 0000000..629bf49 --- /dev/null +++ b/tests/integration/http-client/get_presets_conditional.http @@ -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); + }); +%}