273 lines
9.5 KiB
Go
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")
|
|
}
|