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

273 lines
9.5 KiB
Go

package github
import (
"context"
"errors"
"sync"
"testing"
"time"
)
type passiveFallbackGraphQLClient struct {
*graphQLMockClient
mu sync.Mutex
findErr error
findCalls int
}
func (c *passiveFallbackGraphQLClient) FindPRByBranch(
context.Context, string, string, string,
) (*PR, error) {
c.mu.Lock()
c.findCalls++
err := c.findErr
c.mu.Unlock()
return nil, err
}
func (c *passiveFallbackGraphQLClient) findPRByBranchCallCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.findCalls
}
func TestPassiveFallbackAdmissionBoundsWorkspaceAndGlobalWindow(t *testing.T) {
_, svc, _, store := setupPollerTest(t)
clock := time.Date(2026, time.September, 22, 12, 0, 0, 0, time.UTC)
svc.SetClock(func() time.Time { return clock })
watches := seedSearchingFallbackWatches(t, store, 7)
first := svc.selectPassiveFallbackTargets(testWorkspaceID, watches)
if len(first) != prWatchFallbackWorkspaceBudget {
t.Fatalf("first workspace fallback targets = %d, want %d", len(first), prWatchFallbackWorkspaceBudget)
}
otherWorkspace := make([]*PRWatch, 7)
for index := range otherWorkspace {
otherWorkspace[index] = &PRWatch{
ID: "other-" + watches[index].ID,
WorkspaceID: "workspace-other",
Owner: "o",
Repo: "r",
Branch: watches[index].Branch,
}
}
second := svc.selectPassiveFallbackTargets("workspace-other", otherWorkspace)
if len(second) != prWatchFallbackWorkspaceBudget {
t.Fatalf("second workspace fallback targets = %d, want %d", len(second), prWatchFallbackWorkspaceBudget)
}
if third := svc.selectPassiveFallbackTargets(testWorkspaceID, watches); len(third) != 0 {
t.Fatalf("same-window workspace fallback targets = %d, want 0", len(third))
}
clock = clock.Add(time.Minute)
rotated := svc.selectPassiveFallbackTargets(testWorkspaceID, watches)
if len(rotated) != prWatchFallbackWorkspaceBudget {
t.Fatalf("next-window fallback targets = %d, want %d", len(rotated), prWatchFallbackWorkspaceBudget)
}
if rotated[0].ID == first[0].ID {
t.Fatalf("fallback target rotation restarted at %q", rotated[0].ID)
}
}
func TestPassiveFallbackAdmissionUsesRollingMinute(t *testing.T) {
_, svc, _, store := setupPollerTest(t)
start := time.Date(2026, time.September, 22, 12, 0, 59, 0, time.UTC)
clock := start
svc.SetClock(func() time.Time { return clock })
firstWorkspace := seedSearchingFallbackWatches(t, store, 7)
secondWorkspace := make([]*PRWatch, 7)
for index := range secondWorkspace {
secondWorkspace[index] = &PRWatch{
ID: "rolling-other-" + firstWorkspace[index].ID,
WorkspaceID: "workspace-rolling-other",
Owner: "o",
Repo: "r",
Branch: firstWorkspace[index].Branch,
}
}
if got := len(svc.selectPassiveFallbackTargets(testWorkspaceID, firstWorkspace)); got != prWatchFallbackWorkspaceBudget {
t.Fatalf("first workspace admissions = %d, want %d", got, prWatchFallbackWorkspaceBudget)
}
if got := len(svc.selectPassiveFallbackTargets("workspace-rolling-other", secondWorkspace)); got != prWatchFallbackWorkspaceBudget {
t.Fatalf("second workspace admissions = %d, want %d", got, prWatchFallbackWorkspaceBudget)
}
// A wall-clock window would reset at 12:01:00 and admit another burst.
// The rolling window keeps the ten admissions active until each is a full
// minute old.
clock = start.Add(2 * time.Second)
if got := len(svc.selectPassiveFallbackTargets(testWorkspaceID, firstWorkspace)); got != 0 {
t.Fatalf("boundary burst admitted %d targets, want 0", got)
}
clock = start.Add(time.Minute)
if got := len(svc.selectPassiveFallbackTargets(testWorkspaceID, firstWorkspace)); got != prWatchFallbackWorkspaceBudget {
t.Fatalf("expired rolling admissions = %d, want %d", got, prWatchFallbackWorkspaceBudget)
}
}
func TestPassiveFallbackStopsAfterFirstRateLimit(t *testing.T) {
_, svc, _, store := setupPollerTest(t)
rateClient := &fallbackProbeClient{
Client: NewMockClient(),
findErr: rateLimitAPIError(429, "secondary limit"),
}
configureTestWorkspaceAuth(t, svc, rateClient, testWorkspaceID)
watches := seedSearchingFallbackWatches(t, store, 7)
err := svc.refreshWatchesPerWatch(context.Background(), testWorkspaceID, watches)
if err == nil || !prWatchFallbackShouldStopWorkspace(err) {
t.Fatalf("fallback error = %v, want a workspace stop", err)
}
if got := len(rateClient.fallbackBranches()); got != 1 {
t.Fatalf("rate-limited passive fallback calls = %d, want one", got)
}
}
func TestPassiveFallbackSkipsRateLimitedBatch(t *testing.T) {
_, svc, client, store := setupBatchedPollerTest(t)
rateErr := rateLimitAPIError(429, "secondary limit")
client.branchErr = rateErr
watches := seedSearchingFallbackWatches(t, store, 7)
staleTasks := make(map[string]struct{}, len(watches))
for _, watch := range watches {
staleTasks[watch.TaskID] = struct{}{}
}
svc.refreshStaleWorkspaceWatches(testWorkspaceID, staleTasks)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, ok := svc.inflightWorkspaceRefreshes.Load(testWorkspaceID); !ok {
break
}
time.Sleep(5 * time.Millisecond)
}
if _, ok := svc.inflightWorkspaceRefreshes.Load(testWorkspaceID); ok {
t.Fatal("workspace refresh did not finish")
}
if got := len(client.branchQueries); got != 1 {
t.Fatalf("batch branch queries = %d, want one", got)
}
// The GraphQL error is rate limited, so the passive legacy fallback must
// not issue a second provider request for any of the seven targets.
if got := client.FindPRByBranchCallCount(); got != 0 {
t.Fatalf("per-watch fallback calls = %d, want 0", got)
}
}
func TestPassiveFallbackOrdinaryErrorContinuesWithinBudget(t *testing.T) {
_, svc, _, store := setupPollerTest(t)
ordinary := &fallbackProbeClient{Client: NewMockClient(), findErr: errors.New("temporary transport failure")}
configureTestWorkspaceAuth(t, svc, ordinary, testWorkspaceID)
watches := seedSearchingFallbackWatches(t, store, 7)
if err := svc.refreshWatchesPerWatch(context.Background(), testWorkspaceID, watches); err != nil {
t.Fatalf("ordinary fallback error = %v, want best-effort completion", err)
}
if got := len(ordinary.fallbackBranches()); got != prWatchFallbackWorkspaceBudget {
t.Fatalf("ordinary passive fallback calls = %d, want %d", got, prWatchFallbackWorkspaceBudget)
}
}
func TestPassiveFallbackDeduplicatesSharedTargetsAndFansOut(t *testing.T) {
_, svc, _, store := setupPollerTest(t)
ctx := context.Background()
client := &fallbackProbeClient{Client: NewMockClient()}
configureTestWorkspaceAuth(t, svc, client, testWorkspaceID)
watches := seedSearchingFallbackWatches(t, store, 5)
watches[1].Branch = watches[0].Branch
if err := svc.refreshWatchesPerWatch(context.Background(), testWorkspaceID, watches); err != nil {
t.Fatalf("passive fallback error = %v", err)
}
branches := client.fallbackBranches()
if len(branches) != 4 {
t.Fatalf("passive fallback calls = %d, want one per unique target", len(branches))
}
sharedCalls := 0
for _, branch := range branches {
if branch == watches[0].Branch {
sharedCalls++
}
}
if sharedCalls != 1 {
t.Fatalf("passive shared target calls = %d, want one", sharedCalls)
}
for _, watch := range watches {
updated, err := store.GetPRWatch(ctx, watch.ID)
if err != nil {
t.Fatalf("get watch %q: %v", watch.ID, err)
}
if updated == nil || updated.LastCheckedAt == nil {
t.Fatalf("watch %q did not receive the shared fallback result", watch.ID)
}
}
}
func TestPassiveWorkspaceFallbackBoundsTargetsAndStopsAfterFirstRateLimit(t *testing.T) {
_, svc, _, store := setupBatchedPollerTest(t)
client := &passiveFallbackGraphQLClient{
graphQLMockClient: &graphQLMockClient{
MockClient: NewMockClient(),
branchErr: errors.New("temporary graphql transport failure"),
},
findErr: rateLimitAPIError(429, "secondary limit"),
}
configureTestWorkspaceAuth(t, svc, client, testWorkspaceID)
watches := seedSearchingFallbackWatches(t, store, 7)
staleTasks := make(map[string]struct{}, len(watches))
for _, watch := range watches {
staleTasks[watch.TaskID] = struct{}{}
}
svc.refreshStaleWorkspaceWatches(testWorkspaceID, staleTasks)
waitForPassiveWorkspaceRefresh(t, svc, testWorkspaceID)
if got := client.findPRByBranchCallCount(); got != 1 {
t.Fatalf("workspace fallback calls after first rate limit = %d, want 1", got)
}
if got := len(client.branchQueries); got != 1 {
t.Fatalf("workspace batch queries = %d, want 1", got)
}
svc.Stop()
}
func TestPassiveWorkspaceFallbackUsesFiveTargetBudget(t *testing.T) {
_, svc, _, store := setupBatchedPollerTest(t)
client := &passiveFallbackGraphQLClient{
graphQLMockClient: &graphQLMockClient{
MockClient: NewMockClient(),
branchErr: errors.New("temporary graphql transport failure"),
},
findErr: errors.New("temporary REST transport failure"),
}
configureTestWorkspaceAuth(t, svc, client, testWorkspaceID)
watches := seedSearchingFallbackWatches(t, store, 7)
staleTasks := make(map[string]struct{}, len(watches))
for _, watch := range watches {
staleTasks[watch.TaskID] = struct{}{}
}
svc.refreshStaleWorkspaceWatches(testWorkspaceID, staleTasks)
waitForPassiveWorkspaceRefresh(t, svc, testWorkspaceID)
if got := client.findPRByBranchCallCount(); got != prWatchFallbackWorkspaceBudget {
t.Fatalf("workspace fallback calls = %d, want %d", got, prWatchFallbackWorkspaceBudget)
}
svc.Stop()
}
func waitForPassiveWorkspaceRefresh(t *testing.T, svc *Service, workspaceID string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, ok := svc.inflightWorkspaceRefreshes.Load(workspaceID); !ok {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("workspace refresh did not finish")
}