Files
RenewCMS/test/update_restart_usecase_test.go
T

318 lines
13 KiB
Go

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)
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(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(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(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(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(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(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(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")
}
})
}