feat(setup): report migration data readiness in the summary

The readiness verdict was only reachable by attempting the migration, so the
UI's pre-flight panel could show every check green and then fail at Apply
with a 409. The user commits to the operation before learning it will be
refused.

GetMigrationSummary now runs the same read-only check and reports it:
data_ready_error carries the reason migration would be refused, and
data_ready_warnings carries the advisory ones. Nothing is enforced here, and
MigrateSpeaker still runs the check itself, so this cannot let a refused
migration through.

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 b43fb05ea1
commit ad65058354
2 changed files with 58 additions and 0 deletions
@@ -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, `<presets/>`)
// 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, `<presets/>`)
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)
}
})
}
+13
View File
@@ -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