diff --git a/src/core/runner_test.go b/src/core/runner_test.go index 11081bc..3ab66ca 100644 --- a/src/core/runner_test.go +++ b/src/core/runner_test.go @@ -7,8 +7,142 @@ import ( "runtime" "strings" "testing" + "time" ) +func TestRunJobLogFileAllHeaders(t *testing.T) { + logsDir := t.TempDir() + job := Job{ + ID: 99, + Name: "Log Header Test", + Command: echoCommand("header test output"), + SuccessExitCodes: "0,1", + } + + record := RunJob(context.Background(), &job, "Schedule", logsDir) + if record.LogFile == "" { + t.Fatal("expected log file to be written") + } + + data, err := os.ReadFile(record.LogFile) + if err != nil { + t.Fatal(err) + } + content := string(data) + + for _, want := range []string{ + "job_id: 99", + "job_name: Log Header Test", + "trigger: Schedule", + "state: OK", + "detail: ", + "command: " + job.Command, + "arguments: ", + "success_exit_codes: 0,1", + "start_only: false", + "stdout:", + "stderr:", + } { + if !strings.Contains(content, want) { + t.Errorf("log file missing %q:\n%s", want, content) + } + } + + // The time header must use the documented format. + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, "time: ") { + ts := strings.TrimPrefix(line, "time: ") + if _, err := time.Parse("2006-01-02 15:04:05", ts); err != nil { + t.Errorf("time header %q does not match format 2006-01-02 15:04:05: %v", ts, err) + } + break + } + } +} + +func TestRunJobRecordFields(t *testing.T) { + job := Job{ + ID: 55, + Name: "Record Fields Test", + Command: echoCommand("record field check"), + } + + record := RunJob(context.Background(), &job, "Schedule", t.TempDir()) + + if record.JobID != job.ID { + t.Errorf("JobID: got %d, want %d", record.JobID, job.ID) + } + if record.JobName != job.Name { + t.Errorf("JobName: got %q, want %q", record.JobName, job.Name) + } + if record.Trigger != "Schedule" { + t.Errorf("Trigger: got %q, want 'Schedule'", record.Trigger) + } + if record.State != "OK" { + t.Errorf("State: got %q, want 'OK' (detail: %q)", record.State, record.Detail) + } + if record.LogFile == "" { + t.Error("LogFile should be a non-empty path") + } + if _, err := time.Parse("2006-01-02 15:04:05", record.Time); err != nil { + t.Errorf("Time format wrong, got %q: %v", record.Time, err) + } + if !strings.Contains(record.Output, "stdout:") { + t.Errorf("Output missing 'stdout:', got:\n%s", record.Output) + } + if !strings.Contains(record.Output, "stderr:") { + t.Errorf("Output missing 'stderr:', got:\n%s", record.Output) + } +} + +func TestFormatOutput(t *testing.T) { + got := formatOutput("hello world", "some error") + want := "stdout:\nhello world\n\nstderr:\nsome error" + if got != want { + t.Errorf("formatOutput:\ngot: %q\nwant: %q", got, want) + } +} + +func TestFormatOutputEmptyStreams(t *testing.T) { + got := formatOutput("", "") + if !strings.Contains(got, "stdout:\n") { + t.Errorf("empty stdout should show , got:\n%s", got) + } + if !strings.Contains(got, "stderr:\n") { + t.Errorf("empty stderr should show , got:\n%s", got) + } +} + +func TestLogArguments(t *testing.T) { + cases := []struct{ input, want string }{ + {"", ""}, + {" ", ""}, + {"--flag", "--flag"}, + {"--flag\r\n--value", "--flag\n--value"}, + {"--flag\n--value", "--flag\n--value"}, + } + for _, tc := range cases { + if got := logArguments(tc.input); got != tc.want { + t.Errorf("logArguments(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestSanitizeFileName(t *testing.T) { + cases := []struct{ input, want string }{ + {"Hello Test", "Hello_Test"}, + {"job-1_ok", "job-1_ok"}, + {"!!!", "job"}, + {"", "job"}, + {"A/B:C", "A_B_C"}, + } + for _, tc := range cases { + if got := sanitizeFileName(tc.input); got != tc.want { + t.Errorf("sanitizeFileName(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + func TestRunJobWritesLogFile(t *testing.T) { logsDir := t.TempDir() job := Job{ diff --git a/src/core/scheduler_test.go b/src/core/scheduler_test.go index ed0267d..3076a9d 100644 --- a/src/core/scheduler_test.go +++ b/src/core/scheduler_test.go @@ -6,6 +6,60 @@ import ( "time" ) +func TestNextRunTimeRejectsInvalidSchedules(t *testing.T) { + from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC) + cases := []struct { + schedule string + desc string + }{ + {"", "empty string"}, + {" ", "whitespace only"}, + {"@every", "bare @every without duration"}, + {"@every xyz", "invalid @every duration string"}, + {"@every -1s", "negative @every duration"}, + {"@every 0s", "zero @every duration"}, + {"not-a-cron", "invalid cron expression"}, + {"60 * * * *", "cron minute out of range"}, + } + for _, tc := range cases { + _, ok := nextRunTime(tc.schedule, from) + if ok { + t.Errorf("nextRunTime(%q) [%s]: expected false, got true", tc.schedule, tc.desc) + } + } +} + +func TestPrepareNextRunSetsDisplayString(t *testing.T) { + jobs := []Job{{Schedule: "*/5 * * * *", Enabled: true}} + s := &Scheduler{jobs: &jobs} + from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC) + + s.prepareNextRun(&jobs[0], from) + + want := "2026-06-14 12:05:00" + if jobs[0].NextRun != want { + t.Errorf("NextRun: got %q, want %q", jobs[0].NextRun, want) + } + wantDue := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC) + if !jobs[0].nextDue.Equal(wantDue) { + t.Errorf("nextDue: got %v, want %v", jobs[0].nextDue, wantDue) + } +} + +func TestPrepareNextRunSetsInvalidScheduleLabel(t *testing.T) { + jobs := []Job{{Schedule: "not-a-cron", Enabled: true}} + s := &Scheduler{jobs: &jobs} + + s.prepareNextRun(&jobs[0], time.Now()) + + if jobs[0].NextRun != "Invalid schedule" { + t.Errorf("NextRun: got %q, want 'Invalid schedule'", jobs[0].NextRun) + } + if !jobs[0].nextDue.IsZero() { + t.Errorf("nextDue should be zero for invalid schedule, got %v", jobs[0].nextDue) + } +} + func TestNextRunTimeSupportsEvery(t *testing.T) { from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC) next, ok := nextRunTime("@every 10s", from) diff --git a/src/core/store_test.go b/src/core/store_test.go index 5fc84f2..2b98b6e 100644 --- a/src/core/store_test.go +++ b/src/core/store_test.go @@ -1,12 +1,178 @@ package core import ( + "path/filepath" "strings" "testing" "go.yaml.in/yaml/v4" ) +func TestJobsRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "jobs.yaml") + + original := []Job{ + { + ID: 7, + Name: "Backup data", + Folder: "Maintenance", + Schedule: "0 2 * * *", + Command: "/usr/bin/backup", + Arguments: "--compress\n--verbose", + SuccessExitCodes: "0,1", + StartOnly: true, + Enabled: true, + }, + } + + if err := writeYAML(path, JobsFile{Jobs: original}); err != nil { + t.Fatal(err) + } + + got, err := loadOrCreateJobs(path) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("expected 1 job, got %d", len(got)) + } + + g, w := got[0], original[0] + if g.ID != w.ID { + t.Errorf("ID: got %d, want %d", g.ID, w.ID) + } + if g.Name != w.Name { + t.Errorf("Name: got %q, want %q", g.Name, w.Name) + } + if g.Folder != w.Folder { + t.Errorf("Folder: got %q, want %q", g.Folder, w.Folder) + } + if g.Schedule != w.Schedule { + t.Errorf("Schedule: got %q, want %q", g.Schedule, w.Schedule) + } + if g.Command != w.Command { + t.Errorf("Command: got %q, want %q", g.Command, w.Command) + } + if g.Arguments != w.Arguments { + t.Errorf("Arguments: got %q, want %q", g.Arguments, w.Arguments) + } + if g.SuccessExitCodes != w.SuccessExitCodes { + t.Errorf("SuccessExitCodes: got %q, want %q", g.SuccessExitCodes, w.SuccessExitCodes) + } + if g.StartOnly != w.StartOnly { + t.Errorf("StartOnly: got %v, want %v", g.StartOnly, w.StartOnly) + } + if g.Enabled != w.Enabled { + t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled) + } + + // Runtime fields must not survive the save→load round-trip. + if g.LastRun != "" { + t.Errorf("LastRun should be empty after load, got %q", g.LastRun) + } + if g.LastState != "" { + t.Errorf("LastState should be empty after load, got %q", g.LastState) + } + if g.Logs != nil { + t.Errorf("Logs should be nil after load, got %v", g.Logs) + } +} + +func TestConfigRoundTrip(t *testing.T) { + dir := t.TempDir() + paths := Paths{ + AppDir: dir, + ConfigPath: filepath.Join(dir, ConfigFileName), + } + + want := Config{ + JobsDir: "/custom/jobs", + LogsDir: "/custom/logs", + MaxLogFiles: 50, + MaxLogAgeDays: 14, + StartOnLogin: true, + KeepRunningInTray: false, + NotifyOnFailure: false, + } + if err := writeYAML(paths.ConfigPath, want); err != nil { + t.Fatal(err) + } + + got, err := loadOrCreateConfig(paths) + if err != nil { + t.Fatal(err) + } + + if got.JobsDir != want.JobsDir { + t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir) + } + if got.LogsDir != want.LogsDir { + t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir) + } + if got.MaxLogFiles != want.MaxLogFiles { + t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, want.MaxLogFiles) + } + if got.MaxLogAgeDays != want.MaxLogAgeDays { + t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, want.MaxLogAgeDays) + } + if got.StartOnLogin != want.StartOnLogin { + t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, want.StartOnLogin) + } + if got.KeepRunningInTray != want.KeepRunningInTray { + t.Errorf("KeepRunningInTray: got %v, want %v", got.KeepRunningInTray, want.KeepRunningInTray) + } + if got.NotifyOnFailure != want.NotifyOnFailure { + t.Errorf("NotifyOnFailure: got %v, want %v", got.NotifyOnFailure, want.NotifyOnFailure) + } +} + +func TestNormalizeJobsFillsDefaults(t *testing.T) { + jobs := []Job{ + {Enabled: true}, + {Enabled: false}, + {ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true}, + } + + normalizeJobs(jobs) + + // Blank enabled job gets default name, schedule, command, exit codes, and runtime state. + if jobs[0].ID != 1 { + t.Errorf("first auto ID: got %d, want 1", jobs[0].ID) + } + if jobs[0].Name != "Untitled job" { + t.Errorf("default name: got %q, want 'Untitled job'", jobs[0].Name) + } + if jobs[0].Schedule != "@every 1m" { + t.Errorf("default schedule: got %q, want '@every 1m'", jobs[0].Schedule) + } + if jobs[0].SuccessExitCodes != "0" { + t.Errorf("default exit codes: got %q, want '0'", jobs[0].SuccessExitCodes) + } + if jobs[0].LastState != "Ready" { + t.Errorf("enabled job state: got %q, want 'Ready'", jobs[0].LastState) + } + if jobs[0].NextRun != "After start" { + t.Errorf("enabled job next run: got %q, want 'After start'", jobs[0].NextRun) + } + + // Disabled job is marked Paused. + if jobs[1].LastState != "Paused" { + t.Errorf("disabled job state: got %q, want 'Paused'", jobs[1].LastState) + } + if jobs[1].NextRun != "Paused" { + t.Errorf("disabled job next run: got %q, want 'Paused'", jobs[1].NextRun) + } + + // Pre-set fields survive normalization unchanged. + if jobs[2].ID != 5 { + t.Errorf("pre-set ID should be preserved: got %d, want 5", jobs[2].ID) + } + if jobs[2].SuccessExitCodes != "0,1" { + t.Errorf("pre-set exit codes should be preserved: got %q, want '0,1'", jobs[2].SuccessExitCodes) + } +} + func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) { jobs := []Job{ {