Files
kandev/apps/backend/internal/github/poller_schedule_test.go
T
2026-09-23 13:31:12 +01:00

298 lines
11 KiB
Go

package github
import (
"context"
"errors"
"testing"
"time"
)
type scheduleActivityProvider struct {
activities map[string]PRWatchTaskActivity
err error
calls int
}
func (p *scheduleActivityProvider) LoadPRWatchTaskActivity(
_ context.Context, taskIDs []string,
) (map[string]PRWatchTaskActivity, error) {
p.calls++
if p.err != nil {
return nil, p.err
}
result := make(map[string]PRWatchTaskActivity, len(taskIDs))
for _, taskID := range taskIDs {
if activity, ok := p.activities[taskID]; ok {
result[taskID] = activity
}
}
return result, nil
}
func TestSearchingWatchAdaptiveSchedule(t *testing.T) {
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
lastChecked := now.Add(-15 * time.Minute)
createdAt := now.Add(-48 * time.Hour)
tests := []struct {
name string
watch *PRWatch
activity PRWatchTaskActivity
wantEvery time.Duration
wantDue bool
}{
{
name: "running is fast",
watch: scheduleWatch("running", 0, timePtr(lastChecked), createdAt),
activity: PRWatchTaskActivity{Running: true, LastActivityAt: now.Add(-12 * time.Hour)},
wantEvery: searchFastPollInterval,
wantDue: true,
},
{
name: "below two hours is fast",
watch: scheduleWatch("recent", 0, timePtr(lastChecked), createdAt),
activity: PRWatchTaskActivity{LastActivityAt: now.Add(-2*time.Hour + time.Second)},
wantEvery: searchFastPollInterval,
wantDue: true,
},
{
name: "two hour boundary is fifteen minutes",
watch: scheduleWatch("two-hours", 0, timePtr(now.Add(-15*time.Minute)), createdAt),
activity: PRWatchTaskActivity{LastActivityAt: now.Add(-2 * time.Hour)},
wantEvery: searchIdlePollInterval,
wantDue: true,
},
{
name: "just below twenty four hours is fifteen minutes",
watch: scheduleWatch("day-minus", 0, timePtr(now.Add(-15*time.Minute)), createdAt),
activity: PRWatchTaskTaskActivityAt(now.Add(-24*time.Hour + time.Second)),
wantEvery: searchIdlePollInterval,
wantDue: true,
},
{
name: "twenty four hour boundary is thirty minutes",
watch: scheduleWatch("day", 0, timePtr(now.Add(-30*time.Minute)), createdAt),
activity: PRWatchTaskTaskActivityAt(now.Add(-24 * time.Hour)),
wantEvery: searchVeryIdlePollInterval,
wantDue: true,
},
{
name: "slow target waits for its interval",
watch: scheduleWatch("slow", 0, timePtr(now.Add(-10*time.Minute)), createdAt),
activity: PRWatchTaskTaskActivityAt(now.Add(-24 * time.Hour)),
wantEvery: searchVeryIdlePollInterval,
wantDue: false,
},
{
name: "new watch is due",
watch: scheduleWatch("new", 0, nil, now),
activity: PRWatchTaskActivity{},
wantEvery: searchFastPollInterval,
wantDue: true,
},
{
name: "unknown activity stays fast",
watch: scheduleWatch("unknown", 0, timePtr(now.Add(-time.Minute)), createdAt),
activity: PRWatchTaskActivity{},
wantEvery: searchFastPollInterval,
wantDue: true,
},
{
name: "known PR stays fast",
watch: scheduleWatch("known", 42, timePtr(now.Add(-time.Minute)), createdAt),
activity: PRWatchTaskActivity{LastActivityAt: now.Add(-48 * time.Hour)},
wantEvery: defaultPRPollInterval,
wantDue: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := prWatchSearchInterval(now, tt.watch, tt.activity); got != tt.wantEvery {
t.Fatalf("interval = %s, want %s", got, tt.wantEvery)
}
if got := prWatchIsDue(now, tt.watch, tt.activity); got != tt.wantDue {
t.Fatalf("due = %t, want %t", got, tt.wantDue)
}
})
}
}
func TestSearchingWatchCreationKeepsNewWatchFast(t *testing.T) {
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
watch := scheduleWatch("old-task-new-watch", 0, timePtr(now.Add(-2*time.Minute)), now.Add(-30*time.Minute))
activity := PRWatchTaskActivity{LastActivityAt: now.Add(-48 * time.Hour)}
if got := prWatchSearchInterval(now, watch, activity); got != searchFastPollInterval {
t.Fatalf("new watch interval = %s, want %s", got, searchFastPollInterval)
}
if !prWatchIsDue(now, watch, activity) {
t.Fatal("new watch after its first completed check is not due on the fast tier")
}
}
func TestSearchingWatchAdaptiveScheduleSharedTargets(t *testing.T) {
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
provider := &scheduleActivityProvider{activities: map[string]PRWatchTaskActivity{
"fast-task": {LastActivityAt: now.Add(-time.Hour)},
"slow-task": {LastActivityAt: now.Add(-24 * time.Hour)},
}}
poller := &Poller{taskActivityProvider: provider, clock: func() time.Time { return now }}
sharedFast := scheduleWatch("fast-task", 0, timePtr(now.Add(-time.Minute)), now.Add(-time.Hour))
sharedFast.Owner = "owner"
sharedFast.Repo = "repo"
sharedFast.Branch = "shared"
sharedSlow := scheduleWatch("slow-task", 0, timePtr(now.Add(-time.Minute)), now.Add(-24*time.Hour))
sharedSlow.Owner = "owner"
sharedSlow.Repo = "repo"
sharedSlow.Branch = "shared"
allIdle := scheduleWatch("slow-task", 0, timePtr(now.Add(-time.Minute)), now.Add(-24*time.Hour))
allIdle.Owner = "owner"
allIdle.Repo = "repo"
allIdle.Branch = "idle"
got := poller.selectDuePRWatches(context.Background(), []*PRWatch{sharedSlow, sharedFast, allIdle})
if len(got) != 2 {
t.Fatalf("due watches = %d, want shared target only (2)", len(got))
}
if got[0] != sharedSlow || got[1] != sharedFast {
t.Fatalf("due watches = [%p, %p], want shared watches [%p, %p]", got[0], got[1], sharedSlow, sharedFast)
}
if provider.calls != 1 {
t.Fatalf("activity provider calls = %d, want 1 bulk call", provider.calls)
}
}
func TestSearchingWatchAdaptiveScheduleSeparatesNumberedTarget(t *testing.T) {
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
provider := &scheduleActivityProvider{activities: map[string]PRWatchTaskActivity{
"searching-task": {LastActivityAt: now.Add(-24 * time.Hour)},
}}
poller := &Poller{taskActivityProvider: provider, clock: func() time.Time { return now }}
numbered := scheduleWatch("numbered-task", 42, timePtr(now.Add(-time.Minute)), now.Add(-time.Hour))
numbered.Owner, numbered.Repo, numbered.Branch = "owner", "repo", "shared"
searching := scheduleWatch("searching-task", 0, timePtr(now.Add(-time.Minute)), now.Add(-24*time.Hour))
searching.Owner, searching.Repo, searching.Branch = "owner", "repo", "shared"
got := poller.selectDuePRWatches(context.Background(), []*PRWatch{searching, numbered})
if len(got) != 1 || got[0] != numbered {
t.Fatalf("due watches = %#v, want only numbered watch", got)
}
}
func TestSearchingWatchAdaptiveScheduleActivityAndRestart(t *testing.T) {
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
watch := scheduleWatch("task-1", 0, timePtr(now.Add(-10*time.Minute)), now.Add(-48*time.Hour))
activity := PRWatchTaskActivity{LastActivityAt: now.Add(-24 * time.Hour)}
provider := &scheduleActivityProvider{activities: map[string]PRWatchTaskActivity{"task-1": activity}}
first := &Poller{taskActivityProvider: provider, clock: func() time.Time { return now }}
if got := first.selectDuePRWatches(context.Background(), []*PRWatch{watch}); len(got) != 0 {
t.Fatalf("slow watch due before interval = %d, want 0", len(got))
}
// A fresh activity observation makes the same persisted last check due on
// the next scheduler tick, even though the previous cycle used the slow tier.
provider.activities["task-1"] = PRWatchTaskActivity{LastActivityAt: now.Add(-time.Minute)}
if got := first.selectDuePRWatches(context.Background(), []*PRWatch{watch}); len(got) != 1 {
t.Fatalf("watch after fresh activity due = %d, want 1", len(got))
}
// A new poller sees the same persisted timestamp and activity projection.
provider.activities["task-1"] = activity
restarted := &Poller{taskActivityProvider: provider, clock: func() time.Time { return now.Add(20 * time.Minute) }}
if got := restarted.selectDuePRWatches(context.Background(), []*PRWatch{watch}); len(got) != 1 {
t.Fatalf("watch after restart due = %d, want 1", len(got))
}
}
func TestSearchingWatchAdaptiveScheduleProviderFailureFailsOpen(t *testing.T) {
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
watch := scheduleWatch("task-1", 0, timePtr(now.Add(-time.Minute)), now.Add(-48*time.Hour))
poller := &Poller{
taskActivityProvider: &scheduleActivityProvider{err: errors.New("activity unavailable")},
clock: func() time.Time { return now },
}
if got := poller.selectDuePRWatches(context.Background(), []*PRWatch{watch}); len(got) != 1 {
t.Fatalf("watch after activity read failure due = %d, want 1", len(got))
}
}
func TestSearchingWatchAdaptiveScheduleKeepsExternalPRDiscoverable(t *testing.T) {
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
watch := scheduleWatch("task-external", 0, timePtr(now.Add(-30*time.Minute)), now.Add(-48*time.Hour))
activity := PRWatchTaskActivity{LastActivityAt: now.Add(-48 * time.Hour)}
poller := &Poller{
taskActivityProvider: &scheduleActivityProvider{activities: map[string]PRWatchTaskActivity{
watch.TaskID: activity,
}},
clock: func() time.Time { return now },
}
if got := poller.selectDuePRWatches(context.Background(), []*PRWatch{watch}); len(got) != 1 {
t.Fatalf("external PR first slow-tier check = %d, want 1", len(got))
}
// No local commit or task activity is added. The 30-minute tier remains a
// periodic search, so a PR opened externally remains discoverable.
poller.clock = func() time.Time { return now.Add(30 * time.Minute) }
if got := poller.selectDuePRWatches(context.Background(), []*PRWatch{watch}); len(got) != 1 {
t.Fatalf("external PR later slow-tier check = %d, want 1", len(got))
}
}
func TestSearchingWatchAdaptiveScheduleExcludesArchivedAndDeletedTasks(t *testing.T) {
poller, _, _, store := setupPollerTest(t)
ctx := context.Background()
seedTask(t, store, "task-schedule-active", false)
seedTask(t, store, "task-schedule-archived", true)
for _, taskID := range []string{"task-schedule-active", "task-schedule-archived"} {
if err := store.CreatePRWatch(ctx, withTestWorkspace(&PRWatch{
SessionID: "session-" + taskID, TaskID: taskID, Owner: "owner", Repo: "repo", Branch: taskID,
})); err != nil {
t.Fatalf("create watch for %s: %v", taskID, err)
}
}
active, err := store.ListActivePRWatches(ctx)
if err != nil {
t.Fatalf("list active watches: %v", err)
}
if len(active) != 1 || active[0].TaskID != "task-schedule-active" {
t.Fatalf("active watches = %+v, want active task only", active)
}
if _, err := store.db.Exec(`DELETE FROM tasks WHERE id = ?`, "task-schedule-active"); err != nil {
t.Fatalf("delete task: %v", err)
}
active, err = store.ListActivePRWatches(ctx)
if err != nil {
t.Fatalf("list watches after delete: %v", err)
}
if len(active) != 0 {
t.Fatalf("watches after task delete = %+v, want none", active)
}
_ = poller
}
func scheduleWatch(taskID string, prNumber int, lastChecked *time.Time, createdAt time.Time) *PRWatch {
return &PRWatch{
ID: taskID + "-watch",
TaskID: taskID,
WorkspaceID: "workspace-1",
Owner: "owner-" + taskID,
Repo: "repo-" + taskID,
Branch: "branch-" + taskID,
PRNumber: prNumber,
LastCheckedAt: lastChecked,
CreatedAt: createdAt,
}
}
func timePtr(value time.Time) *time.Time {
return &value
}
func PRWatchTaskTaskActivityAt(at time.Time) PRWatchTaskActivity {
return PRWatchTaskActivity{LastActivityAt: at}
}