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

338 lines
13 KiB
Go

package github
import (
"context"
"sync/atomic"
"testing"
"time"
ws "github.com/kandev/kandev/pkg/websocket"
)
func TestPassiveWorkspaceRefreshHonorsAdaptiveSchedule(t *testing.T) {
_, svc, gh, store := setupBatchedPollerTest(t)
ctx := context.Background()
// Keep the persisted watch older than the injected scheduler clock. This
// isolates task inactivity from the new-watch fast baseline.
now := time.Now().UTC().Add(48 * time.Hour)
svc.SetClock(func() time.Time { return now })
provider := &scheduleActivityProvider{activities: map[string]PRWatchTaskActivity{
"task-passive-slow": {LastActivityAt: now.Add(-24 * time.Hour)},
}}
svc.SetTaskActivityProvider(provider)
seedTask(t, store, "task-passive-slow", false)
watch, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-passive", "task-passive-slow", "repo-1", "owner", "repo", 0, "feature/passive")
if err != nil {
t.Fatalf("create passive watch: %v", err)
}
lastChecked := now.Add(-10 * time.Minute)
if err := store.UpdatePRWatchTimestamps(ctx, watch.ID, lastChecked, nil, "", ""); err != nil {
t.Fatalf("stamp passive watch: %v", err)
}
if _, err := svc.ListWorkspaceTaskPRs(ctx, testWorkspaceID); err != nil {
t.Fatalf("passive workspace read: %v", err)
}
time.Sleep(100 * time.Millisecond)
if len(gh.branchQueries) != 0 {
t.Fatal("passive refresh checked a slow searching watch before its due interval")
}
gh.branchResponses = []string{`{"data":{"b0":{"pullRequests":{"nodes":[]}}}}`}
started := make(chan struct{}, 1)
gh.onExecute = func() { started <- struct{}{} }
svc.SetClock(func() time.Time { return now.Add(30 * time.Minute) })
if _, err := svc.ListWorkspaceTaskPRs(ctx, testWorkspaceID); err != nil {
t.Fatalf("due passive workspace read: %v", err)
}
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("passive refresh did not check the slow searching watch at its due interval")
}
svc.Stop()
}
func TestPassiveWorkspaceRefreshCooldownSkipsRepeatedWorkspaceReads(t *testing.T) {
_, svc, gh, store := setupBatchedPollerTest(t)
ctx := context.Background()
now := time.Now().UTC()
svc.SetClock(func() time.Time { return now })
seedTask(t, store, "task-passive-cooldown", false)
if _, err := svc.CreatePRWatchForWorkspace(
ctx, testWorkspaceID, "session-passive-cooldown", "task-passive-cooldown", "repo-1",
"owner", "repo", 0, "feature/passive-cooldown",
); err != nil {
t.Fatalf("create passive watch: %v", err)
}
gh.branchResponses = []string{
`{"data":{"b0":{"pullRequests":{"nodes":[]}}}}`,
`{"data":{"b0":{"pullRequests":{"nodes":[]}}}}`,
}
if _, err := svc.ListWorkspaceTaskPRs(ctx, testWorkspaceID); err != nil {
t.Fatalf("first passive workspace read: %v", err)
}
waitForPassiveWorkspaceRefresh(t, svc, testWorkspaceID)
if got := len(gh.branchQueries); got != 1 {
t.Fatalf("first passive refresh queries = %d, want 1", got)
}
if _, err := svc.ListWorkspaceTaskPRs(ctx, testWorkspaceID); err != nil {
t.Fatalf("second passive workspace read: %v", err)
}
time.Sleep(100 * time.Millisecond)
if got := len(gh.branchQueries); got != 1 {
t.Fatalf("cooldown allowed repeated passive refresh queries = %d, want 1", got)
}
svc.SetClock(func() time.Time { return now.Add(2 * searchFastPollInterval) })
if _, err := svc.ListWorkspaceTaskPRs(ctx, testWorkspaceID); err != nil {
t.Fatalf("due passive workspace read: %v", err)
}
waitForPassiveWorkspaceRefresh(t, svc, testWorkspaceID)
if got := len(gh.branchQueries); got != 2 {
t.Fatalf("passive refresh after cooldown queries = %d, want 2", got)
}
svc.Stop()
}
func TestExplicitPRRefreshBypassesAdaptiveIdleSchedule(t *testing.T) {
_, svc, gh, store := setupBatchedPollerTest(t)
ctx := context.Background()
now := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
svc.SetClock(func() time.Time { return now })
svc.SetTaskActivityProvider(&scheduleActivityProvider{activities: map[string]PRWatchTaskActivity{
"task-explicit-slow": {LastActivityAt: now.Add(-24 * time.Hour)},
}})
seedTask(t, store, "task-explicit-slow", false)
watch, err := svc.CreatePRWatchForWorkspace(ctx, testWorkspaceID, "session-explicit", "task-explicit-slow", "repo-1", "owner", "repo", 0, "feature/explicit")
if err != nil {
t.Fatalf("create explicit watch: %v", err)
}
if err := store.UpdatePRWatchTimestamps(ctx, watch.ID, now.Add(-10*time.Minute), nil, "", ""); err != nil {
t.Fatalf("stamp explicit watch: %v", err)
}
gh.branchResponses = []string{`{"data":{"b0":{"pullRequests":{"nodes":[]}}}}`}
if _, err := svc.TriggerPRSyncAll(ctx, "task-explicit-slow"); err != nil {
t.Fatalf("explicit PR refresh: %v", err)
}
if len(gh.branchQueries) != 1 {
t.Fatalf("explicit refresh branch queries = %d, want 1", len(gh.branchQueries))
}
}
func TestWSSyncTaskPRUsesPassiveAdmissionAndExplicitRefresh(t *testing.T) {
_, svc, gh, store := setupBatchedPollerTest(t)
ctx := context.Background()
now := time.Now().UTC().Add(48 * time.Hour)
svc.SetClock(func() time.Time { return now })
svc.SetTaskActivityProvider(&scheduleActivityProvider{activities: map[string]PRWatchTaskActivity{
"task-ws-passive": {LastActivityAt: now.Add(-24 * time.Hour)},
}})
seedTask(t, store, "task-ws-passive", false)
watch, err := svc.CreatePRWatchForWorkspace(
ctx, testWorkspaceID, "session-ws-passive", "task-ws-passive", "repo-1",
"owner", "repo", 0, "feature/ws-passive",
)
if err != nil {
t.Fatalf("create watch: %v", err)
}
if err := store.UpdatePRWatchTimestamps(ctx, watch.ID, now.Add(-10*time.Minute), nil, "", ""); err != nil {
t.Fatalf("stamp watch: %v", err)
}
passive, err := ws.NewRequest("passive", ws.ActionGitHubTaskPRSync, map[string]any{
"task_id": "task-ws-passive",
})
if err != nil {
t.Fatalf("build passive request: %v", err)
}
if _, err := wsSyncTaskPR(svc, nil)(ctx, passive); err != nil {
t.Fatalf("passive sync request: %v", err)
}
if len(gh.branchQueries) != 0 {
t.Fatalf("passive idle branch queries = %d, want 0", len(gh.branchQueries))
}
gh.branchResponses = []string{`{"data":{"b0":{"pullRequests":{"nodes":[]}}}}`}
explicit, err := ws.NewRequest("explicit", ws.ActionGitHubTaskPRSync, map[string]any{
"task_id": "task-ws-passive",
"explicit_refresh": true,
})
if err != nil {
t.Fatalf("build explicit request: %v", err)
}
if _, err := wsSyncTaskPR(svc, nil)(ctx, explicit); err != nil {
t.Fatalf("explicit sync request: %v", err)
}
if len(gh.branchQueries) != 1 {
t.Fatalf("explicit branch queries = %d, want 1", len(gh.branchQueries))
}
svc.Stop()
}
func TestWSSyncTaskPRExplicitRefreshSupersedesInFlightPassiveBatch(t *testing.T) {
_, svc, gh, store := setupBatchedPollerTest(t)
defer svc.Stop()
ctx := context.Background()
seedTask(t, store, "task-ws-inflight", false)
_, err := svc.CreatePRWatchForWorkspace(
ctx, testWorkspaceID, "session-ws-inflight", "task-ws-inflight", "repo-1",
"owner", "repo", 42, "feature/ws-inflight",
)
if err != nil {
t.Fatalf("create watch: %v", err)
}
if err := store.CreateTaskPR(ctx, &TaskPR{
TaskID: "task-ws-inflight", Owner: "owner", Repo: "repo", PRNumber: 42,
PRURL: "https://github.com/owner/repo/pull/42", PRTitle: "PR 42",
HeadBranch: "feature/ws-inflight", BaseBranch: "main", State: "open", HeadSHA: "initial",
}); err != nil {
t.Fatalf("create task PR: %v", err)
}
gh.prResponses = []string{
`{"data":{"repo0":{"pr0":{"state":"OPEN","title":"PR 42","url":"https://github.com/owner/repo/pull/42","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","headRefName":"feature/ws-inflight","baseRefName":"main","headRefOid":"passive-head","author":{"login":"alice"},"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-02T00:00:00Z","reviews":{"nodes":[]},"reviewRequests":{"totalCount":0},"commits":{"nodes":[]}}}}}`,
`{"data":{"repo0":{"pr0":{"state":"OPEN","title":"PR 42","url":"https://github.com/owner/repo/pull/42","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","headRefName":"feature/ws-inflight","baseRefName":"main","headRefOid":"explicit-head","author":{"login":"alice"},"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-02T00:00:00Z","reviews":{"nodes":[]},"reviewRequests":{"totalCount":0},"commits":{"nodes":[]}}}}}`,
}
started := make(chan struct{})
secondStarted := make(chan struct{}, 1)
releaseFirst := make(chan struct{})
var executeCount atomic.Int32
gh.onExecute = func() {
switch executeCount.Add(1) {
case 1:
close(started)
<-releaseFirst
case 2:
secondStarted <- struct{}{}
}
}
passive, err := ws.NewRequest("passive-inflight", ws.ActionGitHubTaskPRSync, map[string]any{
"task_id": "task-ws-inflight",
})
if err != nil {
t.Fatalf("build passive request: %v", err)
}
passiveDone := make(chan error, 1)
go func() {
_, requestErr := wsSyncTaskPR(svc, nil)(ctx, passive)
passiveDone <- requestErr
}()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("passive sync did not enter the provider call")
}
explicit, err := ws.NewRequest("explicit-inflight", ws.ActionGitHubTaskPRSync, map[string]any{
"task_id": "task-ws-inflight",
"explicit_refresh": true,
})
if err != nil {
t.Fatalf("build explicit request: %v", err)
}
explicitDone := make(chan error, 1)
go func() {
_, requestErr := wsSyncTaskPR(svc, nil)(ctx, explicit)
explicitDone <- requestErr
}()
select {
case <-secondStarted:
case <-time.After(2 * time.Second):
close(releaseFirst)
<-passiveDone
<-explicitDone
t.Fatal("explicit refresh joined the in-flight passive provider call")
}
close(releaseFirst)
select {
case err := <-passiveDone:
if err != nil {
t.Fatalf("passive sync: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("passive sync did not finish")
}
select {
case err := <-explicitDone:
if err != nil {
t.Fatalf("explicit sync: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("explicit sync did not finish")
}
if got := len(gh.prQueries); got != 2 {
t.Fatalf("provider batch calls = %d, want two independent reads", got)
}
}
func TestWSSyncTaskPRPassiveKeepsActionsCache(t *testing.T) {
_, svc, gh, store := setupBatchedPollerTest(t)
ctx := context.Background()
seedTask(t, store, "task-ws-actions", false)
_, err := svc.CreatePRWatchForWorkspace(
ctx, testWorkspaceID, "session-ws-actions", "task-ws-actions", "repo-1",
"owner", "repo", 42, "feature/ws-actions",
)
if err != nil {
t.Fatalf("create numbered watch: %v", err)
}
scope := testAutomationScope(t, svc, testWorkspaceID)
key := workflowRunsCacheKey(scope, "owner", "repo", "head-ws-actions")
resolved, err := svc.resolveAutomationClient(ctx, testWorkspaceID, "", "")
if err != nil {
t.Fatalf("resolve workspace client: %v", err)
}
personalScope := resolved.cacheScopeForPurpose(CredentialPurposePersonalRead)
personalKey := workflowRunsCacheKey(personalScope, "owner", "repo", "head-ws-actions")
svc.workflowRunsCache.setWithTTL(key, []WorkflowRun{{
ID: 1, RunAttempt: 1, Status: workflowStatusCompleted, Conclusion: "success",
}}, workflowAttentionLongTTL)
svc.workflowRunsCache.setWithTTL(personalKey, []WorkflowRun{{
ID: 1, RunAttempt: 1, Status: workflowStatusCompleted, Conclusion: "success",
}}, workflowAttentionLongTTL)
gh.ReplaceWorkflowRuns("owner", "repo", "head-ws-actions", []WorkflowRun{{
ID: 2, RunAttempt: 1, Status: workflowStatusCompleted, Conclusion: "success",
}})
response := `{"data":{"repo0":{"pr0":{"state":"OPEN","title":"PR","url":"https://x/42","isDraft":false,"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","headRefName":"feature/ws-actions","baseRefName":"main","headRefOid":"head-ws-actions","author":{"login":"alice"},"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-02T00:00:00Z","reviews":{"nodes":[]},"reviewRequests":{"totalCount":0},"commits":{"nodes":[{"commit":{"statusCheckRollup":{"state":"SUCCESS"}}}]}}}}}`
gh.prResponses = []string{response, response}
passive, err := ws.NewRequest("passive-actions", ws.ActionGitHubTaskPRSync, map[string]any{
"task_id": "task-ws-actions",
})
if err != nil {
t.Fatalf("build passive actions request: %v", err)
}
if _, err := wsSyncTaskPR(svc, nil)(ctx, passive); err != nil {
t.Fatalf("passive actions sync: %v", err)
}
passiveValue, ok := svc.workflowRunsCache.get(key)
if !ok || passiveValue.([]WorkflowRun)[0].ID != 1 {
t.Fatal("passive task sync invalidated completed Actions cache")
}
if personalValue, ok := svc.workflowRunsCache.get(personalKey); !ok || personalValue.([]WorkflowRun)[0].ID != 1 {
t.Fatal("passive task sync invalidated the personal-read Actions cache")
}
explicit, err := ws.NewRequest("explicit-actions", ws.ActionGitHubTaskPRSync, map[string]any{
"task_id": "task-ws-actions",
"explicit_refresh": true,
})
if err != nil {
t.Fatalf("build explicit actions request: %v", err)
}
if _, err := wsSyncTaskPR(svc, nil)(ctx, explicit); err != nil {
t.Fatalf("explicit actions sync: %v", err)
}
explicitValue, ok := svc.workflowRunsCache.get(key)
if !ok || explicitValue.([]WorkflowRun)[0].ID != 2 {
t.Fatalf("explicit task refresh did not replace the Actions cache: %#v", explicitValue)
}
if _, ok := svc.workflowRunsCache.get(personalKey); ok {
t.Fatal("explicit task refresh left the personal-read Actions cache populated")
}
svc.Stop()
}