test(11-07): cover jobs, scheduler and lagoon seams branch by branch
- conga: TestOutcome*, TestCancelQueuedNeverRuns, TestCancelRunningCancelsCtx, TestStopJobFromWorker, TestManagerPHPSemantics, principal, delay, registration, worker and queue-setting branches; TestQueueClear and TestQueueWork move to commands_test.go with the batch/state and queue-filter cases (coverage 92.5%) - scheduler: validation, ordering, missing catalog, log writer, dueAt - lagoon: TestQueueMigrationsUpDown (River v7 + summer_jobs, rollback, idempotent rerun), OnDatabase isolation, Transaction edges - bonfire TestCallEdges, pact TestCadence
This commit is contained in:
@@ -605,3 +605,188 @@ func TestScheduleRunForeground(t *testing.T) {
|
||||
t.Fatal("schedule:run did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduleValidation covers the CLI-04 refusals: an empty command, a
|
||||
// zero cadence, an interval that does not divide 24h and an invalid
|
||||
// app.timezone each fail the compile, naming the plugin and entry index,
|
||||
// and a worker does not start with them.
|
||||
func TestScheduleValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
app *backpack.App
|
||||
entry pact.ScheduledCommand
|
||||
want string
|
||||
}{
|
||||
{"empty_command", backpack.New(nil), pact.ScheduledCommand{Command: "", Cadence: pact.Daily()}, "plugin acme.v schedule entry 1: command is empty"},
|
||||
{"zero_cadence", backpack.New(nil), pact.ScheduledCommand{Command: "acme:x"}, "plugin acme.v schedule entry 1 (acme:x): cadence is zero"},
|
||||
{"non_dividing_interval", backpack.New(nil), pact.ScheduledCommand{Command: "acme:x", Cadence: pact.Every(7 * time.Minute)}, "plugin acme.v schedule entry 1 (acme:x): interval 7m0s does not divide 24h evenly"},
|
||||
{"sub_second_interval", backpack.New(nil), pact.ScheduledCommand{Command: "acme:x", Cadence: pact.Every(time.Millisecond)}, "shorter than one second"},
|
||||
{"negative_interval", backpack.New(nil), pact.ScheduledCommand{Command: "acme:x", Cadence: pact.Every(-time.Minute)}, "not positive"},
|
||||
{"daily_out_of_range", backpack.New(nil), pact.ScheduledCommand{Command: "acme:x", Cadence: pact.DailyAt(12, 75)}, "daily time 12:75 is out of range"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
plugins := []party.Plugin{&schedulePlugin{id: "acme.v", schedule: []pact.ScheduledCommand{{Command: "acme:ok", Cadence: pact.Daily()}, c.entry}}}
|
||||
_, err := scheduleEntries(c.app, plugins)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Fatalf("err = %v, want %q", err, c.want)
|
||||
}
|
||||
if _, err := StartWorker(t.Context(), c.app, plugins, WorkerOptions{}); err == nil {
|
||||
t.Fatal("worker started with an invalid schedule")
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("invalid_timezone", func(t *testing.T) {
|
||||
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: "testing", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.Set("app.timezone", "Mars/Olympus"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
_, err = scheduleEntries(app, schedulePlugins(pact.ScheduledCommand{Command: "acme:x", Cadence: pact.Daily()}))
|
||||
if err == nil || !strings.Contains(err.Error(), `app.timezone "Mars/Olympus"`) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if _, err := onceRun(t, app, schedulePlugins(pact.ScheduledCommand{Command: "acme:x", Cadence: pact.Daily()}), time.Now()); err == nil {
|
||||
t.Fatal("--once ran with an invalid app.timezone")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestScheduleOrdering covers entry order and ids: plugins in activation
|
||||
// order, entries in declaration order, plugins without a schedule skipped,
|
||||
// and a compiled table keyed by id with cloned args.
|
||||
func TestScheduleOrdering(t *testing.T) {
|
||||
args := []string{"--force"}
|
||||
plugins := []party.Plugin{
|
||||
&schedulePlugin{id: "acme.z", schedule: []pact.ScheduledCommand{
|
||||
{Command: "acme:first", Args: args, Cadence: pact.Every(time.Hour)},
|
||||
{Command: "acme:second", Cadence: pact.DailyAt(3, 15)},
|
||||
}},
|
||||
&jobsPlugin{id: "acme.jobs-only"},
|
||||
&schedulePlugin{id: "acme.a", schedule: []pact.ScheduledCommand{{Command: "acme:first", Cadence: pact.Daily()}}},
|
||||
}
|
||||
jobs, table, err := periodicJobs(backpack.New(nil), plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(jobs) != 3 || len(table) != 3 {
|
||||
t.Fatalf("jobs %d, table %d, want 3", len(jobs), len(table))
|
||||
}
|
||||
entries, err := scheduleEntries(backpack.New(nil), plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ids []string
|
||||
for _, e := range entries {
|
||||
ids = append(ids, e.id)
|
||||
}
|
||||
want := []string{"acme.z[0]:acme:first", "acme.z[1]:acme:second", "acme.a[0]:acme:first"}
|
||||
if strings.Join(ids, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("ids = %v, want %v", ids, want)
|
||||
}
|
||||
args[0] = "--mutated"
|
||||
if got := table["acme.z[0]:acme:first"].Args; len(got) != 1 || got[0] != "--force" {
|
||||
t.Fatalf("compiled args = %v, want a copy of the declared args", got)
|
||||
}
|
||||
a, opts := entries[1].construct()
|
||||
sa, ok := a.(ScheduledCommandArgs)
|
||||
if !ok || sa.Entry != "acme.z[1]:acme:second" || sa.Kind() != "summer.scheduled_command" {
|
||||
t.Fatalf("constructed args = %#v", a)
|
||||
}
|
||||
if opts.Queue != QueueScheduled || opts.MaxAttempts != 1 || !opts.UniqueOpts.ByArgs || opts.UniqueOpts.ByPeriod != 24*time.Hour {
|
||||
t.Fatalf("insert opts = %+v", opts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduleMissingCatalog covers user decision 5 without a published
|
||||
// command catalog: a scheduled run is logged at Warn and skipped, and a
|
||||
// matching entry whose command fails returns the error.
|
||||
func TestScheduleMissingCatalog(t *testing.T) {
|
||||
app := backpack.New(nil)
|
||||
logs := &captureHandler{}
|
||||
if err := app.Publish(slog.New(logs)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := From(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.schedule = map[string]pact.ScheduledCommand{"acme.test[0]:acme:tick": {Command: "acme:tick", Cadence: pact.Daily()}}
|
||||
if err := m.runScheduled(t.Context(), ScheduledCommandArgs{Entry: "acme.test[0]:acme:tick", Command: "acme:tick"}); err != nil {
|
||||
t.Fatalf("run without a catalog = %v, want nil", err)
|
||||
}
|
||||
if !logs.find(slog.LevelWarn, "schedule: no command catalog published; skipping", "command", "acme:tick") {
|
||||
t.Fatal("no Warn log for the missing catalog")
|
||||
}
|
||||
if err := app.Publish(bonfire.NewCatalog([]bonfire.Command{{Name: "acme:tick", Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
out.Println("partial output")
|
||||
return errors.New("tick failed")
|
||||
}}})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = m.runScheduled(t.Context(), ScheduledCommandArgs{Entry: "acme.test[0]:acme:tick", Command: "acme:tick"})
|
||||
if err == nil || !strings.Contains(err.Error(), "tick failed") {
|
||||
t.Fatalf("failing command = %v", err)
|
||||
}
|
||||
if !logs.find(slog.LevelInfo, "partial output", "command", "acme:tick") {
|
||||
t.Fatal("command output was not logged")
|
||||
}
|
||||
if !logs.find(slog.LevelError, "schedule: command failed", "command", "acme:tick") {
|
||||
t.Fatal("no Error log for the failed command")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduleLogWriter covers the scheduled command output logger: lines
|
||||
// split across writes, CRLF, blank lines and a trailing partial line.
|
||||
func TestScheduleLogWriter(t *testing.T) {
|
||||
logs := &captureHandler{}
|
||||
w := newLogWriter(slog.New(logs), "acme:tick")
|
||||
for _, chunk := range []string{"first li", "ne\r\n\nsecond\nthi", "rd"} {
|
||||
if n, err := w.Write([]byte(chunk)); err != nil || n != len(chunk) {
|
||||
t.Fatalf("Write(%q) = %d, %v", chunk, n, err)
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
w.Flush()
|
||||
var msgs []string
|
||||
for _, r := range logs.records {
|
||||
if r.attrs["command"] != "acme:tick" {
|
||||
t.Fatalf("record without the command attribute: %+v", r)
|
||||
}
|
||||
msgs = append(msgs, r.msg)
|
||||
}
|
||||
if strings.Join(msgs, "|") != "first line|second|third" {
|
||||
t.Fatalf("logged lines = %q", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduleDueAt covers the --once minute match, including an Every(d)
|
||||
// longer than a minute that does not divide an hour.
|
||||
func TestScheduleDueAt(t *testing.T) {
|
||||
at := func(hm string) time.Time { return mustTime(t, time.UTC, "2026-03-10 "+hm+":00") }
|
||||
cases := []struct {
|
||||
name string
|
||||
c pact.Cadence
|
||||
t time.Time
|
||||
want bool
|
||||
}{
|
||||
{"daily_at_match", pact.DailyAt(3, 15), at("03:15"), true},
|
||||
{"daily_at_other_minute", pact.DailyAt(3, 15), at("03:16"), false},
|
||||
{"every_minute_always", pact.Every(time.Minute), at("07:13"), true},
|
||||
{"every_30s_always", pact.Every(30 * time.Second), at("07:13"), true},
|
||||
{"every_90s_at_3m", pact.Every(90 * time.Second), at("00:03"), true},
|
||||
{"every_90s_at_1m", pact.Every(90 * time.Second), at("00:01"), false},
|
||||
{"every_90s_at_1h", pact.Every(90 * time.Second), at("01:00"), true},
|
||||
{"every_hour_on_the_hour", pact.Every(time.Hour), at("13:00"), true},
|
||||
{"every_hour_off_the_hour", pact.Every(time.Hour), at("13:30"), false},
|
||||
{"zero_cadence", pact.Cadence{}, at("00:00"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := dueAt(c.c, c.t); got != c.want {
|
||||
t.Errorf("%s: dueAt = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user