package test import ( "RenewCMS/internal/domain/update" "RenewCMS/internal/infrastructure/useCases" "errors" "sync" "testing" "time" ) var restartBaseTime = time.Date(2026, 9, 30, 9, 50, 0, 0, time.UTC) type restartRig struct { store *fakeUpdateStore jobs *fakeJobStore helper *fakeHelperDetector results *fakeResultStore requests *fakeRestartRequestStore clock *fakeClock request *useCases.RequestRestartUseCase status *useCases.GetUpdateStatusUseCase } func newRestartRig(running string) *restartRig { rig := &restartRig{ store: &fakeUpdateStore{result: newStoredUpdateResult(), found: true}, jobs: &fakeJobStore{}, helper: &fakeHelperDetector{installed: true}, results: &fakeResultStore{}, requests: &fakeRestartRequestStore{}, clock: &fakeClock{now: restartBaseTime}, } rig.request = useCases.NewRequestRestartUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, rig.clock.Now, discardRecorder) rig.status = useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, false, rig.clock.Now) return rig } func (r *restartRig) readyJob() { r.jobs.put(update.Job{Tag: downloadTag, State: update.StateReady, StartedAt: restartBaseTime.Add(-time.Hour)}) } func finishedAt(at time.Time) *time.Time { return &at } func TestRequestRestartUseCase(t *testing.T) { t.Run("a ready job gives an update marker with the tag and the requester", func(t *testing.T) { rig := newRestartRig(updateRunning) rig.readyJob() rig.results.put(update.Result{Outcome: update.OutcomeRolledBack}) status, err := rig.request.Request(testCtx, updateOwnerId, "") if err != nil { t.Fatal(err) } marker, markerErr := rig.requests.Read() if markerErr != nil || marker.Kind != update.RestartKindUpdate || marker.Tag != downloadTag || marker.RequestedBy != updateOwnerId || !marker.RequestedAt.Equal(restartBaseTime) { t.Errorf("marker = %+v, err = %v", marker, markerErr) } if status.State != update.StateRestarting || status.StartedAt == nil || !status.StartedAt.Equal(marker.RequestedAt) || !status.CanApply { t.Errorf("status = %+v", status) } if rig.results.deletes != 1 || rig.results.found { t.Errorf("the result must be deleted first: deletes = %d", rig.results.deletes) } }) t.Run("no ready job gives a plain restart marker without a tag", func(t *testing.T) { for name, prepare := range map[string]func(*restartRig){ "idle": func(*restartRig) {}, "failed job": func(r *restartRig) { r.jobs.put(update.Job{Tag: downloadTag, State: update.StateFailed, Error: "download"}) }, "rolled back": func(r *restartRig) { r.results.put(update.Result{Outcome: update.OutcomeRolledBack, Kind: update.RestartKindUpdate}) }, } { t.Run(name, func(t *testing.T) { rig := newRestartRig(updateRunning) prepare(rig) status, err := rig.request.Request(testCtx, updateOwnerId, update.RestartKindRestart) marker, _ := rig.requests.Read() if err != nil || marker.Kind != update.RestartKindRestart || marker.Tag != "" || status.State != update.StateRestarting { t.Errorf("err = %v, marker = %+v, status = %+v", err, marker, status) } }) } }) t.Run("a kind that does not match the state is refused and nothing changes", func(t *testing.T) { cases := map[string]struct { prepare func(*restartRig) kind string }{ "update without a ready job": {func(*restartRig) {}, update.RestartKindUpdate}, "update after a failed job": {func(r *restartRig) { r.jobs.put(update.Job{Tag: downloadTag, State: update.StateFailed}) }, update.RestartKindUpdate}, "restart with a ready job": {func(r *restartRig) { r.readyJob() }, update.RestartKindRestart}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { rig := newRestartRig(updateRunning) rig.results.put(update.Result{Outcome: update.OutcomeFailed}) tc.prepare(rig) jobBefore, _ := rig.jobs.Load() _, err := rig.request.Request(testCtx, updateOwnerId, tc.kind) if !errors.Is(err, update.ErrRestartKindMismatch) { t.Fatalf("err = %v", err) } jobAfter, _ := rig.jobs.Load() if rig.requests.writes != 0 || rig.results.deletes != 0 || !rig.results.found || jobAfter != jobBefore { t.Error("nothing may change on a mismatch") } }) } }) t.Run("a restart is refused while it cannot or must not run", func(t *testing.T) { cases := map[string]struct { prepare func(*restartRig) want error }{ "no helper": {func(r *restartRig) { r.helper.installed = false }, update.ErrNotPrepared}, "downloading": {func(r *restartRig) { r.jobs.put(update.Job{State: update.StateDownloading}) }, update.ErrJobInProgress}, "verifying": {func(r *restartRig) { r.jobs.put(update.Job{State: update.StateVerifying}) }, update.ErrJobInProgress}, "marker pending": {func(r *restartRig) { r.requests.present = true }, update.ErrRestartPending}, "helper at work": {func(r *restartRig) { r.results.put(update.Result{Outcome: update.OutcomeRunning, StartedAt: restartBaseTime.Add(-time.Minute)}) }, update.ErrRestartPending}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { rig := newRestartRig(updateRunning) tc.prepare(rig) resultBefore, foundBefore := rig.results.Read() writesBefore := rig.requests.writes _, err := rig.request.Request(testCtx, updateOwnerId, "") resultAfter, foundAfter := rig.results.Read() if !errors.Is(err, tc.want) || rig.requests.writes != writesBefore || resultAfter != resultBefore || foundAfter != foundBefore || rig.results.deletes != 0 { t.Errorf("err = %v", err) } }) } }) t.Run("a stale running result does not block a new restart", func(t *testing.T) { rig := newRestartRig(updateRunning) rig.results.put(update.Result{Outcome: update.OutcomeRunning, StartedAt: restartBaseTime.Add(-6 * time.Minute)}) _, err := rig.request.Request(testCtx, updateOwnerId, "") if err != nil || rig.requests.writes != 1 { t.Errorf("err = %v, writes = %d", err, rig.requests.writes) } }) t.Run("a marker write error is returned and leaves the job alone", func(t *testing.T) { rig := newRestartRig(updateRunning) rig.readyJob() rig.requests.writeErr = errors.New("disk full at /var/lib/renewcms") jobBefore, _ := rig.jobs.Load() _, err := rig.request.Request(testCtx, updateOwnerId, "") jobAfter, _ := rig.jobs.Load() if err == nil || errors.Is(err, update.ErrRestartKindMismatch) || rig.requests.present || jobAfter != jobBefore { t.Errorf("err = %v", err) } }) t.Run("two simultaneous requests write one marker", func(t *testing.T) { rig := newRestartRig(updateRunning) var wait sync.WaitGroup errs := make([]error, 2) for i := range errs { wait.Add(1) go func() { defer wait.Done() _, errs[i] = rig.request.Request(testCtx, uint32(i+1), "") }() } wait.Wait() accepted, refused := 0, 0 for _, err := range errs { switch { case err == nil: accepted++ case errors.Is(err, update.ErrRestartPending): refused++ } } if accepted != 1 || refused != 1 || rig.requests.writes != 1 { t.Errorf("accepted = %d, refused = %d, writes = %d", accepted, refused, rig.requests.writes) } }) } func (f *fakeResultStore) put(result update.Result) { f.mu.Lock() defer f.mu.Unlock() f.result = result f.found = true } func TestUpdateStatusFromFiles(t *testing.T) { running := updateRunning started := restartBaseTime.Add(-30 * time.Second) finished := restartBaseTime.Add(-10 * time.Second) cases := []struct { name string marker bool result *update.Result corrupt bool state string errKind string finished *time.Time }{ {"a marker is present", true, nil, false, "restarting", "", nil}, {"a marker wins over a finished result", true, &update.Result{Outcome: update.OutcomeFailed, StartedAt: started, FinishedAt: &finished}, false, "restarting", "", &finished}, {"result running written 30 s ago", false, &update.Result{Outcome: update.OutcomeRunning, StartedAt: started}, false, "restarting", "", nil}, {"result running just under 5 minutes", false, &update.Result{Outcome: update.OutcomeRunning, StartedAt: restartBaseTime.Add(-5*time.Minute + time.Second)}, false, "restarting", "", nil}, {"result running for 5 minutes, no marker", false, &update.Result{Outcome: update.OutcomeRunning, StartedAt: restartBaseTime.Add(-5 * time.Minute), FinishedAt: &finished}, false, "failed", "install", &finished}, {"result running written 6 minutes ago, no marker", false, &update.Result{Outcome: update.OutcomeRunning, StartedAt: restartBaseTime.Add(-6 * time.Minute), FinishedAt: &finished}, false, "failed", "install", &finished}, {"succeeded update with the running tag", false, &update.Result{Outcome: update.OutcomeSucceeded, Kind: update.RestartKindUpdate, Tag: running, StartedAt: started, FinishedAt: &finished}, false, "succeeded", "", &finished}, {"succeeded update with an older tag", false, &update.Result{Outcome: update.OutcomeSucceeded, Kind: update.RestartKindUpdate, Tag: "v2.1.0-beta", StartedAt: started, FinishedAt: &finished}, false, "idle", "", &finished}, {"succeeded plain restart", false, &update.Result{Outcome: update.OutcomeSucceeded, Kind: update.RestartKindRestart, StartedAt: started, FinishedAt: &finished}, false, "idle", "", &finished}, {"rolled back", false, &update.Result{Outcome: update.OutcomeRolledBack, Kind: update.RestartKindUpdate, Tag: "v2.3.0-beta", StartedAt: started, FinishedAt: &finished}, false, "failed", "rolled_back", &finished}, {"failed", false, &update.Result{Outcome: update.OutcomeFailed, Kind: update.RestartKindUpdate, StartedAt: started, FinishedAt: &finished}, false, "failed", "install", &finished}, {"no marker, no result", false, nil, false, "idle", "", nil}, {"a corrupt result file", false, nil, true, "idle", "", nil}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { rig := newRestartRig(running) rig.requests.present = tc.marker if tc.marker { rig.requests.request = update.RestartRequest{Kind: update.RestartKindRestart, RequestedAt: started} } if tc.result != nil { rig.results.put(*tc.result) } if tc.marker || tc.result != nil { rig.jobs.put(update.Job{Tag: downloadTag, State: update.StateReady, StartedAt: started}) } first := rig.status.GetStatus(true) second := rig.status.GetStatus(true) if first.State != tc.state || (tc.errKind == "") != (first.Error == nil) || (first.Error != nil && *first.Error != tc.errKind) { t.Errorf("state = %q, error = %v", first.State, first.Error) } if (tc.finished == nil) != (first.FinishedAt == nil) || (tc.finished != nil && !first.FinishedAt.Equal(*tc.finished)) { t.Errorf("finished_at = %v", first.FinishedAt) } if first.Progress != nil && tc.state != "ready" { t.Errorf("progress = %v", first.Progress) } if first.State != second.State || rig.results.deletes != 0 || rig.requests.deletes != 0 || rig.results.writes != nil || rig.requests.writes != 0 { t.Error("GET must not change anything") } }) } t.Run("without any file a ready job is reported as ready", func(t *testing.T) { rig := newRestartRig(running) rig.readyJob() status := rig.status.GetStatus(true) if status.State != update.StateReady || status.FinishedAt != nil { t.Errorf("status = %+v", status) } }) t.Run("after a successful update nothing is available and current is the new version", func(t *testing.T) { rig := newRestartRig(downloadTag) rig.results.put(update.Result{Outcome: update.OutcomeSucceeded, Kind: update.RestartKindUpdate, Tag: downloadTag, StartedAt: started, FinishedAt: &finished}) status := rig.status.GetStatus(true) if status.State != update.StateSucceeded || status.Available || status.Current != downloadTag || status.FinishedAt == nil { t.Errorf("status = %+v", status) } }) } func TestRecordRunningInfoUseCase(t *testing.T) { t.Run("it writes the version, the pid and the start instant", func(t *testing.T) { store := &fakeRunningInfoStore{} clock := &fakeClock{now: restartBaseTime} err := useCases.NewRecordRunningInfoUseCase(store, "v2.3.0-beta", 4242, clock.Now).Record() want := update.RunningInfo{Version: "v2.3.0-beta", Pid: 4242, StartedAt: restartBaseTime} if err != nil || len(store.writes) != 1 || store.writes[0] != want { t.Errorf("err = %v, writes = %+v", err, store.writes) } }) t.Run("a write error is returned, not raised", func(t *testing.T) { store := &fakeRunningInfoStore{writeErr: errors.New("read-only file system")} err := useCases.NewRecordRunningInfoUseCase(store, "v2.3.0-beta", 1, time.Now).Record() if err == nil { t.Error("the error must be returned so the startup code can log it") } }) }