package state import ( "errors" "os" "path/filepath" "testing" ) // statePath returns a per-test state file path inside t.TempDir(). func statePath(t *testing.T) string { t.Helper() return filepath.Join(t.TempDir(), "state.json") } func TestLoadMissingReturnsIdle(t *testing.T) { s, err := Load(filepath.Join(t.TempDir(), "does-not-exist.json")) if err != nil { t.Fatalf("unexpected error loading missing state: %v", err) } if s.Phase != PhaseIdle { t.Errorf("missing file: phase=%q, want %q", s.Phase, PhaseIdle) } } func TestSaveLoadRoundTrip(t *testing.T) { path := statePath(t) in := &UpdateState{ Phase: PhaseStaged, FromVersion: "v0.2.0", ToVersion: "v0.3.0", AttemptCount: 1, } if err := in.Save(path); err != nil { t.Fatalf("save: %v", err) } out, err := Load(path) if err != nil { t.Fatalf("load: %v", err) } if out.Phase != in.Phase { t.Errorf("phase: got %q, want %q", out.Phase, in.Phase) } if out.FromVersion != in.FromVersion { t.Errorf("from_version: got %q, want %q", out.FromVersion, in.FromVersion) } if out.ToVersion != in.ToVersion { t.Errorf("to_version: got %q, want %q", out.ToVersion, in.ToVersion) } if out.AttemptCount != in.AttemptCount { t.Errorf("attempt_count: got %d, want %d", out.AttemptCount, in.AttemptCount) } if out.UpdatedAt.IsZero() { t.Error("UpdatedAt should be set by Save") } } func TestSaveRejectsInvalidPhase(t *testing.T) { s := &UpdateState{Phase: Phase("bogus")} err := s.Save(statePath(t)) if err == nil { t.Fatal("expected error saving invalid phase, got nil") } } func TestSaveIsAtomic(t *testing.T) { // After Save, the .tmp file should NOT exist — confirming we renamed it. path := statePath(t) s := New() if err := s.Save(path); err != nil { t.Fatalf("save: %v", err) } if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { t.Errorf("tmp file still present after Save: %v", err) } } func TestSaveCreatesDirectory(t *testing.T) { // State directory may not exist yet (first-ever boot). Save() should mkdir. dir := filepath.Join(t.TempDir(), "fresh", "subdir") path := filepath.Join(dir, "state.json") if err := New().Save(path); err != nil { t.Fatalf("save into nonexistent dir: %v", err) } if _, err := os.Stat(path); err != nil { t.Errorf("state file not present after Save: %v", err) } } func TestTransitionIdleToChecking(t *testing.T) { path := statePath(t) s := New() if err := s.Transition(path, PhaseChecking, "v0.3.0", ""); err != nil { t.Fatalf("transition: %v", err) } if s.Phase != PhaseChecking { t.Errorf("phase: got %q, want %q", s.Phase, PhaseChecking) } if s.ToVersion != "v0.3.0" { t.Errorf("to_version: got %q, want v0.3.0", s.ToVersion) } if s.AttemptCount != 1 { t.Errorf("attempt_count: got %d, want 1 (first attempt after Idle)", s.AttemptCount) } if s.StartedAt.IsZero() { t.Error("StartedAt should be set when leaving Idle") } } func TestTransitionRetainsAttemptCountWithinAttempt(t *testing.T) { path := statePath(t) s := New() _ = s.Transition(path, PhaseChecking, "v0.3.0", "") _ = s.Transition(path, PhaseDownloading, "v0.3.0", "") _ = s.Transition(path, PhaseStaged, "v0.3.0", "") if s.AttemptCount != 1 { t.Errorf("attempt_count after staying on same version: got %d, want 1", s.AttemptCount) } } func TestTransitionResetsAttemptCountOnNewVersion(t *testing.T) { path := statePath(t) s := New() _ = s.Transition(path, PhaseChecking, "v0.3.0", "") // Now an attempt at a NEW version starts. AttemptCount should reset. _ = s.Transition(path, PhaseChecking, "v0.4.0", "") if s.ToVersion != "v0.4.0" { t.Errorf("to_version: got %q, want v0.4.0", s.ToVersion) } if s.AttemptCount != 0 { t.Errorf("attempt_count after new ToVersion: got %d, want 0 (reset)", s.AttemptCount) } } func TestTransitionFailedRecordsError(t *testing.T) { path := statePath(t) s := New() _ = s.Transition(path, PhaseDownloading, "v0.3.0", "") _ = s.Transition(path, PhaseFailed, "v0.3.0", "checksum mismatch") if s.Phase != PhaseFailed { t.Errorf("phase: got %q, want %q", s.Phase, PhaseFailed) } if s.LastError != "checksum mismatch" { t.Errorf("last_error: got %q, want %q", s.LastError, "checksum mismatch") } } func TestTransitionSuccessClearsError(t *testing.T) { path := statePath(t) s := New() _ = s.Transition(path, PhaseFailed, "v0.3.0", "boom") if s.LastError == "" { t.Fatal("setup: LastError should be non-empty before success") } _ = s.Transition(path, PhaseSuccess, "v0.3.0", "") if s.LastError != "" { t.Errorf("last_error after success: got %q, want empty", s.LastError) } } func TestRecordError(t *testing.T) { path := statePath(t) s := New() if err := s.RecordError(path, errors.New("network down")); err != nil { t.Fatalf("RecordError: %v", err) } if s.Phase != PhaseFailed { t.Errorf("phase: got %q, want %q", s.Phase, PhaseFailed) } if s.LastError != "network down" { t.Errorf("last_error: got %q, want %q", s.LastError, "network down") } } func TestSetFromVersionIdempotent(t *testing.T) { s := New() s.SetFromVersion("v0.2.0") if s.FromVersion != "v0.2.0" { t.Errorf("from_version: got %q, want v0.2.0", s.FromVersion) } // Second call should not overwrite. s.SetFromVersion("v0.1.0") if s.FromVersion != "v0.2.0" { t.Errorf("from_version after second SetFromVersion: got %q, want v0.2.0 (immutable)", s.FromVersion) } } func TestLoadHandlesGarbageFile(t *testing.T) { path := statePath(t) if err := os.WriteFile(path, []byte("not json"), 0o644); err != nil { t.Fatalf("seed: %v", err) } _, err := Load(path) if err == nil { t.Error("expected error loading garbage, got nil") } }