Files
kandev/apps/backend/internal/github/service_pr_sync_test.go
T

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)
}
})
}