A search can now be saved with a schedule (every N hours, daily, or weekly at a given day and time). An internal scheduler runs it and keeps a snapshot of the results on a /data volume (JSON files, no new dependency). The new Watch tab lists the scheduled searches; the review page of each one shows, for any run of its history, the repositories never seen before and the ones that gained the most stars since the previous run. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
101 lines
3.5 KiB
Go
101 lines
3.5 KiB
Go
package main
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestScheduleNext(t *testing.T) {
|
|
loc := time.FixedZone("CEST", 2*3600)
|
|
// Wednesday 2026-09-30 10:00
|
|
now := time.Date(2026, 9, 30, 10, 0, 0, 0, loc)
|
|
cases := []struct {
|
|
name string
|
|
sc Schedule
|
|
last time.Time
|
|
want time.Time
|
|
}{
|
|
{"weekly monday 8:00", Schedule{Every: "week", Weekday: 1, Hour: 8}, time.Time{}, time.Date(2026, 10, 5, 8, 0, 0, 0, loc)},
|
|
{"weekly today later", Schedule{Every: "week", Weekday: 3, Hour: 18, Minute: 30}, time.Time{}, time.Date(2026, 9, 30, 18, 30, 0, 0, loc)},
|
|
{"weekly today passed", Schedule{Every: "week", Weekday: 3, Hour: 9}, time.Time{}, time.Date(2026, 10, 7, 9, 0, 0, 0, loc)},
|
|
{"daily passed", Schedule{Every: "day", Hour: 7}, time.Time{}, time.Date(2026, 10, 1, 7, 0, 0, 0, loc)},
|
|
{"hours first", Schedule{Every: "hours", Hours: 6}, time.Time{}, now.Add(time.Minute)},
|
|
{"hours after last", Schedule{Every: "hours", Hours: 6}, now.Add(-2 * time.Hour), now.Add(4 * time.Hour)},
|
|
{"hours late", Schedule{Every: "hours", Hours: 6}, now.Add(-7 * time.Hour), now.Add(time.Minute)},
|
|
}
|
|
for _, c := range cases {
|
|
if got := c.sc.Next(now, c.last); !got.Equal(c.want) {
|
|
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScheduleValid(t *testing.T) {
|
|
for _, sc := range []Schedule{{Every: "hours"}, {Every: "hours", Hours: 200}, {Every: "week", Weekday: 7}, {Every: "day", Hour: 24}, {Every: "month"}} {
|
|
if sc.Valid() {
|
|
t.Errorf("%+v should be invalid", sc)
|
|
}
|
|
}
|
|
if !(Schedule{Every: "week", Weekday: 1, Hour: 8}).Valid() {
|
|
t.Error("weekly schedule should be valid")
|
|
}
|
|
}
|
|
|
|
func TestStoreRuns(t *testing.T) {
|
|
s, err := OpenStore(t.TempDir(), 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
x, err := s.Create(SavedSearch{Name: "go", Params: "language=Go", Schedule: Schedule{Every: "day"}, MaxResults: 30, Enabled: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t0 := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
|
|
add := func(id string, at time.Time, repos ...Repo) Run {
|
|
t.Helper()
|
|
r, err := s.AddRun(x.ID, RunInfo{ID: id, At: at}, repos)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
r1 := add("r1", t0, Repo{FullName: "a/a", Stars: 10}, Repo{FullName: "b/b", Stars: 5})
|
|
if r1.New != 0 || r1.Items[0].New || r1.Items[0].StarsDelta != nil {
|
|
t.Errorf("first run must be a baseline: %+v", r1)
|
|
}
|
|
|
|
r2 := add("r2", t0.Add(24*time.Hour), Repo{FullName: "a/a", Stars: 25}, Repo{FullName: "c/c", Stars: 3})
|
|
if r2.New != 1 || !r2.Items[1].New || r2.Items[0].New {
|
|
t.Errorf("c/c should be the only new repository: %+v", r2.Items)
|
|
}
|
|
if d := r2.Items[0].StarsDelta; d == nil || *d != 15 || r2.Rising != 1 {
|
|
t.Errorf("a/a should have gained 15 stars: %+v", r2.Items[0])
|
|
}
|
|
|
|
// A failed run is recorded but does not break the comparison.
|
|
s.AddRun(x.ID, RunInfo{ID: "r3", At: t0.Add(48 * time.Hour), Error: "boom"}, nil)
|
|
r4 := add("r4", t0.Add(72*time.Hour), Repo{FullName: "b/b", Stars: 6}, Repo{FullName: "c/c", Stars: 4})
|
|
if r4.New != 0 {
|
|
t.Errorf("b/b was seen in the first run, not new: %+v", r4.Items)
|
|
}
|
|
|
|
idx, _ := s.Runs(x.ID)
|
|
if len(idx) != 2 || idx[0].ID != "r4" {
|
|
t.Errorf("history should keep the 2 newest runs, got %+v", idx)
|
|
}
|
|
if _, err := s.Run(x.ID, "r1"); err == nil {
|
|
t.Error("r1 should have been pruned")
|
|
}
|
|
latest, err := s.Run(x.ID, "latest")
|
|
if err != nil || latest.ID != "r4" {
|
|
t.Errorf("latest = %v, %v", latest.ID, err)
|
|
}
|
|
if err := s.Delete(x.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.Get(x.ID); err == nil {
|
|
t.Error("search should be deleted")
|
|
}
|
|
}
|