Files
Bose-SoundTouch/pkg/service/soundtouchweb/source_cache_test.go
T
Tobias GesellchenandClaude Opus 5 43d085b9e6 fix(player): mark sources stale on repeated failures, not on one
A failed /sources read went through CompleteFieldPoll, so it consumed the
field generation. That let a newer failed read discard an older, still
in-flight read that had succeeded, and since the player renders
disabled=${sourcesStale}, one transient hiccup could disable every source
button until the next fully successful poll, up to 30s later, even though a
valid inventory had just arrived.

A failure carries no inventory, so there is nothing to order and no reason
to spend the generation on it. ApplySourcesRead now splits the two:

  - a success is still fenced by generation, so two successful reads keep
    their ordering and an older one cannot overwrite a newer one, and it
    always clears the marker;
  - a failure is counted instead, and only staleSourcesFailureThreshold in
    a row marks the inventory unusable, matching how
    offlineFailureThreshold already debounces connectivity in this file.

A genuinely unreachable speaker is therefore stale one poll cycle later
than before, and a single dropped read costs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00

172 lines
6.6 KiB
Go

package soundtouchweb
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
)
// TestUpdateDeviceStatusDoesNotRefreshNowPlayingRevisionOnFailure: a poll round
// where /now_playing failed but /volume succeeded must merge the volume and
// leave now-playing authority untouched. A source selection waiting for
// confirmation reads NowPlayingRevision, so advancing it on a failed read
// would confirm a selection nothing actually verified.
func TestUpdateDeviceStatusDoesNotRefreshNowPlayingRevisionOnFailure(t *testing.T) {
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/volume" {
_, _ = w.Write([]byte(`<volume><targetvolume>35</targetvolume><actualvolume>35</actualvolume><muteenabled>false</muteenabled></volume>`))
return
}
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer speaker.Close()
conn := webtypes.NewDeviceConnection(client.NewClient(&client.Config{Host: speaker.URL}), nil)
conn.SetStatus(&webtypes.DeviceStatus{NowPlaying: &models.NowPlaying{Source: "SPOTIFY"}})
baseline := conn.Status()
NewWebApp().UpdateDeviceStatus("speaker", conn)
updated := conn.Status()
if updated.Revision <= baseline.Revision || updated.Volume == nil || updated.Volume.ActualVolume != 35 {
t.Fatalf("unrelated successful field was not merged: %+v", updated)
}
if updated.NowPlaying.Source != "SPOTIFY" || updated.NowPlayingRevision != baseline.NowPlayingRevision {
t.Fatalf("failed now-playing read advanced source authority: %+v", updated)
}
}
// TestUpdateDeviceStatusMarksSourcesStaleOnRepeatedFailedReads: unlike every
// other field, a failed /sources read is still recorded. The last known
// inventory stays visible but goes unusable once reads keep failing, because
// offering source buttons the speaker no longer confirms is worse than
// offering none.
func TestUpdateDeviceStatusMarksSourcesStaleOnRepeatedFailedReads(t *testing.T) {
sourcesOK := true
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/sources" {
if !sourcesOK {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<sources><sourceItem source="AUX" sourceAccount="AUX1" status="READY" isLocal="true">Aux 1</sourceItem></sources>`))
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<volume><targetvolume>35</targetvolume><actualvolume>35</actualvolume><muteenabled>false</muteenabled></volume>`))
}))
defer speaker.Close()
app := NewWebApp()
conn := webtypes.NewDeviceConnection(client.NewClient(&client.Config{Host: speaker.URL}), nil)
app.UpdateDeviceStatus("speaker", conn)
fresh := conn.Status()
if fresh.SourcesStale || fresh.Sources == nil || len(fresh.Sources.SourceItem) != 1 {
t.Fatalf("successful source read was not merged as actionable: %+v", fresh)
}
// One failure is not enough: a single transient hiccup must not disable
// the whole source list.
sourcesOK = false
app.UpdateDeviceStatus("speaker", conn)
if single := conn.Status(); single.SourcesStale {
t.Fatalf("one failed source read marked the inventory stale: %+v", single)
}
app.UpdateDeviceStatus("speaker", conn)
stale := conn.Status()
if !stale.SourcesStale {
t.Fatalf("consecutive failed source reads did not mark the inventory stale: %+v", stale)
}
if stale.Sources != fresh.Sources {
t.Fatalf("failed source read discarded the last known inventory: %+v", stale)
}
sourcesOK = true
app.UpdateDeviceStatus("speaker", conn)
if recovered := conn.Status(); recovered.SourcesStale {
t.Fatalf("successful source read did not clear staleness: %+v", recovered)
}
}
// TestHandleAPIDevicePublishesCanonicalReadback: the player's bounded readback
// after a source selection reads GET /devices/{id}, so that response must
// carry the same revisions the connection now holds -- not a snapshot taken
// before its own refresh.
func TestHandleAPIDevicePublishesCanonicalReadback(t *testing.T) {
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
responses := map[string]string{
"/now_playing": `<nowPlaying source="AUX" sourceAccount="AUX1"><track>Confirmed track</track><playStatus>PLAY_STATE</playStatus></nowPlaying>`,
"/volume": `<volume><targetvolume>35</targetvolume><actualvolume>35</actualvolume><muteenabled>false</muteenabled></volume>`,
"/presets": `<presets></presets>`,
"/sources": `<sources><sourceItem source="AUX" sourceAccount="AUX1" status="READY" isLocal="true">Aux 1</sourceItem></sources>`,
"/bass": `<bass><targetbass>0</targetbass><actualbass>0</actualbass></bass>`,
}
body, ok := responses[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(body))
}))
defer speaker.Close()
app := NewWebApp()
conn := webtypes.NewDeviceConnection(
client.NewClient(&client.Config{Host: speaker.URL}),
&models.DeviceInfo{Name: "Speaker"},
)
conn.SetStatus(&webtypes.DeviceStatus{NowPlaying: &models.NowPlaying{Source: "STANDBY"}})
baselineRevision := conn.Status().NowPlayingRevision
conn.WebSocket = &client.WebSocketClient{}
app.AddDevice("speaker", conn)
req := httptest.NewRequest(http.MethodGet, "/api/control/devices/speaker", nil)
req = withChiParams(req, map[string]string{"id": "speaker"})
w := httptest.NewRecorder()
app.HandleAPIDevice(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET device status = %d: %s", w.Code, w.Body.String())
}
var response struct {
Success bool `json:"success"`
Data struct {
Status webtypes.DeviceStatus `json:"status"`
} `json:"data"`
}
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("decode device readback: %v", err)
}
canonical := conn.Status()
if !response.Success || canonical.NowPlaying.Source != "AUX" || canonical.NowPlaying.Track != "Confirmed track" {
t.Fatalf("canonical readback not merged: response=%+v status=%+v", response, canonical)
}
if response.Data.Status.NowPlaying.Source != canonical.NowPlaying.Source ||
response.Data.Status.Revision != canonical.Revision ||
response.Data.Status.NowPlayingRevision != canonical.NowPlayingRevision ||
canonical.NowPlayingRevision <= baselineRevision {
t.Fatalf("response did not publish canonical status: response=%+v status=%+v", response.Data.Status, canonical)
}
}