package selfupdate import ( "bytes" "context" "encoding/json" "io" "log/slog" "os" "path/filepath" "testing" "time" ) // writePending drops a pending.json fixture into the staging dir. func writePending(t *testing.T, stateDir string, m PendingMarker) { t.Helper() dir := stagingDir(stateDir) if err := os.MkdirAll(dir, 0o750); err != nil { t.Fatal(err) } b, _ := json.Marshal(m) if err := os.WriteFile(filepath.Join(dir, "pending.json"), b, 0o644); err != nil { t.Fatal(err) } } func newMgr(t *testing.T, stateDir, runningVersion string, wrap WrapperRunner, buf *bytes.Buffer) *Manager { t.Helper() var w io.Writer = io.Discard if buf != nil { w = buf } m := NewManager(ManagerConfig{ StateDir: stateDir, RunningVersion: runningVersion, Dwell: time.Hour, // never elapses in the test; we drive it via the sleep seam Runner: wrap, Logger: slog.New(slog.NewTextHandler(w, nil)), }) // Replace the real dwell sleep with an instant one so the test doesn't block. m.sleep = func(context.Context, time.Duration) {} return m } // Scenario D: pending marker names THIS version → after the dwell, `commit` is called. func TestManager_CommitsMatchingVersion(t *testing.T) { stateDir := t.TempDir() writePending(t, stateDir, PendingMarker{OldVersion: "0.70.0", NewVersion: "0.70.1", SHA256: "ab", AppliedAt: "t"}) wrap := &fakeWrapper{} m := newMgr(t, stateDir, "0.70.1", wrap, nil) m.MaybeCommit(context.Background()) if len(wrap.calls) != 1 || wrap.calls[0][1] != "commit" { t.Fatalf("expected exactly one `commit` call, got %v", wrap.calls) } } // Scenario C5: pending marker version != running version → commit is NEVER called; a loud WARN is // logged; the marker is LEFT (so the report keeps showing pending=true). func TestManager_VersionMismatchDoesNotCommit(t *testing.T) { stateDir := t.TempDir() writePending(t, stateDir, PendingMarker{OldVersion: "0.70.0", NewVersion: "0.70.5", SHA256: "ab", AppliedAt: "t"}) wrap := &fakeWrapper{} buf := &bytes.Buffer{} m := newMgr(t, stateDir, "0.70.1", wrap, buf) // running 0.70.1, marker says 0.70.5 m.MaybeCommit(context.Background()) if len(wrap.calls) != 0 { t.Errorf("commit called despite version mismatch: %v", wrap.calls) } if !bytes.Contains(buf.Bytes(), []byte("NOT committing")) { t.Errorf("expected a loud WARN; logs:\n%s", buf.String()) } // Marker must remain so the report still flags pending. if p, _ := readPending(stateDir); p == nil { t.Error("pending marker was removed on a version mismatch (report would lose visibility)") } // And the report seam reflects it. if pending, ver := m.SelfUpdatePending(); !pending || ver != "0.70.5" { t.Errorf("SelfUpdatePending() = %v/%q, want true/0.70.5", pending, ver) } } // No pending marker → nothing happens (the common steady state). func TestManager_NoPendingNoOp(t *testing.T) { stateDir := t.TempDir() wrap := &fakeWrapper{} m := newMgr(t, stateDir, "0.70.1", wrap, nil) m.MaybeCommit(context.Background()) if len(wrap.calls) != 0 { t.Errorf("commit/anything called with no pending marker: %v", wrap.calls) } if pending, _ := m.SelfUpdatePending(); pending { t.Error("SelfUpdatePending() true with no marker") } } // Shutdown before the dwell elapses → no commit, marker left for the next start. func TestManager_ShutdownBeforeDwellLeavesPending(t *testing.T) { stateDir := t.TempDir() writePending(t, stateDir, PendingMarker{NewVersion: "0.70.1"}) wrap := &fakeWrapper{} m := newMgr(t, stateDir, "0.70.1", wrap, nil) // A cancelled ctx before the (seam) sleep → MaybeCommit sees ctx.Err() and bails. ctx, cancel := context.WithCancel(context.Background()) cancel() m.MaybeCommit(ctx) if len(wrap.calls) != 0 { t.Errorf("commit called during shutdown: %v", wrap.calls) } if p, _ := readPending(stateDir); p == nil { t.Error("pending marker removed on shutdown-before-commit") } }