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>
125 lines
4.3 KiB
Go
125 lines
4.3 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func newTestAPI(t *testing.T) (*http.ServeMux, *API) {
|
|
t.Helper()
|
|
gh, _ := newFake(http.StatusOK, searchBody)
|
|
store, err := OpenStore(t.TempDir(), 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := &API{gh: gh, store: store, sched: NewScheduler(store, gh)}
|
|
mux := http.NewServeMux()
|
|
a.Routes(mux)
|
|
return mux, a
|
|
}
|
|
|
|
func call(t *testing.T, mux *http.ServeMux, method, path, body string, out any) int {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
if out != nil && rec.Code < 300 {
|
|
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
|
|
t.Fatalf("%s %s: %v (%s)", method, path, err, rec.Body)
|
|
}
|
|
}
|
|
return rec.Code
|
|
}
|
|
|
|
func TestSavedSearchAPI(t *testing.T) {
|
|
mux, a := newTestAPI(t)
|
|
|
|
for _, bad := range []string{
|
|
`{"name": "", "params": "q=x", "schedule": {"every": "week", "weekday": 1, "hour": 8}}`,
|
|
`{"name": "x", "params": "sort=nope", "schedule": {"every": "week", "weekday": 1, "hour": 8}}`,
|
|
`{"name": "x", "params": "q=x", "schedule": {"every": "month"}}`,
|
|
`{"name": "x", "params": "q=x", "maxResults": 7, "schedule": {"every": "day", "hour": 8}}`,
|
|
} {
|
|
if code := call(t, mux, "POST", "/api/saved", bad, nil); code != http.StatusBadRequest {
|
|
t.Errorf("%s: got %d, want 400", bad, code)
|
|
}
|
|
}
|
|
|
|
var x SavedSearch
|
|
code := call(t, mux, "POST", "/api/saved",
|
|
`{"name": "Go syslog", "params": "q=syslog&language=Go&page=3", "schedule": {"every": "week", "weekday": 1, "hour": 8}}`, &x)
|
|
if code != http.StatusCreated || x.ID == "" || x.MaxResults != 50 || !x.Enabled || x.NextRunAt.IsZero() {
|
|
t.Fatalf("create: %d %+v", code, x)
|
|
}
|
|
if strings.Contains(x.Params, "page") {
|
|
t.Errorf("page should be dropped from params: %s", x.Params)
|
|
}
|
|
|
|
// The first run starts in the background; run once more by hand.
|
|
var info RunInfo
|
|
if code := call(t, mux, "POST", "/api/saved/"+x.ID+"/run", "", &info); code != http.StatusOK || info.Error != "" || info.Count != 1 {
|
|
t.Fatalf("run: %d %+v", code, info)
|
|
}
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
var runs []RunInfo
|
|
for time.Now().Before(deadline) {
|
|
call(t, mux, "GET", "/api/saved/"+x.ID+"/runs", "", &runs)
|
|
if len(runs) == 2 {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
if len(runs) != 2 {
|
|
t.Fatalf("want 2 runs (background + manual), got %d", len(runs))
|
|
}
|
|
var run Run
|
|
if code := call(t, mux, "GET", "/api/saved/"+x.ID+"/runs/latest", "", &run); code != http.StatusOK || len(run.Items) != 1 || run.Items[0].FullName != "mcuadros/go-syslog" {
|
|
t.Fatalf("latest run: %d %+v", code, run)
|
|
}
|
|
if d := run.Items[0].StarsDelta; d == nil || *d != 0 || run.Items[0].New {
|
|
t.Errorf("second run: repository should be known with a 0 delta: %+v", run.Items[0])
|
|
}
|
|
|
|
var upd SavedSearch
|
|
code = call(t, mux, "PUT", "/api/saved/"+x.ID,
|
|
`{"name": "Renamed", "params": "q=syslog", "maxResults": 100, "enabled": false, "schedule": {"every": "hours", "hours": 6}}`, &upd)
|
|
if code != http.StatusOK || upd.Name != "Renamed" || upd.Enabled || !upd.NextRunAt.IsZero() || upd.LastRun == nil {
|
|
t.Fatalf("update: %d %+v", code, upd)
|
|
}
|
|
|
|
if code := call(t, mux, "GET", "/api/saved/"+x.ID+"/runs/../../x", "", nil); code == http.StatusOK {
|
|
t.Error("path traversal should not succeed")
|
|
}
|
|
if code := call(t, mux, "DELETE", "/api/saved/"+x.ID, "", nil); code != http.StatusNoContent {
|
|
t.Errorf("delete: %d", code)
|
|
}
|
|
if code := call(t, mux, "GET", "/api/saved/"+x.ID, "", nil); code != http.StatusNotFound {
|
|
t.Errorf("get after delete: %d", code)
|
|
}
|
|
_ = a
|
|
}
|
|
|
|
func TestSchedulerRunsDueSearches(t *testing.T) {
|
|
_, a := newTestAPI(t)
|
|
x, _ := a.store.Create(SavedSearch{Name: "due", Params: "q=syslog", MaxResults: 30, Enabled: true,
|
|
Schedule: Schedule{Every: "day", Hour: 8}, NextRunAt: time.Now().Add(-time.Minute)})
|
|
a.store.Create(SavedSearch{Name: "paused", Params: "q=syslog", MaxResults: 30,
|
|
Schedule: Schedule{Every: "day", Hour: 8}, NextRunAt: time.Now().Add(-time.Minute)})
|
|
a.sched.runDue(context.Background())
|
|
|
|
got, _ := a.store.Get(x.ID)
|
|
if got.LastRun == nil || !got.NextRunAt.After(time.Now()) {
|
|
t.Errorf("due search should have run and be planned again: %+v", got)
|
|
}
|
|
for _, s := range a.store.List() {
|
|
if s.Name == "paused" && s.LastRun != nil {
|
|
t.Error("a paused search must not run")
|
|
}
|
|
}
|
|
}
|