package selfupdate import ( "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) // R-608 (v0.261.0) — the self-updater's half of the lock. // // The controller swaps itself daily at `self_update.auto_update_time` (04:30 by default) AND after // any hub report once a floor sits above this box — so at any hour. That swap restarts this process. // Until v0.261.0 the only busy gate was `backupRunning`, which covers the app update's `backing-up` // phase (it takes the backup single-flight) and none of the others. // TestR608_TriggerUpdateRefusedWhileAnAppUpdates is a CONSEQUENCE test: does the swap actually // refuse, and does it say so in a sentence the household can read in its own language? // // COMPANION RED-PROOF (run 2026-09-21): delete the `u.appUpdating` block from TriggerUpdate. The // refusal assertion then fails — the swap proceeds on top of a live app update. func TestR608_TriggerUpdateRefusedWhileAnAppUpdates(t *testing.T) { agent := &fakeAgent{} u := newTestUpdater(t, "0.260.0", agent) u.queryFn = func() (string, error) { return "0.261.0", nil } u.pullFn = func(string) error { return nil } appUpdating := true u.SetAppUpdatingCheck(func() bool { return appUpdating }) err := u.TriggerUpdate("manual") if err == nil { t.Fatal("while a guarded app update is in flight the controller swap must be REFUSED") } if m, ok := util.AsMsg(err); !ok { t.Error("the refusal must carry a bundle key, or an English household reads Hungarian") } else if m.Key() != "err.selfupdate.alkalmazas_frissites_folyamatban" { t.Errorf("key = %q", m.Key()) } if !strings.Contains(err.Error(), "alkalmaz") { t.Errorf("the Hungarian fallback must name the app update, got %q", err.Error()) } if len(agent.swapCalls()) != 0 { t.Fatalf("NOTHING may reach the agent on a refusal, got %v", agent.swapCalls()) } // TRANSIENT, not latching: the moment the app update ends, the same trigger goes through with no // human action in between. A gate that latches would strand the box on an old controller. appUpdating = false if err := u.TriggerUpdate("manual"); err != nil { t.Fatalf("once the app update has finished the swap must proceed, got %v", err) } waitDone(t, u) if len(agent.swapCalls()) != 1 { t.Errorf("expected exactly one swap after the gate cleared, got %v", agent.swapCalls()) } } // TestR608_DryRunReportsTheAppUpdate — the operator's dry run must SAY why an update will not run. // A dry run that reports "available" while the trigger would refuse is a confident wrong answer. func TestR608_DryRunReportsTheAppUpdate(t *testing.T) { u := newTestUpdater(t, "0.260.0", &fakeAgent{}) u.queryFn = func() (string, error) { return "0.261.0", nil } if got := u.DryRun().AppUpdating; got { t.Error("control: with no app update in flight the dry run must report false") } u.SetAppUpdatingCheck(func() bool { return true }) if got := u.DryRun().AppUpdating; !got { t.Error("the dry run must report that an app update is holding the swap") } } // TestR608_NilAppUpdatingCheckIsSafe — unwired means the pre-v0.261.0 behaviour, never fail-closed. // Failing closed on an UNWIRED gate would strand every box whose construction path misses it. func TestR608_NilAppUpdatingCheckIsSafe(t *testing.T) { agent := &fakeAgent{} u := newTestUpdater(t, "0.260.0", agent) u.queryFn = func() (string, error) { return "0.261.0", nil } u.pullFn = func(string) error { return nil } if err := u.TriggerUpdate("manual"); err != nil { t.Fatalf("an unwired app-update gate must not refuse, got %v", err) } waitDone(t, u) if u.DryRun().AppUpdating { t.Error("an unwired gate must report false, not true") } }