Files
gosentry/src/app/operations_test.go
T
mix ca2a8c8aa7 fix: accept zero retention limits, retire Store() for typed accessors
Phase 8 (PROJECT_REVIEW_PLAN.md 8.1): 0 in MaxLogFiles/MaxLogAgeDays now
means "keep everything" end to end. runner.CleanupLogs already treated
<= 0 as disabled; validateConfig, the Settings form, and
loadOrCreateConfig's backfill were the only things making that state
unreachable.

Phase 9 (1.1, rolling up 1.2, 1.3, 7.3): added Service.Config() and
Service.Paths(), copying under mu, and converted every UI site that read
Service state through the raw *storage.Store returned by Store() (now
removed). jobs_view's pause control is now driven by refreshView reading
svc.Config().Paused on every event instead of only mirroring its own tap
handler, which makes it an actual consumer of SchedulerStateChanged.
mainwindow's event listener is a real type switch, and events.go's doc
comment no longer claims a compiler exhaustiveness check Go doesn't have.
Unexported the redundant SetAutostart/AutostartStatus package functions
in platform/autostart now that only the Manager methods are used outside
the package.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 22:15:19 +03:00

984 lines
33 KiB
Go

package app
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/storage"
)
// newTempService builds a Service backed by a store rooted in a temp directory,
// so the mutating operations can persist to real (throwaway) files.
func newTempService(t *testing.T, jobs []domain.Job) *Service {
t.Helper()
dir := t.TempDir()
store := &storage.Store{
Paths: storage.Paths{
ExecutablePath: filepath.Join(dir, "gosentry"),
AppDir: dir,
ConfigPath: filepath.Join(dir, "gosentry.json"),
JobsDir: dir,
JobsPath: filepath.Join(dir, "jobs.json"),
LogsDir: filepath.Join(dir, "logs"),
},
Config: domain.Config{JobsFile: "jobs.json", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30, ExecutionMode: domain.ExecutionModeParallel, OverlapPolicy: domain.OverlapPolicySkip, DefaultTimeoutSeconds: 30},
}
return NewService(store, jobs)
}
// recorder is a test observer that captures every emitted event.
type recorder struct {
events []Event
}
func (r *recorder) OnEvent(e Event) { r.events = append(r.events, e) }
func (r *recorder) jobChanged() (ids []int) {
for _, e := range r.events {
if jc, ok := e.(JobChanged); ok {
ids = append(ids, jc.JobID)
}
}
return ids
}
func (r *recorder) records() (out []domain.RunRecord) {
for _, e := range r.events {
if rr, ok := e.(RunRecorded); ok {
out = append(out, rr.Record)
}
}
return out
}
func TestCreateJobAssignsIDAndEmits(t *testing.T) {
svc := newTempService(t, nil)
rec := &recorder{}
svc.Subscribe(rec)
created, err := svc.CreateJob(domain.Job{Name: "Build", Schedule: "@every 1m", Command: "echo hi", Enabled: true})
if err != nil {
t.Fatalf("CreateJob: %v", err)
}
if created.ID != 1 {
t.Errorf("first job ID = %d, want 1", created.ID)
}
if got := svc.Jobs(); len(got) != 1 || got[0].Name != "Build" {
t.Fatalf("jobs after create = %+v", got)
}
if rt := svc.Runtime(1); rt == nil || rt.LastState != "Ready" {
t.Errorf("runtime = %+v, want LastState Ready", rt)
}
if recs := rec.records(); len(recs) != 1 || recs[0].State != "Created" {
t.Errorf("records = %+v, want one Created", recs)
}
if ids := rec.jobChanged(); len(ids) != 1 || ids[0] != 1 {
t.Errorf("JobChanged ids = %v, want [1]", ids)
}
// A second job takes the next free ID.
second, err := svc.CreateJob(domain.Job{Name: "Two", Schedule: "@every 1m", Command: "echo two"})
if err != nil {
t.Fatalf("CreateJob 2: %v", err)
}
if second.ID != 2 {
t.Errorf("second job ID = %d, want 2", second.ID)
}
}
func TestCreateJobValidates(t *testing.T) {
svc := newTempService(t, nil)
if _, err := svc.CreateJob(domain.Job{Schedule: "@every 1m", Command: "echo"}); err == nil {
t.Error("expected error for missing name")
}
if got := svc.Jobs(); len(got) != 0 {
t.Errorf("invalid job should not be stored, jobs = %+v", got)
}
if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", OverlapPolicy: "invalid"}); err == nil {
t.Error("expected error for invalid overlap policy")
}
if _, err := svc.CreateJob(domain.Job{Name: "A", Schedule: "@every 1m", Command: "echo", TimeoutSeconds: domain.TimeoutSecondsPtr(-1)}); err == nil {
t.Error("expected error for negative per-job timeout")
}
// An explicit 0 is a valid choice ("no timeout"), not a rejected one.
if _, err := svc.CreateJob(domain.Job{Name: "Zero", Schedule: "@every 1m", Command: "echo", TimeoutSeconds: domain.TimeoutSecondsPtr(0)}); err != nil {
t.Errorf("explicit zero per-job timeout should be accepted: %v", err)
}
}
func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: true}})
if err := svc.UpdateJob(domain.Job{ID: 5, Name: "New", Schedule: "@every 1m", Command: "echo", Enabled: false}); err != nil {
t.Fatalf("UpdateJob: %v", err)
}
got := svc.Jobs()
if got[0].Name != "New" || got[0].Enabled {
t.Errorf("job after update = %+v", got[0])
}
if rt := svc.Runtime(5); rt == nil || rt.LastState != "Paused" || rt.NextRun != "Paused" {
t.Errorf("runtime after disable = %+v", rt)
}
}
func TestUpdateJobReenablesPausedJob(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: false}})
if rt := svc.Runtime(5); rt.LastState != "Paused" {
t.Fatalf("precondition: runtime = %+v, want Paused", rt)
}
if err := svc.UpdateJob(domain.Job{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: true}); err != nil {
t.Fatalf("UpdateJob: %v", err)
}
if rt := svc.Runtime(5); rt.LastState != "Ready" || rt.NextDue.IsZero() {
t.Errorf("re-enabled runtime = %+v, want Ready with a next-due", rt)
}
}
// runtimeForLocked lazily recreates a missing runtime entry so the Service stays
// robust if a job somehow lacks one. Dropping the entry and driving an operation
// that needs it exercises that path.
func TestRuntimeLazilyRecreated(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
svc.mu.Lock()
delete(svc.runtimes, 1)
svc.mu.Unlock()
if err := svc.SetEnabled(1, true); err != nil {
t.Fatalf("SetEnabled: %v", err)
}
if rt := svc.Runtime(1); rt == nil {
t.Error("runtime was not lazily recreated")
}
}
func TestUpdateJobNotFound(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.UpdateJob(domain.Job{ID: 99, Name: "X", Schedule: "@every 1m", Command: "echo"}); err == nil {
t.Error("expected not-found error")
}
}
func TestDeleteJobRemovesEverything(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
rec := &recorder{}
svc.Subscribe(rec)
if err := svc.DeleteJob(1); err != nil {
t.Fatalf("DeleteJob: %v", err)
}
if got := svc.Jobs(); len(got) != 0 {
t.Errorf("jobs after delete = %+v", got)
}
if rt := svc.Runtime(1); rt != nil {
t.Errorf("runtime should be gone, got %+v", rt)
}
if recs := rec.records(); len(recs) != 1 || recs[0].State != "Deleted" {
t.Errorf("records = %+v, want one Deleted", recs)
}
if ids := rec.jobChanged(); len(ids) != 1 || ids[0] != 0 {
t.Errorf("JobChanged ids = %v, want [0] (broad)", ids)
}
}
func TestDeleteJobNotFound(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.DeleteJob(42); err == nil {
t.Error("expected not-found error deleting unknown job")
}
}
func TestSetEnabledNotFound(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.SetEnabled(42, true); err == nil {
t.Error("expected not-found error enabling unknown job")
}
}
func TestSetEnabledToggles(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: false}})
if err := svc.SetEnabled(1, true); err != nil {
t.Fatalf("SetEnabled true: %v", err)
}
if rt := svc.Runtime(1); rt.LastState != "Ready" || rt.NextDue.IsZero() {
t.Errorf("enabled runtime = %+v, want Ready with a next-due", rt)
}
if err := svc.SetEnabled(1, false); err != nil {
t.Fatalf("SetEnabled false: %v", err)
}
if rt := svc.Runtime(1); rt.LastState != "Paused" || !rt.NextDue.IsZero() {
t.Errorf("disabled runtime = %+v, want Paused with no next-due", rt)
}
}
// TestSetEnabledClearsPendingRuns verifies that disabling a job drops any
// "queue" overlap backlog it was carrying, so re-enabling it later does not
// replay a deferred run for an occurrence that fired before the disable.
func TestSetEnabledClearsPendingRuns(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
svc.mu.Lock()
svc.runtimes[1].PendingRuns = 2
svc.mu.Unlock()
if err := svc.SetEnabled(1, false); err != nil {
t.Fatalf("SetEnabled false: %v", err)
}
if rt := svc.Runtime(1); rt.PendingRuns != 0 {
t.Errorf("PendingRuns after disable = %d, want 0", rt.PendingRuns)
}
}
func TestSetGlobalPauseUpdatesRuntimesAndEmits(t *testing.T) {
svc := newTempService(t, []domain.Job{
{ID: 1, Name: "On", Schedule: "@every 1m", Command: "echo", Enabled: true},
{ID: 2, Name: "Off", Schedule: "@every 1m", Command: "echo", Enabled: false},
})
rec := &recorder{}
svc.Subscribe(rec)
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
if rt := svc.Runtime(1); rt.NextRun != "Scheduler paused" {
t.Errorf("enabled job next-run = %q, want %q", rt.NextRun, "Scheduler paused")
}
if rt := svc.Runtime(2); rt.NextRun != "Paused" {
t.Errorf("disabled job next-run = %q, want %q", rt.NextRun, "Paused")
}
var sawState bool
for _, e := range rec.events {
if ss, ok := e.(SchedulerStateChanged); ok && ss.Paused {
sawState = true
}
}
if !sawState {
t.Error("expected a SchedulerStateChanged{Paused:true} event")
}
// Resuming recomputes a real next run for the enabled job.
if err := svc.SetGlobalPause(false); err != nil {
t.Fatalf("resume: %v", err)
}
if rt := svc.Runtime(1); rt.NextDue.IsZero() {
t.Errorf("resumed enabled job should have a next-due, got %+v", rt)
}
}
func TestRunNowUsesRunnerAndRecords(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan domain.RunRecord, 1)
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string, _ time.Duration) (domain.RunRecord, error) {
if trigger != "Manual" {
t.Errorf("trigger = %q, want Manual", trigger)
}
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"}, nil
}
svc.Subscribe(ObserverFunc(func(e Event) {
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 {
select {
case done <- rr.Record:
default:
}
}
}))
if err := svc.RunNow(1); err != nil {
t.Fatalf("RunNow: %v", err)
}
select {
case record := <-done:
if record.State != "Success" {
t.Errorf("recorded state = %q, want Success", record.State)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for run to be recorded")
}
if rt := svc.Runtime(1); rt.LastState != "Success" || rt.Output != "ok" {
t.Errorf("runtime after run = %+v", rt)
}
}
func TestRunNowNotFound(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.RunNow(99); err == nil {
t.Error("expected not-found error for unknown job")
}
}
func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
// Park the job in the "Running" state so a second RunNow must refuse: the
// runner signals once it has started and then blocks until released.
entered := make(chan struct{}, 1)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
entered <- struct{}{}
<-release
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := make(chan struct{}, 1)
svc.Subscribe(ObserverFunc(func(e Event) {
if rr, ok := e.(RunRecorded); ok && rr.Record.State == "Success" {
select {
case done <- struct{}{}:
default:
}
}
}))
if err := svc.RunNow(1); err != nil {
t.Fatalf("first RunNow: %v", err)
}
<-entered // the run is now in-flight and blocked
if err := svc.RunNow(1); err == nil {
t.Error("expected RunNow to be refused while already running")
}
close(release)
// Wait for the in-flight run to finish before returning so its background
// writes complete before t.TempDir cleanup removes the directory.
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for the in-flight run to complete")
}
// Only the first run should ever have reached the runner.
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("runner called %d times, want 1", got)
}
}
func TestRunNowAllowedWhilePaused(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan struct{}, 1)
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
select {
case done <- struct{}{}:
default:
}
return domain.RunRecord{State: "Success"}, nil
}
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
// Pause stops only scheduled runs; an explicit manual run is still allowed.
if err := svc.RunNow(1); err != nil {
t.Fatalf("RunNow should be allowed while paused: %v", err)
}
select {
case <-done:
case <-time.After(2 * time.Second):
t.Error("runner was not invoked for a manual run while paused")
}
}
func TestRunDueStartsDueJob(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan domain.RunRecord, 1)
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string, _ time.Duration) (domain.RunRecord, error) {
if trigger != "Schedule" {
t.Errorf("trigger = %q, want Schedule", trigger)
}
return domain.RunRecord{Time: "2026-06-19 12:00:00", JobID: job.ID, JobName: job.Name, State: "Success", Output: "ok"}, nil
}
svc.Subscribe(ObserverFunc(func(e Event) {
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 && rr.Record.State == "Success" {
select {
case done <- rr.Record:
default:
}
}
}))
// The job's next-due was primed ~1m ahead at construction; tick well past it.
svc.RunDue(time.Now().Add(2 * time.Minute))
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("RunDue did not start the due job")
}
if rt := svc.Runtime(1); rt.LastState != "Success" || rt.Output != "ok" {
t.Errorf("runtime after scheduled run = %+v", rt)
}
}
func TestRunDueSkipsJobNotYetDue(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var ran int32
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1)
return domain.RunRecord{}, nil
}
// Next-due is ~1m out, so nothing is due "now".
svc.RunDue(time.Now())
time.Sleep(50 * time.Millisecond)
if atomic.LoadInt32(&ran) != 0 {
t.Error("RunDue ran a job before it was due")
}
}
// TestRunDueSkipsJobInRunningState verifies that RunDue will not start a second
// concurrent instance of a job that is already in "Running" state — even if the
// job's NextDue is in the past. This guards against the window between
// executeRun completing and refreshNextRunLocked setting a new NextDue.
func TestRunDueSkipsJobInRunningState(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var calls int32
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&calls, 1)
return domain.RunRecord{State: "Success"}, nil
}
// Force the job into "Running" with a past NextDue, simulating an in-flight
// run. We set NextDue to a past time so the due check would otherwise pass.
svc.mu.Lock()
rt := svc.runtimes[1]
rt.LastState = "Running"
rt.NextDue = time.Now().Add(-time.Minute)
svc.mu.Unlock()
svc.RunDue(time.Now().Add(2 * time.Minute))
time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&calls); got != 0 {
t.Errorf("RunDue called runner %d time(s) for a job in Running state, want 0", got)
}
}
func TestRunDueDoesNothingWhilePaused(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var ran int32
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1)
return domain.RunRecord{}, nil
}
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
svc.RunDue(time.Now().Add(2 * time.Minute))
time.Sleep(50 * time.Millisecond)
if atomic.LoadInt32(&ran) != 0 {
t.Error("RunDue ran a job while globally paused")
}
}
// appFakeClock is a scheduler.Clock whose tick and "now" the test controls, used
// to verify Start wires the loop to RunDue without the wall clock.
type appFakeClock struct {
ticks chan time.Time
now time.Time
}
func (c *appFakeClock) Now() time.Time { return c.now }
func (c *appFakeClock) Ticks() <-chan time.Time { return c.ticks }
func (c *appFakeClock) Stop() {}
func TestStartDrivesRunDueOnTick(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan struct{}, 1)
svc.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
select {
case done <- struct{}{}:
default:
}
return domain.RunRecord{State: "Success"}, nil
}
clock := &appFakeClock{ticks: make(chan time.Time, 1), now: time.Now().Add(2 * time.Minute)}
svc.StartWith(clock)
defer svc.Stop()
clock.ticks <- clock.now
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Start did not drive a run from a clock tick")
}
}
func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
svc := newTempService(t, nil)
bad := svc.store.Config
bad.MaxLogFiles = -1
if err := svc.UpdateSettings(bad); err == nil {
t.Error("expected validation error for negative max log files")
}
good := svc.store.Config
good.NotifyOnFailure = false
good.MaxLogAgeDays = 7
if err := svc.UpdateSettings(good); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if svc.store.Config.MaxLogAgeDays != 7 || svc.store.Config.NotifyOnFailure {
t.Errorf("config not applied: %+v", svc.store.Config)
}
// 0 means "keep everything" (see STANDARDS §Intentional behavior), not an
// invalid value, so it must be accepted and persisted rather than rejected
// or silently backfilled.
unlimited := svc.store.Config
unlimited.MaxLogFiles = 0
unlimited.MaxLogAgeDays = 0
if err := svc.UpdateSettings(unlimited); err != nil {
t.Fatalf("UpdateSettings with zero retention limits: %v", err)
}
if svc.store.Config.MaxLogFiles != 0 || svc.store.Config.MaxLogAgeDays != 0 {
t.Errorf("zero retention limits not preserved: %+v", svc.store.Config)
}
}
func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
svc := newTempService(t, nil)
base := svc.store.Config
tests := []struct {
name string
mutate func(c *domain.Config)
}{
{"missing jobs file", func(c *domain.Config) { c.JobsFile = " " }},
{"jobs file without a file name", func(c *domain.Config) { c.JobsFile = "jobs" + string(filepath.Separator) }},
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
{"negative max files", func(c *domain.Config) { c.MaxLogFiles = -1 }},
{"negative max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
{"negative default timeout", func(c *domain.Config) { c.DefaultTimeoutSeconds = -1 }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := base
tc.mutate(&cfg)
if err := svc.UpdateSettings(cfg); err == nil {
t.Errorf("expected validation error for %s", tc.name)
}
})
}
}
func TestHasFileName(t *testing.T) {
tests := []struct {
path string
want bool
}{
{"jobs.json", true},
{filepath.Join("data", "team.json"), true},
{"jobs" + string(filepath.Separator), false},
{"data/", false},
{".", false},
{"..", false},
{string(filepath.Separator), false},
}
for _, tc := range tests {
if got := hasFileName(tc.path); got != tc.want {
t.Errorf("hasFileName(%q) = %v, want %v", tc.path, got, tc.want)
}
}
}
// Renaming or relocating the jobs file writes the loaded jobs to the new path,
// which is what makes the Settings change take effect without a restart.
func TestUpdateSettingsWritesJobsToTheNewFile(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Kept", Schedule: "@every 1m", Command: "echo hi", Enabled: true}})
config := svc.store.Config
config.JobsFile = filepath.Join("data", "team-jobs.json")
if err := svc.UpdateSettings(config); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
moved := filepath.Join(svc.store.Paths.AppDir, "data", "team-jobs.json")
if svc.store.Paths.JobsPath != moved {
t.Errorf("JobsPath: got %q, want %q", svc.store.Paths.JobsPath, moved)
}
data, err := os.ReadFile(moved)
if err != nil {
t.Fatalf("read moved jobs file: %v", err)
}
var file domain.JobsFile
if err := json.Unmarshal(data, &file); err != nil {
t.Fatalf("unmarshal moved jobs file: %v", err)
}
if len(file.Jobs) != 1 || file.Jobs[0].Name != "Kept" {
t.Errorf("moved jobs file: got %+v, want the single 'Kept' job", file.Jobs)
}
}
// Pointing Settings at a jobs file that already exists must adopt that file:
// its jobs replace the loaded ones instead of being overwritten by them. This is
// the only way the user can switch between job lists, so the file's contents
// win, the job list is rebuilt around them, and History is told where they came
// from.
func TestUpdateSettingsAdoptsExistingJobsFile(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
rec := &recorder{}
svc.Subscribe(rec)
shared := filepath.Join(svc.store.Paths.AppDir, "shared.json")
existing := domain.JobsFile{Jobs: []domain.Job{
{ID: 4, Name: "Adopted", Schedule: "@every 5m", Command: "echo adopted", Enabled: true},
{Name: "Needs an ID", Schedule: "@every 9m", Command: "echo second", Enabled: false},
}}
data, err := json.Marshal(existing)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(shared, data, 0o644); err != nil {
t.Fatal(err)
}
config := svc.store.Config
config.JobsFile = shared
if err := svc.UpdateSettings(config); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
jobs := svc.Jobs()
if len(jobs) != 2 || jobs[0].Name != "Adopted" {
t.Fatalf("jobs after adoption: got %+v, want the two jobs from the selected file", jobs)
}
// The adopted jobs must be fully live, not just listed: runtime and parsed
// schedule are rebuilt for the IDs the file brought (including the one
// normalization had to assign).
for _, job := range jobs {
if svc.Runtime(job.ID) == nil {
t.Errorf("job %d (%q) has no runtime after adoption", job.ID, job.Name)
}
}
if svc.Runtime(1) != nil {
t.Error("runtime of the replaced job should be gone")
}
var loaded []JobsLoaded
for _, e := range rec.events {
if jl, ok := e.(JobsLoaded); ok {
loaded = append(loaded, jl)
}
}
if len(loaded) != 1 || loaded[0].Path != shared || loaded[0].Count != 2 {
t.Errorf("JobsLoaded events: got %+v, want one for %q with 2 jobs", loaded, shared)
}
}
// A path with no file behind it is the "rename or relocate" case: the current
// jobs are written there rather than an empty list being adopted.
func TestUpdateSettingsKeepsJobsWhenTheNewFileIsMissing(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
config := svc.store.Config
config.JobsFile = filepath.Join("moved", "jobs.json")
if err := svc.UpdateSettings(config); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
jobs := svc.Jobs()
if len(jobs) != 1 || jobs[0].Name != "Local" {
t.Fatalf("jobs after the move: got %+v, want the original job", jobs)
}
if _, err := os.Stat(filepath.Join(svc.store.Paths.AppDir, "moved", "jobs.json")); err != nil {
t.Errorf("jobs should have been written to the new path: %v", err)
}
}
// Adoption throws away every runtime, including the state of a run in flight,
// and a finishing run would then write its result onto whichever job inherited
// its ID. Refusing the switch is what keeps that from happening.
func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Long", Schedule: "@every 1h", Command: "echo long", Enabled: true}})
entered := make(chan int, 1)
release := make(chan struct{})
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
entered <- job.ID
<-release
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
}
done := completions(svc)
if err := svc.RunNow(1); err != nil {
t.Fatalf("RunNow: %v", err)
}
<-entered
config := svc.store.Config
config.JobsFile = filepath.Join("elsewhere", "jobs.json")
if err := svc.UpdateSettings(config); err == nil {
t.Error("expected the jobs-file switch to be refused while a job is running")
}
if svc.store.Config.JobsFile == config.JobsFile {
t.Error("the refused switch must not have been persisted")
}
// A setting that does not touch the jobs file still saves during a run.
unrelated := svc.store.Config
unrelated.NotifyOnFailure = !unrelated.NotifyOnFailure
if err := svc.UpdateSettings(unrelated); err != nil {
t.Errorf("unrelated setting should still save during a run: %v", err)
}
close(release)
waitRecord(t, done)
}
// Adoption reconstructs the adopted jobs' aggregate statistics from the log
// files the new configuration points at. That scan opens every log in the
// directory, so UpdateSettings runs it before taking the state lock; this pins
// that its result still reaches the runtime map.
func TestUpdateSettingsSeedsAdoptedJobsFromLogs(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
logsDir := svc.store.Paths.LogsDir
if err := os.MkdirAll(logsDir, 0o755); err != nil {
t.Fatal(err)
}
log := "time: 2026-08-05 10:00:00\njob_id: 7\njob_name: Adopted\ntrigger: Schedule\nstate: Failed\ndetail: boom\nduration: 1500\n\nstdout:\n<empty>\n"
if err := os.WriteFile(filepath.Join(logsDir, "20260805-100000_Adopted.log"), []byte(log), 0o644); err != nil {
t.Fatal(err)
}
shared := filepath.Join(svc.store.Paths.AppDir, "shared.json")
data, err := json.Marshal(domain.JobsFile{Jobs: []domain.Job{
{ID: 7, Name: "Adopted", Schedule: "@every 5m", Command: "echo adopted", Enabled: true},
}})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(shared, data, 0o644); err != nil {
t.Fatal(err)
}
config := svc.store.Config
config.JobsFile = shared
if err := svc.UpdateSettings(config); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
runtime := svc.Runtime(7)
if runtime == nil {
t.Fatal("the adopted job has no runtime")
}
if runtime.RunCount != 1 || runtime.FailCount != 1 || runtime.LastDurationMS != 1500 {
t.Errorf("seeded stats: RunCount=%d FailCount=%d LastDurationMS=%d, want 1/1/1500",
runtime.RunCount, runtime.FailCount, runtime.LastDurationMS)
}
}
// Job saves run after mu is released, so one operation can be writing while
// another mutates state. deferSaveLocked takes its own lock while mu is still
// held, which is what keeps writes in mutation order: whatever changed the list
// last also wrote it last, so the file ends up matching memory instead of
// holding an older snapshot.
func TestConcurrentJobOperationsLeaveTheFileMatchingMemory(t *testing.T) {
svc := newTempService(t, nil)
const workers = 8
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
job, err := svc.CreateJob(domain.Job{Name: fmt.Sprintf("Job %d", i), Schedule: "@every 1m", Command: "echo hi", Enabled: true})
if err != nil {
t.Errorf("CreateJob %d: %v", i, err)
return
}
if err := svc.SetEnabled(job.ID, false); err != nil {
t.Errorf("SetEnabled %d: %v", job.ID, err)
}
}(i)
}
wg.Wait()
memory := svc.Jobs()
if len(memory) != workers {
t.Fatalf("jobs in memory = %d, want %d", len(memory), workers)
}
saved, found, err := storage.LoadJobsFile(svc.store.Paths.JobsPath)
if err != nil || !found {
t.Fatalf("read jobs file: found=%v err=%v", found, err)
}
if len(saved) != len(memory) {
t.Fatalf("jobs on disk = %d, want %d: the last write must be the last mutation", len(saved), len(memory))
}
onDisk := make(map[int]domain.Job, len(saved))
for _, job := range saved {
onDisk[job.ID] = job
}
for _, job := range memory {
got, ok := onDisk[job.ID]
if !ok {
t.Errorf("job %d (%q) is in memory but missing from the file", job.ID, job.Name)
continue
}
if got.Name != job.Name || got.Enabled != job.Enabled {
t.Errorf("job %d on disk = %q/%v, want %q/%v", job.ID, got.Name, got.Enabled, job.Name, job.Enabled)
}
}
}
func TestPrependLogCapsActivityList(t *testing.T) {
runtime := &domain.JobRuntime{}
for i := 0; i < maxJobLogs+10; i++ {
prependLog(runtime, domain.RunRecord{Detail: "r"})
}
if len(runtime.Logs) != maxJobLogs {
t.Errorf("activity list len = %d, want capped at %d", len(runtime.Logs), maxJobLogs)
}
}
func TestSetGlobalPausePersistsToConfigFile(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
data, err := os.ReadFile(svc.store.Paths.ConfigPath)
if err != nil {
t.Fatalf("reading config file: %v", err)
}
var cfg domain.Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("unmarshalling config: %v", err)
}
if !cfg.Paused {
t.Error("persisted config does not have Paused=true after SetGlobalPause(true)")
}
// Resuming clears the flag on disk.
if err := svc.SetGlobalPause(false); err != nil {
t.Fatalf("SetGlobalPause(false): %v", err)
}
data, err = os.ReadFile(svc.store.Paths.ConfigPath)
if err != nil {
t.Fatalf("reading config file after resume: %v", err)
}
var cfg2 domain.Config
if err := json.Unmarshal(data, &cfg2); err != nil {
t.Fatalf("unmarshalling config after resume: %v", err)
}
if cfg2.Paused {
t.Error("persisted config still has Paused=true after SetGlobalPause(false)")
}
}
func TestSetJobListViewPersistsToConfigFile(t *testing.T) {
svc := newTempService(t, nil)
readConfig := func(stage string) domain.Config {
t.Helper()
data, err := os.ReadFile(svc.store.Paths.ConfigPath)
if err != nil {
t.Fatalf("reading config file %s: %v", stage, err)
}
var cfg domain.Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("unmarshalling config %s: %v", stage, err)
}
return cfg
}
if err := svc.SetJobListView(domain.JobListViewCompact); err != nil {
t.Fatalf("SetJobListView(compact): %v", err)
}
if got := readConfig("after compact").JobListView; got != domain.JobListViewCompact {
t.Errorf("persisted JobListView = %q, want %q", got, domain.JobListViewCompact)
}
if err := svc.SetJobListView(domain.JobListViewDetailed); err != nil {
t.Fatalf("SetJobListView(detailed): %v", err)
}
if got := readConfig("after detailed").JobListView; got != domain.JobListViewDetailed {
t.Errorf("persisted JobListView = %q, want %q", got, domain.JobListViewDetailed)
}
}
// TestSetJobListViewNormalizesUnknownValue guards the config file against
// gaining a value no reader understands: anything but "compact" is stored as
// "detailed".
func TestSetJobListViewNormalizesUnknownValue(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.SetJobListView(domain.JobListViewCompact); err != nil {
t.Fatalf("SetJobListView(compact): %v", err)
}
if err := svc.SetJobListView("tiny"); err != nil {
t.Fatalf("SetJobListView(tiny): %v", err)
}
data, err := os.ReadFile(svc.store.Paths.ConfigPath)
if err != nil {
t.Fatalf("reading config file: %v", err)
}
var cfg domain.Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("unmarshalling config: %v", err)
}
if cfg.JobListView != domain.JobListViewDetailed {
t.Errorf("persisted JobListView = %q, want %q", cfg.JobListView, domain.JobListViewDetailed)
}
}
func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) {
jobs := []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}}
svc := newTempService(t, jobs)
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
// Simulate a restart: NewService reads Config.Paused from the store that
// SetGlobalPause already updated (both in memory and on disk).
svc2 := NewService(svc.store, svc.Jobs())
var ran int32
runStarted := make(chan struct{}, 1)
svc2.runJob = func(context.Context, *domain.Job, string, string, time.Duration) (domain.RunRecord, error) {
atomic.AddInt32(&ran, 1)
select {
case runStarted <- struct{}{}:
default:
}
return domain.RunRecord{}, nil
}
// RunDue must not start any job while paused: the scheduler stays paused after
// a restart that rebuilt the service from a paused store.
svc2.RunDue(time.Now().Add(2 * time.Minute))
time.Sleep(50 * time.Millisecond)
if atomic.LoadInt32(&ran) != 0 {
t.Error("RunDue ran a job on a service rebuilt from a paused store")
}
// A manual RunNow is still allowed while paused — pause only stops the
// scheduler, not the user's explicit action.
if err := svc2.RunNow(1); err != nil {
t.Errorf("RunNow should be allowed while paused: %v", err)
}
select {
case <-runStarted:
case <-time.After(2 * time.Second):
t.Error("manual run was not started on a service rebuilt from a paused store")
}
}