212 lines
5.9 KiB
Go
212 lines
5.9 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"testing/synctest"
|
|
)
|
|
|
|
func TestTriggerPRSync_SyncsExistingWatch(t *testing.T) {
|
|
_, svc, mockClient, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
|
|
// Set up a PR in mock client.
|
|
mockClient.AddPR(&PR{
|
|
Number: 10,
|
|
Title: "Feature PR",
|
|
State: "open",
|
|
HeadSHA: "abc123",
|
|
HeadBranch: "feat/x",
|
|
RepoOwner: "org",
|
|
RepoName: "repo",
|
|
})
|
|
|
|
// Create a PR watch and TaskPR in DB.
|
|
watch := &PRWatch{
|
|
SessionID: "s1",
|
|
TaskID: "t1",
|
|
Owner: "org",
|
|
Repo: "repo",
|
|
PRNumber: 10,
|
|
Branch: "feat/x",
|
|
}
|
|
if err := store.CreatePRWatch(ctx, watch); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tp := &TaskPR{
|
|
TaskID: "t1",
|
|
Owner: "org",
|
|
Repo: "repo",
|
|
PRNumber: 10,
|
|
PRTitle: "Feature PR",
|
|
State: "open",
|
|
}
|
|
if err := store.CreateTaskPR(ctx, tp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Trigger sync.
|
|
result, err := svc.TriggerPRSync(ctx, "t1")
|
|
if err != nil {
|
|
t.Fatalf("TriggerPRSync: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected non-nil TaskPR")
|
|
}
|
|
if result.LastSyncedAt == nil {
|
|
t.Error("expected LastSyncedAt to be set after sync")
|
|
}
|
|
}
|
|
|
|
func TestTriggerPRSync_DetectsPR(t *testing.T) {
|
|
_, svc, mockClient, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
|
|
// Set up a PR findable by branch.
|
|
mockClient.AddPR(&PR{
|
|
Number: 20,
|
|
Title: "New PR",
|
|
State: "open",
|
|
HeadBranch: "feat/y",
|
|
RepoOwner: "org",
|
|
RepoName: "repo",
|
|
})
|
|
|
|
// Create a watch with pr_number=0 (still searching).
|
|
watch := &PRWatch{
|
|
SessionID: "s2",
|
|
TaskID: "t2",
|
|
Owner: "org",
|
|
Repo: "repo",
|
|
PRNumber: 0,
|
|
Branch: "feat/y",
|
|
}
|
|
if err := store.CreatePRWatch(ctx, watch); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
result, err := svc.TriggerPRSync(ctx, "t2")
|
|
if err != nil {
|
|
t.Fatalf("TriggerPRSync: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("expected TaskPR after detection")
|
|
}
|
|
if result.PRNumber != 20 {
|
|
t.Errorf("expected PR #20, got #%d", result.PRNumber)
|
|
}
|
|
}
|
|
|
|
func TestTriggerPRSync_NoWatch(t *testing.T) {
|
|
_, svc, _, _ := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
|
|
result, err := svc.TriggerPRSync(ctx, "nonexistent")
|
|
if err != nil {
|
|
t.Fatalf("TriggerPRSync: %v", err)
|
|
}
|
|
if result != nil {
|
|
t.Errorf("expected nil TaskPR for task without watch, got %+v", result)
|
|
}
|
|
}
|
|
|
|
// TestTriggerPRSyncAll_ThrottlesDetectionProbe reproduces the log-flood bug:
|
|
// a pr_number=0 watch on a branch that has no PR (e.g. an unresolvable repo)
|
|
// was re-probing GitHub on every on-demand sync, because the detection path —
|
|
// unlike the status-sync path — had no freshness guard and never stamped
|
|
// last_checked_at. The frontend re-syncs every 5s while no PR is found, so
|
|
// each repeated sync hit `gh` again and logged a warning. After the fix the
|
|
// first sync probes once and stamps the watch; the immediate second sync is
|
|
// throttled within prSyncFreshnessWindow and must NOT probe again.
|
|
func TestTriggerPRSyncAll_ThrottlesDetectionProbe(t *testing.T) {
|
|
_, svc, mockClient, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
|
|
// Watch with pr_number=0 on a branch that has no PR in the mock — every
|
|
// FindPRByBranch returns (nil, nil), mirroring "PR not found yet".
|
|
watch := &PRWatch{
|
|
SessionID: "s1",
|
|
TaskID: "t1",
|
|
Owner: "org",
|
|
Repo: "missing",
|
|
PRNumber: 0,
|
|
Branch: "feat/never-merged",
|
|
}
|
|
if err := store.CreatePRWatch(ctx, watch); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := svc.TriggerPRSyncAll(ctx, "t1"); err != nil {
|
|
t.Fatalf("first TriggerPRSyncAll: %v", err)
|
|
}
|
|
if got := mockClient.FindPRByBranchCallCount(); got != 1 {
|
|
t.Fatalf("expected exactly 1 detection probe on first sync, got %d", got)
|
|
}
|
|
|
|
// Second sync immediately after — well within prSyncFreshnessWindow.
|
|
if _, err := svc.TriggerPRSyncAll(ctx, "t1"); err != nil {
|
|
t.Fatalf("second TriggerPRSyncAll: %v", err)
|
|
}
|
|
if got := mockClient.FindPRByBranchCallCount(); got != 1 {
|
|
t.Errorf("expected detection probe to be throttled on second sync (still 1 call), got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestTriggerPRDetection_CoalescesConcurrentProbes verifies that parallel
|
|
// syncs for the same watch collapse to a single GitHub probe. The freshness
|
|
// guard alone is racy — concurrent callers can all read a stale
|
|
// last_checked_at and probe simultaneously — so detection runs inside a
|
|
// per-watch singleflight. We force overlap by blocking the probe: the leader
|
|
// enters FindPRByBranch and parks; the followers must coalesce into it rather
|
|
// than issue their own probes.
|
|
//
|
|
// synctest.Wait() supplies the otherwise-unobservable "all followers have
|
|
// joined the singleflight" signal: it returns only once every goroutine is
|
|
// durably blocked (the leader on the release channel, the followers inside
|
|
// singleflight). That makes the coalescing assertion deterministic without a
|
|
// fixed sleep.
|
|
func TestTriggerPRDetection_CoalescesConcurrentProbes(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
_, svc, mockClient, store := setupPollerTest(t)
|
|
ctx := context.Background()
|
|
|
|
watch := &PRWatch{
|
|
SessionID: "s1",
|
|
TaskID: "t1",
|
|
Owner: "org",
|
|
Repo: "missing",
|
|
PRNumber: 0,
|
|
Branch: "feat/never-merged",
|
|
}
|
|
if err := store.CreatePRWatch(ctx, watch); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
const concurrency = 6
|
|
release := make(chan struct{})
|
|
mockClient.GateFindPRByBranch(nil, release) // block the leader inside FindPRByBranch
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(concurrency)
|
|
for range concurrency {
|
|
go func() {
|
|
defer wg.Done()
|
|
if _, err := svc.TriggerPRSyncAll(ctx, "t1"); err != nil {
|
|
t.Errorf("TriggerPRSyncAll: %v", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Block until the leader is parked in FindPRByBranch and every other
|
|
// caller has coalesced into its singleflight call.
|
|
synctest.Wait()
|
|
close(release)
|
|
wg.Wait()
|
|
|
|
if got := mockClient.FindPRByBranchCallCount(); got != 1 {
|
|
t.Errorf("expected concurrent detection probes to coalesce to 1 GitHub call, got %d", got)
|
|
}
|
|
})
|
|
}
|