package selfupdate import ( "context" "fmt" "io" "log" "sync" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) const imageBase = "gitea.dooplex.hu/admin/felhom-controller" type fakeAgent struct { mu sync.Mutex calls []string err error } func (f *fakeAgent) SwapController(_ context.Context, image string) (agentapi.SwapResult, error) { f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, image) return agentapi.SwapResult{Status: "swapping", PreviousImage: imageBase + ":0.77.0"}, f.err } func (f *fakeAgent) swapCalls() []string { f.mu.Lock() defer f.mu.Unlock() return append([]string(nil), f.calls...) } func newTestUpdater(t *testing.T, current string, agent AgentSwapper) *Updater { t.Helper() cfg := &config.SelfUpdateConfig{Enabled: true, Image: imageBase} git := &config.GitConfig{Username: "u", Token: "tok"} return NewUpdater(cfg, git, current, t.TempDir(), agent, log.New(io.Discard, "", 0), false) } // waitDone polls until an in-flight update finishes (performUpdate runs async). func waitDone(t *testing.T, u *Updater) { t.Helper() deadline := time.Now().Add(2 * time.Second) for u.IsUpdateRunning() { if time.Now().After(deadline) { t.Fatal("update did not finish in time") } time.Sleep(2 * time.Millisecond) } } // Scenario C — already latest: no pull, no agent call. func TestTriggerUpdate_UpToDate_NoPullNoAgent(t *testing.T) { agent := &fakeAgent{} u := newTestUpdater(t, "0.84.0", agent) u.queryFn = func() (string, error) { return "0.84.0", nil } pulled := false u.pullFn = func(string) error { pulled = true; return nil } err := u.TriggerUpdate("test") if err == nil { t.Fatal("expected 'no update' error when already latest") } if pulled { t.Error("must NOT pull when up-to-date") } if n := len(agent.swapCalls()); n != 0 { t.Errorf("agent called %d times, want 0 (up-to-date)", n) } } // Scenario D — pull fails: agent never called, current controller untouched. func TestPerformUpdate_PullFails_AgentNotCalled(t *testing.T) { agent := &fakeAgent{} u := newTestUpdater(t, "0.77.0", agent) u.queryFn = func() (string, error) { return "0.84.0", nil } u.pullFn = func(string) error { return fmt.Errorf("registry unreachable") } if err := u.TriggerUpdate("test"); err != nil { t.Fatalf("TriggerUpdate returned %v, want nil (async)", err) } waitDone(t, u) if n := len(agent.swapCalls()); n != 0 { t.Errorf("agent called %d times, want 0 (pull failed)", n) } st, _ := LoadState(u.dataDir) if st == nil || st.Status != "failed" { t.Errorf("state = %+v, want failed", st) } } // Happy path — pull then delegate the swap to the agent with the right ref. func TestPerformUpdate_Happy_PullThenSwap(t *testing.T) { agent := &fakeAgent{} u := newTestUpdater(t, "0.77.0", agent) u.queryFn = func() (string, error) { return "0.84.0", nil } var pulledImage string u.pullFn = func(img string) error { pulledImage = img; return nil } if err := u.TriggerUpdate("test"); err != nil { t.Fatalf("TriggerUpdate returned %v, want nil", err) } waitDone(t, u) want := imageBase + ":0.84.0" if pulledImage != want { t.Errorf("pulled %q, want %q", pulledImage, want) } calls := agent.swapCalls() if len(calls) != 1 || calls[0] != want { t.Errorf("agent swap calls = %v, want exactly [%q]", calls, want) } } // No agent (un-provisioned guest) → update unavailable, never pulls. func TestTriggerUpdate_NoAgent_Unavailable(t *testing.T) { u := newTestUpdater(t, "0.77.0", nil) u.queryFn = func() (string, error) { return "0.84.0", nil } pulled := false u.pullFn = func(string) error { pulled = true; return nil } if err := u.TriggerUpdate("test"); err == nil { t.Fatal("expected error when no agent is wired") } if pulled { t.Error("must not pull when no agent") } }