Compare commits

...
3 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 4.7 59593a27e0 refactor(marge): extract findMatchingSourceForUpdatePreset to drop cyclo to spec
After adding the (SourceKeyType, SourceKeyAccount) tier to the storePreset
matcher, UpdatePreset's cyclomatic complexity climbed to 23 (gocyclo
threshold is 20). The three match tiers (ID → type+account → type-only
legacy) read more clearly as named branches in a helper anyway, so pull
them into findMatchingSourceForUpdatePreset and an isWellKnownProviderID
predicate. UpdatePreset now just parses, logs, calls the matcher, and
proceeds.

Also adds the doc comment revive was missing on UpdatePreset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:49:28 +02:00
Tobias GesellchenandClaude Opus 4.7 c817b4902f refactor(handlers): seed placeholder sources at device discovery, not priming
EnsurePlaceholderSources was previously called from
registerSpotifySourceForDevice (inside PrimeDeviceWithSpotify), which is
gated on a Spotify service being configured AND an account being linked.
That meant a user who only ever uses Spotify Connect (push from the
Spotify app, no OAuth-managed account in AfterTouch) never got the
SPOTIFY/SpotifyConnectUserName placeholder, so Connect-initiated presets
still collapsed back to "invalid SourceID".

Move the call to handleDiscoveredDevice (and its fallback) where it
belongs semantically: the placeholder represents a firmware-managed
slot on the speaker, so it should exist for every known device, period —
no music-service config required. Idempotent, so re-discovery is safe.

Remove the duplicate call from registerSpotifySourceForDevice; the
discovery hook is now the single source of truth.

New test in placeholder_discovery_test.go asserts that
handleDiscoveredDevice seeds the placeholder for a fresh device when
NO Spotify service is configured (srv.spotifyService == nil).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:49:28 +02:00
Tobias GesellchenandClaude Opus 4.7 8a7f8010c9 feat(marge): seed SPOTIFY/SpotifyConnectUserName placeholder + bind presets by exact (type, account)
When Spotify Connect is the active source on the speaker, a storePreset
sent to marge carries source="SPOTIFY" sourceAccount="SpotifyConnectUserName"
— the firmware-internal slug for "the user pushed playback from the Spotify
app, no OAuth involved". marge.UpdatePreset's old fallback matched by
SourceKeyType alone and collapsed every SPOTIFY preset onto whichever
OAuth-brokered entry happened to exist (account=gesellix), rewriting the
sourceAccount on save and quietly redirecting recall through the OAuth
token path instead of the firmware's Connect path.

UpdatePreset now:
- parses Source and SourceAccount from the incoming XML;
- prefers an exact (SourceKeyType, SourceKeyAccount) match before falling
  back to the type-only legacy path. Direct ID match still wins first.

New marge.EnsurePlaceholderSources seeds the SPOTIFY/SpotifyConnectUserName
placeholder (no credentials, Status=UNAVAILABLE, deterministic ID
PLACEHOLDER_SPOTIFY_SpotifyConnectUserName). knownPlaceholderSources()
collects future placeholders (UPnP, StoredMusic) in one slice.

registerSpotifySourceForDevice in handlers/server.go calls
EnsurePlaceholderSources after a successful AddSource so every priming run
guarantees both the OAuth entry and the Connect placeholder exist.

Tests in pkg/service/marge/placeholder_sources_test.go:
- placeholder is seeded on first call, no-op on second (idempotent);
- placeholder coexists with an OAuth source under the same account;
- a Connect-style storePreset binds to the placeholder, not the OAuth
  source (regression);
