package scheduler import ( "errors" "os" "os/exec" "path/filepath" "testing" "time" "bugabinga.net/luci/internal/history" "bugabinga.net/luci/internal/inbox" "bugabinga.net/luci/internal/trigger" ) func TestPollPinsDefaultBranchAndDeduplicatesMinute(t *testing.T) { root := t.TempDir() bare, rev := createRepo(t, root, `job "nightly" { image "alpine" run "echo ok" trigger { schedule "0 3 * * *" } } `) _ = bare minute := time.Date(2026, 3, 4, 3, 0, 0, 0, time.UTC) cfg := Config{DataDir: filepath.Join(root, "data"), RepoRoots: []string{filepath.Join(root, "repos")}, InboxDir: filepath.Join(root, "data", "inbox"), Now: func() time.Time { return minute }} if err := os.MkdirAll(cfg.InboxDir, 0o755); err != nil { t.Fatal(err) } ids, err := Poll(cfg) if err != nil || len(ids) != 1 { t.Fatalf("ids=%v err=%v", ids, err) } pending, warnings, err := inbox.List(cfg.InboxDir) if err != nil || len(warnings) != 0 || len(pending) != 1 { t.Fatalf("pending=%#v warnings=%v err=%v", pending, warnings, err) } event, ok := pending[0].Event.(trigger.ScheduleEvent) if !ok || event.Repo != "repo" || event.Job != "nightly" || event.Rev != rev || event.Ref != "refs/heads/main" || event.Minute != "2026-03-04T03:00Z" { t.Fatalf("event=%#v", pending[0].Event) } ids, err = Poll(cfg) if err != nil || len(ids) != 0 { t.Fatalf("duplicate ids=%v err=%v", ids, err) } } func TestPollThrottlesAndCoalescesFrequentSchedule(t *testing.T) { root := t.TempDir() createRepo(t, root, `job "frequent" { image "alpine" run "echo ok" trigger { schedule "* * * * *" } } `) minute := time.Date(2026, 3, 4, 3, 0, 0, 0, time.UTC) cfg := Config{DataDir: filepath.Join(root, "data"), RepoRoots: []string{filepath.Join(root, "repos")}, InboxDir: filepath.Join(root, "data", "inbox"), Now: func() time.Time { return minute }} if err := os.MkdirAll(cfg.InboxDir, 0o755); err != nil { t.Fatal(err) } ids, err := Poll(cfg) if err != nil || len(ids) != 1 { t.Fatalf("first ids=%v err=%v", ids, err) } if err := os.Remove(filepath.Join(cfg.InboxDir, ids[0]+".json")); err != nil { t.Fatal(err) } minute = minute.Add(14 * time.Minute) ids, err = Poll(cfg) if err != nil || len(ids) != 0 { t.Fatalf("throttled ids=%v err=%v", ids, err) } minute = minute.Add(time.Minute) ids, err = Poll(cfg) if err != nil || len(ids) != 1 { t.Fatalf("after interval ids=%v err=%v", ids, err) } minute = minute.Add(15 * time.Minute) ids, err = Poll(cfg) if err != nil || len(ids) != 0 { t.Fatalf("coalesced ids=%v err=%v", ids, err) } } func TestPollRecordsInvalidDefaultBranchConfigOncePerRevision(t *testing.T) { root := t.TempDir() createRepo(t, root, `job "broken" { image "alpine" run "echo ok" trigger { schedule "@daily" } } `) minute := time.Date(2026, 3, 4, 3, 0, 0, 0, time.UTC) cfg := Config{DataDir: filepath.Join(root, "data"), RepoRoots: []string{filepath.Join(root, "repos")}, InboxDir: filepath.Join(root, "data", "inbox"), Now: func() time.Time { return minute }} if err := os.MkdirAll(cfg.InboxDir, 0o755); err != nil { t.Fatal(err) } for range 2 { if _, err := Poll(cfg); err == nil { t.Fatal("invalid config did not report error") } } events, err := history.ReadAll(cfg.DataDir) if err != nil || len(events) != 1 || events[0].Job != "schedule" || events[0].Status != "failed" { t.Fatalf("events=%#v err=%v", events, err) } } func TestUnscannableScheduleStateBlocksAdmission(t *testing.T) { root := t.TempDir() createRepo(t, root, `job "nightly" { image "alpine" run "echo ok" trigger { schedule "0 3 * * *" } } `) dataDir := filepath.Join(root, "data") if err := os.MkdirAll(dataDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dataDir, "schedules"), []byte("not a directory"), 0o644); err != nil { t.Fatal(err) } cfg := Config{DataDir: dataDir, RepoRoots: []string{filepath.Join(root, "repos")}, InboxDir: filepath.Join(dataDir, "inbox"), Now: func() time.Time { return time.Date(2026, 3, 4, 3, 0, 0, 0, time.UTC) }} if err := os.MkdirAll(cfg.InboxDir, 0o755); err != nil { t.Fatal(err) } ids, err := Poll(cfg) if err == nil || len(ids) != 0 { t.Fatalf("ids=%v err=%v", ids, err) } pending, _, listErr := inbox.List(cfg.InboxDir) if listErr != nil || len(pending) != 0 { t.Fatalf("pending=%#v err=%v", pending, listErr) } } func TestUnreadableReservationStateBlocksNewAdmission(t *testing.T) { root := t.TempDir() createRepo(t, root, `job "frequent" { image "alpine" run "echo ok" trigger { schedule "* * * * *" } } `) minute := time.Date(2026, 3, 4, 3, 0, 0, 0, time.UTC) cfg := Config{DataDir: filepath.Join(root, "data"), RepoRoots: []string{filepath.Join(root, "repos")}, InboxDir: filepath.Join(root, "data", "inbox"), Now: func() time.Time { return minute }} if err := os.MkdirAll(cfg.InboxDir, 0o755); err != nil { t.Fatal(err) } originalWrite := writeInbox writeInbox = func(string, string, trigger.Event) error { return errors.New("blocked") } t.Cleanup(func() { writeInbox = originalWrite }) if _, err := Poll(cfg); err == nil { t.Fatal("publication failure not reported") } before, _, err := loadState(cfg.DataDir, "repo", "frequent") if err != nil || before.Pending == nil { t.Fatalf("before=%#v err=%v", before, err) } if err := os.WriteFile(filepath.Join(cfg.DataDir, "schedules", "000.json"), []byte("{"), 0o644); err != nil { t.Fatal(err) } minute = minute.Add(15 * time.Minute) if _, err := Poll(cfg); err == nil { t.Fatal("malformed state did not block admission") } after, _, err := loadState(cfg.DataDir, "repo", "frequent") if err != nil || after.PendingID != before.PendingID || after.Pending.Minute != before.Pending.Minute { t.Fatalf("reservation changed: before=%#v after=%#v err=%v", before, after, err) } } func TestPollDoesNotBackfillMissedMinute(t *testing.T) { root := t.TempDir() createRepo(t, root, `job "nightly" { image "alpine" run "echo ok" trigger { schedule "0 3 * * *" } } `) cfg := Config{DataDir: filepath.Join(root, "data"), RepoRoots: []string{filepath.Join(root, "repos")}, InboxDir: filepath.Join(root, "data", "inbox"), Now: func() time.Time { return time.Date(2026, 3, 4, 3, 1, 0, 0, time.UTC) }} if err := os.MkdirAll(cfg.InboxDir, 0o755); err != nil { t.Fatal(err) } ids, err := Poll(cfg) if err != nil || len(ids) != 0 { t.Fatalf("ids=%v err=%v", ids, err) } } func createRepo(t *testing.T, root, config string) (string, string) { t.Helper() repos := filepath.Join(root, "repos") bare := filepath.Join(repos, "repo.git") work := filepath.Join(root, "work") if err := os.MkdirAll(repos, 0o755); err != nil { t.Fatal(err) } runGit(t, root, "init", "--bare", bare) runGit(t, root, "init", "-b", "main", work) runGit(t, work, "config", "user.email", "ci@example.invalid") runGit(t, work, "config", "user.name", "CI") if err := os.MkdirAll(filepath.Join(work, ".ci"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(work, ".ci", "ci.kdl"), []byte(config), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("repo\n"), 0o644); err != nil { t.Fatal(err) } runGit(t, work, "add", ".") runGit(t, work, "commit", "-m", "initial") runGit(t, work, "remote", "add", "origin", bare) runGit(t, work, "push", "origin", "main") runGit(t, root, "--git-dir", bare, "symbolic-ref", "HEAD", "refs/heads/main") rev := gitOutput(t, root, "--git-dir", bare, "rev-parse", "main") return bare, rev } func runGit(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %v: %v: %s", args, err, out) } } func gitOutput(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir out, err := cmd.Output() if err != nil { t.Fatal(err) } return string(out[:len(out)-1]) }