Files
Bose-SoundTouch/pkg/service/marge/i334_sources_roundtrip_test.go
Tobias GesellchenandClaude Opus 4.8 f26176fad4 fix(marge): never persist or serve sources without a resolvable provider id
Root cause of #334's INVALID_SOURCE: a speaker reports device-local slots
(STORED_MUSIC_MEDIA_RENDERER, UPNP) in /sources; AfterTouch imports them
verbatim and re-serves them in /full. PrepareConfiguredSource fills
sourceproviderid only for types in constants.StaticProviders, so these go
out with an empty <sourceproviderid> — a required protobuf field — and the
speaker rejects them as INVALID_SOURCE, which then re-syncs back into the
datastore.

Fix, keyed on the principle (no hardcoded denylist in production):
- HasResolvableProviderID(s): true if the source already carries a provider
  id, or its source-key type resolves via StaticProviders.
- Serve-side guard in getAccountSources: drop any source whose resolved
  sourceproviderid is still empty (generalises the existing AUX/#195 skip).
  Heals already-polluted datastores on the next /full, no resync needed.
- Import-side filter in syncConfiguredSources (marge) and both branches of
  syncSources (setup): drop unresolvable sources before persisting, stopping
  future pollution and the re-import loop.

Tests: reproduction converted to regression test
(TestI334FullOmitsSourcesWithoutProviderID) seeded from a sanitised real
#334 /sources capture; explicit servable/non-servable tables in
TestHasResolvableProviderID. Two pre-existing fixtures that relied on
sources with no provider id were given valid ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:33:19 +02:00

201 lines
6.8 KiB
Go

package marge
import (
"encoding/xml"
"os"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// configuredFromSpeakerSources mirrors the HTTP /sources import branch of
// setup.(*Manager).syncSources (cmd path: SyncDeviceData -> syncSources,
// pkg/service/setup/setup.go). It is a verbatim field copy: the speaker's
// <sourceItem source="X"> becomes ConfiguredSource.SourceKey.Type=X with no
// ID, no protocol Type and no SourceProviderID. Replicated here (rather than
// imported) because the upstream is inline I/O code and the field mapping is
// the input to the real /full builder, not the thing under test.
func configuredFromSpeakerSources(srs models.Sources) []models.ConfiguredSource {
var out []models.ConfiguredSource
for _, s := range srs.SourceItem {
cs := models.ConfiguredSource{DisplayName: s.DisplayName}
if s.Status == models.SourceStatusReady {
cs.SecretType = constants.CredentialTypeToken
}
if s.Source == constants.ProviderSpotify {
cs.SecretType = constants.CredentialTypeTokenV3
}
cs.SourceKey.Type = s.Source
cs.SourceKey.Account = s.SourceAccount
cs.SourceKeyType = s.Source
cs.SourceKeyAccount = s.SourceAccount
out = append(out, cs)
}
return out
}
// TestI334FullOmitsSourcesWithoutProviderID guards the #334 fix: a speaker's
// legitimate /sources list (the fixture has zero INVALID_SOURCE entries) is
// imported, persisted, and then turned into the /full <sources> block by
// getAccountSources. After the fix:
//
// - Every emitted source must have a non-empty SourceProviderID; any source
// without one (STORED_MUSIC_MEDIA_RENDERER, UPNP, …) would be rejected by
// the speaker as INVALID_SOURCE, creating the feedback loop that #334
// identified.
// - The two specific device-local slots from v-tron's datastore
// (StoredMusicUserName / STORED_MUSIC_MEDIA_RENDERER and UPnPUserName /
// UPNP) must be absent from the emitted set entirely.
//
// Precedent: AUX was excluded the same way for issue #195 (see the comment
// block immediately before the ProviderAux continue in getAccountSources).
// This test extends that pattern to all sources whose type has no entry in
// constants.StaticProviders.
func TestI334FullOmitsSourcesWithoutProviderID(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("testdata", "i334_speaker_sources.xml"))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
var srs models.Sources
if err := xml.Unmarshal(raw, &srs); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
imported := configuredFromSpeakerSources(srs)
if len(imported) == 0 {
t.Fatal("fixture produced no sources")
}
// Filter as the import path does: drop unresolvable sources before save.
var servable []models.ConfiguredSource
for _, s := range imported {
if HasResolvableProviderID(s) {
servable = append(servable, s)
}
}
const (
account = "ACCT01"
device = "DEVICEID01"
)
ds := datastore.NewDataStore(t.TempDir())
if err := ds.SaveConfiguredSources(account, device, servable); err != nil {
t.Fatalf("save configured sources: %v", err)
}
// Real /full source build: merge defaults + PrepareConfiguredSource +
// mapToFullResponseSource, exactly as AccountFullToXML invokes it.
full := getAccountSources(ds, account, device)
t.Logf("%-26s | %-8s | %-13s | %s", "out.name", "out.type", "out.provider", "credential.type")
for _, s := range full {
t.Logf("%-26q | %-8q | %-13q | %q", s.Name, s.Type, s.SourceProviderID, s.Credential.Type)
}
// Every emitted source must have a non-empty SourceProviderID.
for _, s := range full {
if s.SourceProviderID == "" {
t.Errorf("source name=%q was emitted with empty SourceProviderID — it would be rejected as INVALID_SOURCE (#334)", s.Name)
}
}
// The two specific device-local slots from the fixture must be absent.
emittedNames := make(map[string]bool, len(full))
for _, s := range full {
emittedNames[s.Name] = true
}
for _, mustBeAbsent := range []string{"StoredMusicUserName", "UPnPUserName"} {
if emittedNames[mustBeAbsent] {
t.Errorf("source name=%q must not appear in /full (device-local slot with no resolvable sourceproviderid, issue #334)", mustBeAbsent)
}
}
}
// TestHasResolvableProviderID verifies the HasResolvableProviderID predicate
// used to filter sources before persistence and before serving via /full.
func TestHasResolvableProviderID(t *testing.T) {
// Sources that MUST be servable (true): their SourceKey.Type maps to a
// constants.StaticProviders entry, so ensureSourceProviderID can fill the
// canonical providerid.
trueTable := []struct {
name string
typ string
}{
{"TUNEIN", constants.ProviderTunein},
{"SPOTIFY", constants.ProviderSpotify},
{"RADIO_BROWSER", constants.ProviderRadioBrowser},
{"LOCAL_INTERNET_RADIO", constants.ProviderLocalInternetRadio},
{"STORED_MUSIC", constants.ProviderStoredMusic},
{"INTERNET_RADIO", constants.ProviderInternetRadio},
{"AUX", constants.ProviderAux},
}
for _, tc := range trueTable {
t.Run("true/"+tc.name, func(t *testing.T) {
s := models.ConfiguredSource{}
s.SourceKey.Type = tc.typ
if !HasResolvableProviderID(s) {
t.Errorf("HasResolvableProviderID(%q) = false, want true", tc.typ)
}
})
}
// Sources that MUST NOT be servable (false): no StaticProviders entry,
// so ensureSourceProviderID would leave SourceProviderID empty and the
// speaker would reject the source as INVALID_SOURCE.
falseTable := []struct {
name string
typ string
}{
{"STORED_MUSIC_MEDIA_RENDERER", "STORED_MUSIC_MEDIA_RENDERER"},
{"UPNP", "UPNP"},
{"INVALID_SOURCE", "INVALID_SOURCE"},
{"empty type", ""},
}
for _, tc := range falseTable {
t.Run("false/"+tc.name, func(t *testing.T) {
s := models.ConfiguredSource{}
s.SourceKey.Type = tc.typ
if HasResolvableProviderID(s) {
t.Errorf("HasResolvableProviderID(%q) = true, want false", tc.typ)
}
})
}
// A source that already carries a non-empty SourceProviderID must return
// true regardless of its type — the existing providerid wins.
t.Run("true/existing-providerid-overrides-type", func(t *testing.T) {
s := models.ConfiguredSource{SourceProviderID: "99"}
s.SourceKey.Type = "STORED_MUSIC_MEDIA_RENDERER" // normally false
if !HasResolvableProviderID(s) {
t.Error("HasResolvableProviderID with non-empty SourceProviderID = false, want true")
}
})
// Legacy SourceKeyType field must also be checked when SourceKey.Type is empty.
t.Run("true/legacy-sourcekeytype", func(t *testing.T) {
s := models.ConfiguredSource{SourceKeyType: constants.ProviderTunein}
// SourceKey.Type intentionally left empty
if !HasResolvableProviderID(s) {
t.Errorf("HasResolvableProviderID with SourceKeyType=%q (SourceKey.Type empty) = false, want true", constants.ProviderTunein)
}
})
}