diff --git a/pkg/service/setup/migration_readiness_test.go b/pkg/service/setup/migration_readiness_test.go index c54e1d41..d4cd21b6 100644 --- a/pkg/service/setup/migration_readiness_test.go +++ b/pkg/service/setup/migration_readiness_test.go @@ -331,3 +331,48 @@ func TestMigrationDataReadinessExplainsPresetsDroppedByFull(t *testing.T) { t.Errorf("action = %q, want it to point at the missing source", notReady.Action) } } + +// TestMigrationSummaryReportsDataReadiness: the pre-flight panel must show a +// refusal before the user commits, rather than showing all-green and failing +// with a 409 at Apply. +func TestMigrationSummaryReportsDataReadiness(t *testing.T) { + t.Run("refusal", func(t *testing.T) { + m, _, deviceIP := newMigrationReadinessFixture(t, ``) + + // No persisted snapshot, so the check refuses. + summary, err := m.GetMigrationSummary(deviceIP, "http://aftertouch.example:8000", "", nil) + if err != nil { + t.Fatalf("GetMigrationSummary: %v", err) + } + + if summary.DataReadyError == "" { + t.Error("summary hid a refusal that Apply would hit") + } + }) + + t.Run("warning", func(t *testing.T) { + m, ds, deviceIP := newMigrationReadinessFixture(t, ``) + if err := ds.SavePresets(readinessAccount, readinessDevice, nil); err != nil { + t.Fatalf("SavePresets: %v", err) + } + if err := ds.SaveDeviceInfo(readinessAccount, "SIBLING01", &models.ServiceDeviceInfo{ + DeviceID: "SIBLING01", + AccountID: readinessAccount, + Name: "Sibling Speaker", + }); err != nil { + t.Fatalf("SaveDeviceInfo sibling: %v", err) + } + + summary, err := m.GetMigrationSummary(deviceIP, "http://aftertouch.example:8000", "", nil) + if err != nil { + t.Fatalf("GetMigrationSummary: %v", err) + } + + if summary.DataReadyError != "" { + t.Errorf("summary reported a refusal for a shared account: %q", summary.DataReadyError) + } + if len(summary.DataReadyWarnings) != 1 { + t.Errorf("warnings = %v, want one about the device count", summary.DataReadyWarnings) + } + }) +} diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index 57ac70fb..6f38f736 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -80,6 +80,12 @@ type MigrationSummary struct { CurrentResolvConf string `json:"current_resolv_conf,omitempty"` PlannedResolv string `json:"planned_resolv,omitempty"` IsMigrated bool `json:"is_migrated"` + // Data readiness, so the pre-flight panel can show a refusal before the + // user commits to Apply instead of only surfacing it as a 409 afterwards. + // DataReadyError is the reason migration will be refused; empty means it + // will proceed. DataReadyWarnings are advisory and do not block. + DataReadyError string `json:"data_ready_error,omitempty"` + DataReadyWarnings []string `json:"data_ready_warnings,omitempty"` // Per-axis migration signals — IsMigrated is the OR of these. The UI // displays them individually so users can see partial states (e.g. // URLs flipped via telnet but the on-disk XML hasn't caught up, or @@ -303,6 +309,13 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti SSHSuccess: false, } + // Same read-only check MigrateSpeaker runs, reported rather than enforced. + if warnings, err := m.checkMigrationDataReady(deviceIP); err != nil { + summary.DataReadyError = err.Error() + } else { + summary.DataReadyWarnings = warnings + } + // Run the telnet preflight in parallel with the SSH-based probes below. // Both transports are queried independently: SSH gives access to // /etc/hosts, /etc/resolv.conf and the on-device XML config; telnet's