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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-09-05 21:55:20 +02:00
co-authored by Claude Opus 5
parent b9f0c16275
commit ba7e2da7b3
2 changed files with 34 additions and 1 deletions
+10 -1
View File
@@ -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) {
@@ -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, `<info deviceID="%s"><name>Fresh Speaker</name><margeAccountUUID></margeAccountUUID></info>`, 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)
}
}