From ba7e2da7b3555a4552569c4699a70af07a862d46 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 5 Sep 2026 21:35:58 +0200 Subject: [PATCH] fix(setup): let an unpaired speaker migrate The readiness check refused when the live /info carried no margeAccountUUID, which is exactly the state a factory-reset speaker is in. That made the documented onboarding impossible. The admin UI migrates first and pairs afterwards (see "Pairing runs after the URL flip" in the setup page), and MIGRATION-GUIDE.md step 4 tells the user to Generate an account ID on a factory-reset device. Both now hit a 409 before pairing can run. The suggested remedy could not help either: SyncDeviceData files an account-less device under "default", which never matches an empty live account, so the user had no way forward at all. An unpaired speaker has no account data to preserve, so there is nothing for this check to compare and nothing to lose. Skip it. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/service/setup/migration_readiness.go | 11 ++++++++- pkg/service/setup/migration_readiness_test.go | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/pkg/service/setup/migration_readiness.go b/pkg/service/setup/migration_readiness.go index 2ae36ca5..75cd8de8 100644 --- a/pkg/service/setup/migration_readiness.go +++ b/pkg/service/setup/migration_readiness.go @@ -58,7 +58,16 @@ func (m *Manager) checkMigrationDataReady(deviceIP string) error { } if accountID == "" { - return migrationDataNotReadyf("live /info has no paired margeAccountUUID") + // An unpaired speaker, typically factory-reset, has no account data to + // preserve, so there is nothing for this check to compare and nothing + // to lose. Refusing here would also break the documented onboarding + // order: the admin UI migrates first and pairs afterwards (see the + // "Pairing runs after the URL flip" comment in the setup page), and + // MIGRATION-GUIDE.md tells the user to Generate an account ID on a + // factory-reset device. Data Sync cannot unblock it either, since it + // files an account-less device under "default", which never matches an + // empty live account. + return nil } if !datastore.IsSafeIdentifier(accountID) || !datastore.IsSafeIdentifier(deviceID) { diff --git a/pkg/service/setup/migration_readiness_test.go b/pkg/service/setup/migration_readiness_test.go index 767e13d5..77d01b85 100644 --- a/pkg/service/setup/migration_readiness_test.go +++ b/pkg/service/setup/migration_readiness_test.go @@ -252,3 +252,27 @@ func assertPresetSnapshotUnchanged(t *testing.T, path string, want []byte) { t.Fatalf("readiness preflight rewrote Presets.xml\n got: %s\nwant: %s", after, want) } } + +// TestMigrationDataReadinessAllowsUnpairedSpeaker: a factory-reset speaker has +// no account data to preserve, and the admin UI migrates before it pairs, so +// refusing here would make onboarding impossible. Data Sync could not unblock +// it either: an account-less device is filed under "default", which never +// matches an empty live account. +func TestMigrationDataReadinessAllowsUnpairedSpeaker(t *testing.T) { + speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/info" { + _, _ = fmt.Fprintf(w, `Fresh Speaker`, readinessDevice) + + return + } + + http.NotFound(w, r) + })) + defer speaker.Close() + + m := NewManager("http://aftertouch.example:8000", datastore.NewDataStore(t.TempDir()), nil) + + if err := m.checkMigrationDataReady(strings.TrimPrefix(speaker.URL, "http://")); err != nil { + t.Fatalf("unpaired speaker was refused migration: %v", err) + } +}