318 lines
12 KiB
Go
318 lines
12 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, 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")
|
|
}
|
|
})
|
|
}
|