From 979374b5011e2480407ee670103d66c7003107a7 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Fri, 15 May 2026 18:58:22 +0200 Subject: [PATCH] test(integration): pin #285 rename PUT behaviour at the HTTP layer Adds rename_device.http between get_group.http and unregister_device.http in the make test-http-client sequence. The new test fires the PUT the speaker emits after a rename and asserts: - 200 OK, content type vnd.bose.streaming-v1.2+xml - the response carries the renamed value - createdOn matches the value captured during register_device.http (cross-request global), locking in the "first-paired" semantics - ipaddress is preserved from the prior power_on, not reset by the rename body's empty IP field - a mismatched body deviceid is rejected with 400 register_device.http captures the initial createdOn into a global so the rename test can assert equality rather than a flakier updatedOn != createdOn heuristic. The variant POST's stale updatedOn === createdOn assertion is replaced with an upsert-aware equality against the same captured global. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 1 + .../http-client/register_device.http | 13 ++- .../http-client/rename_device.http | 99 +++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/integration/http-client/rename_device.http diff --git a/Makefile b/Makefile index ddbd2b4..ef22ddf 100644 --- a/Makefile +++ b/Makefile @@ -171,6 +171,7 @@ test-http-client: /workdir/get_full_account.http \ /workdir/create_group.http \ /workdir/get_group.http \ + /workdir/rename_device.http \ /workdir/unregister_device.http \ --report; \ EXIT_CODE=$$?; \ diff --git a/tests/integration/http-client/register_device.http b/tests/integration/http-client/register_device.http index d344435..95fb58e 100644 --- a/tests/integration/http-client/register_device.http +++ b/tests/integration/http-client/register_device.http @@ -33,6 +33,10 @@ Authorization: Bearer {{token}} const ipaddress = device.getElementsByTagName("ipaddress")[0]; client.assert(ipaddress !== undefined, "Response body should contain "); + + // Capture the initial createdOn so rename_device.http can + // assert it survives a PUT later in the test sequence. + client.global.set("initialCreatedOn", createdOn.textContent); }); %} @@ -61,6 +65,13 @@ Authorization: Bearer {{token}} const createdOn = device.getElementsByTagName("createdOn")[0]; client.assert(createdOn !== undefined, "Response body should contain "); client.assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|(\+\d{2}:\d{2}))$/.test(createdOn.textContent), "createdOn should be a valid ISO8601 timestamp"); + // This variant POST re-uses the same deviceId as the first + // register above, so it's an upsert — issue #285 made the + // datastore preserve the original createdOn unconditionally. + // The captured global is the load-bearing assertion: if it + // ever differs here, CreatedOn preservation regressed. + client.assert(createdOn.textContent === client.global.get("initialCreatedOn"), + "createdOn (" + createdOn.textContent + ") differs from the value captured by the first POST (" + client.global.get("initialCreatedOn") + ") — upsert must preserve the original timestamp"); const name = device.getElementsByTagName("name")[0]; client.assert(name !== undefined, "Response body should contain "); @@ -68,7 +79,7 @@ Authorization: Bearer {{token}} const updatedOn = device.getElementsByTagName("updatedOn")[0]; client.assert(updatedOn !== undefined, "Response body should contain "); - client.assert(updatedOn.textContent === createdOn.textContent, "updatedOn should match createdOn for a new device"); + client.assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|(\+\d{2}:\d{2}))$/.test(updatedOn.textContent), "updatedOn should be a valid ISO8601 timestamp"); const ipaddress = device.getElementsByTagName("ipaddress")[0]; client.assert(ipaddress !== undefined, "Response body should contain "); diff --git a/tests/integration/http-client/rename_device.http b/tests/integration/http-client/rename_device.http new file mode 100644 index 0000000..051b2ad --- /dev/null +++ b/tests/integration/http-client/rename_device.http @@ -0,0 +1,99 @@ +### PUT /streaming/account/{accountId}/device/{deviceId} (Rename Device) +### +### Speakers fire this against AfterTouch when the user renames them +### via the Bose App or `soundtouch-cli name set`. Before issue #285's +### route fix the request fell through to the [UNHANDLED] catch-all +### and proxied to streaming.bose.com (401), so the speaker retried +### in a loop and the Bose App showed the rename spinning forever. +### This test runs after register_device.http (which creates the +### initial record) and after power_on.http (which seeds the IP at +### 192.168.1.100). The rename PUT must: +### +### - return 200 OK +### - echo the new name +### - preserve from the initial registration +### - preserve from the power_on update (request body +### doesn't carry one; the datastore merge keeps the existing) +### - refresh to "now" +PUT {{host}}/streaming/account/{{accountId}}/device/{{deviceId}} +Content-Type: application/vnd.bose.streaming-v1.2+xml +Authorization: Bearer {{token}} + + + + {{deviceName}} (renamed) + {{macAddress1}} + + +> {% + client.test("Rename returned 200 OK", function() { + client.assert(response.status === 200, "Response status should be 200, got " + response.status); + client.assert(response.contentType.mimeType === "application/vnd.bose.streaming-v1.2+xml", "Response Content-Type should be application/vnd.bose.streaming-v1.2+xml"); + }); + + client.test("Response carries the new name and matches the deviceId", function() { + const doc = response.body; + const device = doc.getElementsByTagName("device")[0]; + client.assert(device !== undefined, "Response body should contain "); + client.assert(device.getAttribute("deviceid") === client.variables.environment.get("deviceId"), "Response deviceid should match"); + + const name = device.getElementsByTagName("name")[0]; + client.assert(name !== undefined, "Response body should contain "); + client.assert(name.textContent === client.variables.environment.get("deviceName") + " (renamed)", "Response should carry the renamed value"); + }); + + client.test("createdOn preserved across rename, updatedOn refreshed", function() { + const device = response.body.getElementsByTagName("device")[0]; + + const createdOn = device.getElementsByTagName("createdOn")[0]; + client.assert(createdOn !== undefined, "Response body should contain "); + client.assert(createdOn.textContent.length > 0, "createdOn should not be empty (it must survive the rename, not be reset)"); + client.assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|(\+\d{2}:\d{2}))$/.test(createdOn.textContent), "createdOn should be ISO8601"); + + // The load-bearing assertion: the value must match what + // register_device.http captured on the initial POST. If it + // doesn't, the rename PUT regressed the "first-paired in + // 2017" semantics — the exact bug behind the parity capture + // at data/parity_mismatches/1771797308__streaming_account_3230304_device_A81B6A536A98.json. + const initial = client.global.get("initialCreatedOn"); + client.assert(initial !== undefined && initial.length > 0, + "initialCreatedOn was not captured by register_device.http — test ordering issue"); + client.assert(createdOn.textContent === initial, + "createdOn (" + createdOn.textContent + ") differs from the value captured at registration (" + initial + ") — the rename PUT must preserve the original timestamp"); + + const updatedOn = device.getElementsByTagName("updatedOn")[0]; + client.assert(updatedOn !== undefined, "Response body should contain "); + client.assert(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|(\+\d{2}:\d{2}))$/.test(updatedOn.textContent), "updatedOn should be ISO8601"); + }); + + client.test("ipaddress preserved from earlier power_on payload", function() { + const device = response.body.getElementsByTagName("device")[0]; + const ipaddress = device.getElementsByTagName("ipaddress")[0]; + client.assert(ipaddress !== undefined, "Response body should contain "); + client.assert(ipaddress.textContent === client.variables.environment.get("deviceIp"), + "ipaddress should be preserved from the prior power_on (" + client.variables.environment.get("deviceIp") + "), not overwritten — got " + ipaddress.textContent); + }); +%} + +### PUT with mismatched deviceid in body (safety check) +### +### If the body's deviceid attribute doesn't match the URL's {device} +### segment the handler refuses with 400 rather than silently re-key +### the persisted record under the wrong account/device. Pins the +### second TestIssue285_* unit-test assertion at the integration +### layer. +PUT {{host}}/streaming/account/{{accountId}}/device/{{deviceId}} +Content-Type: application/vnd.bose.streaming-v1.2+xml +Authorization: Bearer {{token}} + + + + Rogue Rename + DEADBEEFCAFE + + +> {% + client.test("Mismatched body deviceid returns 400", function() { + client.assert(response.status === 400, "Response status should be 400 for body/url deviceid mismatch, got " + response.status); + }); +%}