- an OAuth-style storePreset still binds to the OAuth source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:49:28 +02:00
4 changed files with 544 additions and 20 deletions
@@ -0,0 +1,102 @@
package handlers
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
// TestHandleDiscoveredDevice_SeedsPlaceholdersWithoutSpotify guards the
// "Spotify-agnostic" property of placeholder seeding: a brand-new device
// being discovered must get its SPOTIFY/SpotifyConnectUserName placeholder
// even when no Spotify service is configured and no OAuth account is linked.
// That's the use case where the operator hasn't set anything up yet but the
// user pushes Spotify Connect from their phone and wants to preset it.
func TestHandleDiscoveredDevice_SeedsPlaceholdersWithoutSpotify(t *testing.T) {
tempDir, err := os.MkdirTemp("", "placeholder-discovery-*")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
const (
deviceID = "AABBCCDDEEFF"
accountID = "7654321"
)
deviceInfoXML := `<info deviceID="` + deviceID + `">
<name>Bare Speaker</name>
<type>SoundTouch 20</type>
<margeAccountUUID>` + accountID + `</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>27.0.6</softwareVersion>
<serialNumber>SN-PLACEHOLDER-TEST</serialNumber>
</component>
</components>
<networkInfo type="SCM">
<macAddress>` + deviceID + `</macAddress>
<ipAddress>127.0.0.1</ipAddress>
</networkInfo>
</info>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/info" {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, deviceInfoXML)
return
}
http.NotFound(w, r)
}))
defer server.Close()
deviceIP := server.URL[len("http://"):]
ds := datastore.NewDataStore(tempDir)
sm := setup.NewManager(server.URL, ds, nil)
srv := NewServer(ds, sm, "http://localhost", false, false, false)
// Deliberately NOT calling srv.SetSpotifyService — the whole point is
// that placeholder seeding must work without any music-service config.
if srv.spotifyService != nil {
t.Fatalf("test precondition: spotifyService should be nil for this scenario")
}
srv.handleDiscoveredDevice(models.DiscoveredDevice{
Host: deviceIP,
Name: "Bare Speaker",
DiscoveryMethod: "UPnP",
})
sources, err := ds.GetConfiguredSources(accountID, deviceID)
if err != nil {
t.Fatalf("GetConfiguredSources: %v", err)
}
found := false
for i := range sources {
if sources[i].SourceKey.Type == "SPOTIFY" && sources[i].SourceKey.Account == marge.PlaceholderSpotifyConnectAccount {
found = true
break
}
}
if !found {
t.Errorf("expected SPOTIFY/%s placeholder after discovery (without Spotify config), got %d sources",
marge.PlaceholderSpotifyConnectAccount, len(sources))
for i := range sources {
t.Logf(" source[%d]: %s/%s (id=%s)", i, sources[i].SourceKey.Type, sources[i].SourceKey.Account, sources[i].ID)
}
}
}
+17
View File
@@ -719,6 +719,10 @@ func (s *Server) registerSpotifySourceForDevice(deviceIP string, accounts []spot
registered = true
}
// Note: firmware-internal placeholder sources (SPOTIFY/SpotifyConnectUserName, …)
// are seeded at device-discovery time by handleDiscoveredDevice — they live
// independently of whether any music service is OAuth-linked.
// Tell the speaker its sources list changed so it re-fetches from marge.
// Without this its on-device Sources.xml stays stale until something else
// triggers a sync — which leaves storePreset failing with
@@ -916,6 +920,14 @@ func (s *Server) handleDiscoveredDevice(d models.DiscoveredDevice) {
}
}
// 9. Seed firmware-internal placeholder sources (e.g. SPOTIFY/SpotifyConnectUserName)
// so storePreset payloads carrying speaker-managed sourceAccounts bind to the
// right placeholder. Spotify-agnostic — runs even when no music service is
// linked yet, since Spotify Connect from a phone doesn't need our OAuth setup.
if err := marge.EnsurePlaceholderSources(s.ds, accountID, deviceID); err != nil {
log.Printf("Failed to seed placeholder sources for %s/%s: %v", accountID, deviceID, err)
}
log.Printf("Successfully saved device %s (%s) with MAC-based deviceID: %s", info.Name, d.Host, deviceID)
}
@@ -969,6 +981,11 @@ func (s *Server) handleDiscoveredDeviceFallback(d models.DiscoveredDevice) {
}
}
// Seed firmware-internal placeholder sources — same as the live-info path.
if err := marge.EnsurePlaceholderSources(s.ds, accountID, deviceID); err != nil {
log.Printf("Failed to seed placeholder sources for %s/%s: %v", accountID, deviceID, err)
}
log.Printf("Successfully saved device %s (%s) with fallback deviceID: %s", info.Name, d.Host, deviceID)
}
+159 -20
View File
@@ -1212,6 +1212,66 @@ func RemovePreset(ds *datastore.DataStore, account, device string, presetNumber
}
// UpdatePreset updates or creates a preset for the specified account and device.
// findMatchingSourceForUpdatePreset implements the three-tier match for a
// storePreset payload, in order of decreasing specificity:
//
// 1. Exact source ID match — used when the speaker echoes back an ID we
// previously gave it.
// 2. Exact (SourceKeyType, SourceKeyAccount) match — lets firmware-internal
// sourceAccounts (e.g. "SpotifyConnectUserName" for Connect-initiated
// playback) bind to their dedicated placeholder source rather than
// collapsing onto an unrelated OAuth-brokered entry of the same type.
// See marge.EnsurePlaceholderSources.
// 3. SourceKeyType-only fallback for the well-known provider strings —
// preserves legacy behavior when the speaker only sends a provider name
// and no account.
//
// Returns nil if no tier matches; callers turn that into "invalid account/source".
func findMatchingSourceForUpdatePreset(sources []models.ConfiguredSource, sourceID, source, sourceAccount string) *models.ConfiguredSource {
if sourceID != "" {
for i := range sources {
if sources[i].ID == sourceID {
return &sources[i]
}
}
}
if source != "" && sourceAccount != "" {
for i := range sources {
if sources[i].SourceKeyType == source && sources[i].SourceKeyAccount == sourceAccount {
return &sources[i]
}
}
}
if isWellKnownProviderID(sourceID) {
for i := range sources {
if sources[i].SourceKeyType == sourceID {
return &sources[i]
}
}
}
return nil
}
func isWellKnownProviderID(sourceID string) bool {
switch sourceID {
case constants.ProviderInternetRadio,
constants.ProviderTunein,
constants.ProviderSpotify,
constants.ProviderAmazon:
return true
}
return false
}
// UpdatePreset handles a storePreset payload from a speaker: it parses the
// preset XML, looks up the configured source it should bind to via
// findMatchingSourceForUpdatePreset, persists the preset under the account's
// device directory, and returns the parity XML body the speaker expects.
// Returns an error with "invalid account/source" if no source matches.
func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber int, sourceXML []byte) ([]byte, error) {
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
@@ -1226,6 +1286,8 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
var newPresetElem struct {
Name string `xml:"name"`
SourceID string `xml:"sourceid"`
Source string `xml:"source"`
SourceAccount string `xml:"sourceaccount"`
Location string `xml:"location"`
ContentItemType string `xml:"contentItemType"`
ContainerArt string `xml:"containerArt"`
@@ -1234,31 +1296,14 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
return nil, err
}
var matchingSrc *models.ConfiguredSource
log.Printf("[Marge] Searching for source matching ID=%s in %d sources", newPresetElem.SourceID, len(sources))
log.Printf("[Marge] Searching for source matching ID=%s source=%s sourceAccount=%s in %d sources",
newPresetElem.SourceID, newPresetElem.Source, newPresetElem.SourceAccount, len(sources))
for i := range sources {
log.Printf("[Marge] Source[%d]: ID=%s, Type=%s, SourceKeyType=%s, SourceKeyAccount=%s", i, sources[i].ID, sources[i].Type, sources[i].SourceKeyType, sources[i].SourceKeyAccount)
if sources[i].ID == newPresetElem.SourceID {
matchingSrc = &sources[i]
break
}
}
if matchingSrc == nil {
if newPresetElem.SourceID == constants.ProviderInternetRadio || newPresetElem.SourceID == constants.ProviderTunein || newPresetElem.SourceID == constants.ProviderSpotify || newPresetElem.SourceID == constants.ProviderAmazon {
// Find by SourceKeyType instead of ID if it's a default source
for i := range sources {
if sources[i].SourceKeyType == newPresetElem.SourceID {
matchingSrc = &sources[i]
break
}
}
}
}
matchingSrc := findMatchingSourceForUpdatePreset(sources, newPresetElem.SourceID, newPresetElem.Source, newPresetElem.SourceAccount)
if matchingSrc == nil {
return nil, fmt.Errorf("invalid account/source")
}
@@ -2038,3 +2083,97 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
return sourceID, nil
}
// PlaceholderSpotifyConnectAccount is the firmware-internal sourceAccount
// slug the speaker uses for Spotify-Connect-initiated playback (where the
// speaker handles the Spotify session itself, independent of any OAuth
// account brokered by AfterTouch). When the speaker stores a preset for
// such playback it sends this exact string as sourceAccount.
const PlaceholderSpotifyConnectAccount = "SpotifyConnectUserName"
// placeholderSource describes a firmware-internal "identity" source. These
// carry no credentials and exist purely as match anchors so storePreset
// payloads naming a firmware-managed sourceAccount don't fall back to an
// unrelated OAuth-brokered entry with the same SourceKeyType.
type placeholderSource struct {
SourceKeyType string
SourceKeyAccount string
SourceProviderID string
}
func knownPlaceholderSources() []placeholderSource {
return []placeholderSource{
{
SourceKeyType: constants.ProviderSpotify,
SourceKeyAccount: PlaceholderSpotifyConnectAccount,
SourceProviderID: strconv.Itoa(constants.SpotifyProviderID),
},
}
}
// EnsurePlaceholderSources seeds the firmware-internal placeholder
// ConfiguredSources for a single (account, device) pair. Idempotent — a
// placeholder with the same (SourceKeyType, SourceKeyAccount) is left alone
// if it already exists, so this is safe to call on every prime/discover
// cycle.
//
// Placeholders are saved with empty credentials and Status="UNAVAILABLE" so
// they don't masquerade as user-managed sources in the AfterTouch UI; their
// only job is to give marge.UpdatePreset something concrete to match against
// when the speaker sends e.g. sourceAccount="SpotifyConnectUserName".
func EnsurePlaceholderSources(ds *datastore.DataStore, account, device string) error {
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
return err
}
changed := false
for _, p := range knownPlaceholderSources() {
if hasSourceWithKey(sources, p.SourceKeyType, p.SourceKeyAccount) {
continue
}
now := FormatTime(time.Now())
newSrc := models.ConfiguredSource{
ID: "PLACEHOLDER_" + p.SourceKeyType + "_" + p.SourceKeyAccount,
SourceProviderID: p.SourceProviderID,
Username: p.SourceKeyAccount,
Name: p.SourceKeyAccount,
CreatedOn: now,
UpdatedOn: now,
Status: "UNAVAILABLE",
}
newSrc.SourceKey.Type = p.SourceKeyType
newSrc.SourceKey.Account = p.SourceKeyAccount
PrepareConfiguredSource(&newSrc)
log.Printf("[Marge] Seeding placeholder source %s/%s for account=%s device=%s",
p.SourceKeyType, p.SourceKeyAccount, account, device)
sources = append(sources, newSrc)
changed = true
}
if !changed {
return nil
}
return ds.SaveConfiguredSources(account, device, sources)
}
func hasSourceWithKey(sources []models.ConfiguredSource, sourceKeyType, sourceKeyAccount string) bool {
for i := range sources {
// Match either the structured SourceKey or the legacy flat fields —
// PrepareConfiguredSource keeps the two in sync but older datastore
// records may have populated only one.
if (sources[i].SourceKey.Type == sourceKeyType && sources[i].SourceKey.Account == sourceKeyAccount) ||
(sources[i].SourceKeyType == sourceKeyType && sources[i].SourceKeyAccount == sourceKeyAccount) {
return true
}
}
return false
}
@@ -0,0 +1,266 @@
package marge
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// TestEnsurePlaceholderSources_SeedsSpotifyConnect verifies that the
// SPOTIFY/SpotifyConnectUserName placeholder is created for a fresh device
// and that subsequent calls are no-ops (idempotent).
func TestEnsurePlaceholderSources_SeedsSpotifyConnect(t *testing.T) {
ds, account, device := newMargeTestDatastore(t)
if err := EnsurePlaceholderSources(ds, account, device); err != nil {
t.Fatalf("EnsurePlaceholderSources: %v", err)
}
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
t.Fatalf("GetConfiguredSources: %v", err)
}
if !hasSourceWithKey(sources, "SPOTIFY", PlaceholderSpotifyConnectAccount) {
t.Fatalf("expected SPOTIFY/%s placeholder, got %d sources: %+v",
PlaceholderSpotifyConnectAccount, len(sources), sourceSummary(sources))
}
// Find it and assert the cosmetic fields look right (UNAVAILABLE, no creds).
var placeholder *models.ConfiguredSource
for i := range sources {
if sources[i].SourceKey.Type == "SPOTIFY" && sources[i].SourceKey.Account == PlaceholderSpotifyConnectAccount {
placeholder = &sources[i]
break
}
}
if placeholder == nil {
t.Fatalf("could not locate placeholder source after seeding")
}
// No credentials — placeholders are pure match anchors. (Status carries
// xml:"-" so it doesn't round-trip through XML storage; not asserted.)
if placeholder.Secret != "" || placeholder.Credential.Value != "" {
t.Errorf("placeholder should carry no credential, got Secret=%q credential=%q",
placeholder.Secret, placeholder.Credential.Value)
}
if placeholder.SourceProviderID != "15" {
t.Errorf("placeholder SourceProviderID = %q, want 15", placeholder.SourceProviderID)
}
// Idempotency: second call must not add a duplicate.
if err := EnsurePlaceholderSources(ds, account, device); err != nil {
t.Fatalf("EnsurePlaceholderSources (second call): %v", err)
}
after, _ := ds.GetConfiguredSources(account, device)
connectCount := 0
for i := range after {
if after[i].SourceKey.Type == "SPOTIFY" && after[i].SourceKey.Account == PlaceholderSpotifyConnectAccount {
connectCount++
}
}
if connectCount != 1 {
t.Errorf("expected exactly 1 SPOTIFY/%s placeholder after second call, got %d",
PlaceholderSpotifyConnectAccount, connectCount)
}
}
// TestEnsurePlaceholderSources_CoexistsWithOAuth verifies that seeding the
// placeholder does not collide with or overwrite an existing OAuth-brokered
// SPOTIFY source under the same account.
func TestEnsurePlaceholderSources_CoexistsWithOAuth(t *testing.T) {
ds, account, device := newMargeTestDatastore(t)
// Pre-populate the OAuth-brokered Spotify source (same as bridgeSpotifyToMarge would).
if _, err := AddSource(ds, account, "gesellix", "15", "bs-deadbeef", "token_version_3", "Gesell IX"); err != nil {
t.Fatalf("AddSource: %v", err)
}
if err := EnsurePlaceholderSources(ds, account, device); err != nil {
t.Fatalf("EnsurePlaceholderSources: %v", err)
}
sources, _ := ds.GetConfiguredSources(account, device)
// Both must be present.
if !hasSourceWithKey(sources, "SPOTIFY", "gesellix") {
t.Errorf("OAuth source SPOTIFY/gesellix missing after placeholder seeding")
}
if !hasSourceWithKey(sources, "SPOTIFY", PlaceholderSpotifyConnectAccount) {
t.Errorf("placeholder source SPOTIFY/%s missing after seeding", PlaceholderSpotifyConnectAccount)
}
}
// TestUpdatePreset_BindsToPlaceholderByExactAccount is the regression that
// motivated this whole change: when the speaker sends storePreset with
// source=SPOTIFY sourceAccount=SpotifyConnectUserName, marge must bind the
// preset to the placeholder, NOT the OAuth-brokered "gesellix" entry which
// the old type-only fallback would have picked.
func TestUpdatePreset_BindsToPlaceholderByExactAccount(t *testing.T) {
ds, account, device := newMargeTestDatastore(t)
// OAuth-brokered Spotify (the speaker should NOT bind to this for a
// Connect-initiated preset).
if _, err := AddSource(ds, account, "gesellix", "15", "bs-deadbeef", "token_version_3", "Gesell IX"); err != nil {
t.Fatalf("AddSource: %v", err)
}
// Placeholder for Spotify Connect.
if err := EnsurePlaceholderSources(ds, account, device); err != nil {
t.Fatalf("EnsurePlaceholderSources: %v", err)
}
// Look up the IDs of the two SPOTIFY sources so we can match on them
// (the rendered preset XML doesn't echo Username back, so checking the
// embedded <source id=...> attribute is the reliable signal).
sources, _ := ds.GetConfiguredSources(account, device)
placeholderID := findSourceID(sources, "SPOTIFY", PlaceholderSpotifyConnectAccount)
oauthID := findSourceID(sources, "SPOTIFY", "gesellix")
if placeholderID == "" || oauthID == "" || placeholderID == oauthID {
t.Fatalf("test setup wrong: placeholderID=%q oauthID=%q", placeholderID, oauthID)
}
// Speaker payload modeled on what rhino sent: source/sourceAccount carry
// the firmware-internal slug, sourceid is absent (the speaker doesn't
// know our SRC_ IDs at storePreset time for a Connect-managed session).
body := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<preset>
<name>Sand Castle Tapes</name>
<source>SPOTIFY</source>
<sourceaccount>SpotifyConnectUserName</sourceaccount>
<location>/playback/container/c3BvdGlmeTphbGJ1bTo3M1I2YXlQS2VWWEV2SnR4UTR4SDNv</location>
<contentItemType>tracklisturl</contentItemType>
<containerArt>https://i.scdn.co/image/ab67616d0000b273</containerArt>
</preset>`)
resp, err := UpdatePreset(ds, account, device, 1, body)
if err != nil {
t.Fatalf("UpdatePreset failed: %v", err)
}
xmlStr := string(resp)
if !strings.Contains(xmlStr, `id="`+placeholderID+`"`) {
t.Errorf("preset bound to wrong source: expected embedded <source id=%q>, got:\n%s", placeholderID, xmlStr)
}
if strings.Contains(xmlStr, `id="`+oauthID+`"`) {
t.Errorf("preset unexpectedly bound to OAuth source <id=%q>; should have hit placeholder.\nResponse: %s", oauthID, xmlStr)
}
}
// TestUpdatePreset_FallsBackToOAuthWhenNoPlaceholderMatch verifies the
// existing type-only fallback still works for presets whose sourceAccount
// matches the OAuth user (or is absent), so OAuth-driven presets keep
// binding correctly.
func TestUpdatePreset_FallsBackToOAuthWhenNoPlaceholderMatch(t *testing.T) {
ds, account, device := newMargeTestDatastore(t)
if _, err := AddSource(ds, account, "gesellix", "15", "bs-deadbeef", "token_version_3", "Gesell IX"); err != nil {
t.Fatalf("AddSource: %v", err)
}
if err := EnsurePlaceholderSources(ds, account, device); err != nil {
t.Fatalf("EnsurePlaceholderSources: %v", err)
}
sources, _ := ds.GetConfiguredSources(account, device)
placeholderID := findSourceID(sources, "SPOTIFY", PlaceholderSpotifyConnectAccount)
oauthID := findSourceID(sources, "SPOTIFY", "gesellix")
if placeholderID == "" || oauthID == "" || placeholderID == oauthID {
t.Fatalf("test setup wrong: placeholderID=%q oauthID=%q", placeholderID, oauthID)
}
body := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<preset>
<name>OAuth Album</name>
<source>SPOTIFY</source>
<sourceaccount>gesellix</sourceaccount>
<location>/playback/container/c3BvdGlmeTphbGJ1bTpvYXV0aA==</location>
<contentItemType>tracklisturl</contentItemType>
</preset>`)
resp, err := UpdatePreset(ds, account, device, 2, body)
if err != nil {
t.Fatalf("UpdatePreset failed: %v", err)
}
xmlStr := string(resp)
if !strings.Contains(xmlStr, `id="`+oauthID+`"`) {
t.Errorf("preset did not bind to OAuth source <id=%q>; got:\n%s", oauthID, xmlStr)
}
if strings.Contains(xmlStr, `id="`+placeholderID+`"`) {
t.Errorf("preset unexpectedly bound to placeholder <id=%q>; OAuth preset collapsed wrong direction.\nResponse: %s", placeholderID, xmlStr)
}
}
func findSourceID(sources []models.ConfiguredSource, sourceKeyType, sourceKeyAccount string) string {
for i := range sources {
if (sources[i].SourceKey.Type == sourceKeyType && sources[i].SourceKey.Account == sourceKeyAccount) ||
(sources[i].SourceKeyType == sourceKeyType && sources[i].SourceKeyAccount == sourceKeyAccount) {
return sources[i].ID
}
}
return ""
}
func newMargeTestDatastore(t *testing.T) (*datastore.DataStore, string, string) {
t.Helper()
tempDir, err := os.MkdirTemp("", "marge-placeholder-test-*")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
ds := datastore.NewDataStore(tempDir)
const account = "1234567"
const device = "DEVPLACE"
info := &models.ServiceDeviceInfo{
DeviceID: device,
AccountID: account,
Name: "Test Speaker",
}
if err := ds.SaveDeviceInfo(account, device, info); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
// marge.AddSource walks accounts/{account}/devices and needs the
// per-device dir present to write configuredsources.xml.
if err := os.MkdirAll(filepath.Join(ds.AccountDevicesDir(account), device), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
return ds, account, device
}
func sourceSummary(sources []models.ConfiguredSource) []string {
out := make([]string, 0, len(sources))
for i := range sources {
out = append(out, sources[i].SourceKey.Type+"/"+sources[i].SourceKey.Account+"#"+sources[i].ID)
}
return out
}