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

108 lines
3.6 KiB
Go

package github
import (
"context"
"sync"
"sync/atomic"
"testing"
)
// countingFeedbackClient counts top-level GetPRFeedback calls so the cache /
// singleflight behavior of Service.GetPRFeedback can be asserted without
// reaching real GitHub.
type countingFeedbackClient struct {
*MockClient
calls atomic.Int32
}
func (c *countingFeedbackClient) GetPRFeedback(_ context.Context, owner, repo string, number int) (*PRFeedback, error) {
c.calls.Add(1)
return &PRFeedback{PR: &PR{RepoOwner: owner, RepoName: repo, Number: number}}, nil
}
// Repeated feedback fetches for the same PR within the TTL window must collapse
// to a single upstream call. Before the cache was added, every call hit GitHub
// (4 sequential REST calls each), so a render/mount burst on the task page
// fanned out into dozens of slow, rate-limited duplicate requests.
func TestService_GetPRFeedback_UsesCache(t *testing.T) {
client := &countingFeedbackClient{MockClient: NewMockClient()}
svc := newTestService(client)
ctx := context.Background()
for i := 0; i < 3; i++ {
if _, err := svc.GetPRFeedback(ctx, "o", "r", 1); err != nil {
t.Fatalf("call %d: %v", i, err)
}
}
if got := client.calls.Load(); got != 1 {
t.Fatalf("expected 1 upstream call, got %d", got)
}
if _, err := svc.GetPRFeedback(ctx, "o", "r", 2); err != nil {
t.Fatal(err)
}
if got := client.calls.Load(); got != 2 {
t.Fatalf("expected new fetch for different number, got %d", got)
}
}
// blockingFeedbackClient blocks inside GetPRFeedback until released, so the
// test can force many concurrent in-flight fetches for the same PR and assert
// they coalesce into one upstream call (singleflight). `started` is signaled
// the moment a goroutine enters the upstream call so the test can be certain
// at least one fetch is in flight before releasing siblings.
type blockingFeedbackClient struct {
*MockClient
calls atomic.Int32
release chan struct{}
started chan struct{}
}
func (c *blockingFeedbackClient) GetPRFeedback(_ context.Context, owner, repo string, number int) (*PRFeedback, error) {
c.calls.Add(1)
// Non-blocking signal so only the FIRST entrant fires `started`; later
// arrivals queue behind the singleflight without re-signaling.
select {
case c.started <- struct{}{}:
default:
}
<-c.release
return &PRFeedback{PR: &PR{RepoOwner: owner, RepoName: repo, Number: number}}, nil
}
// Concurrent feedback fetches for the same PR must coalesce to a single
// upstream call. This reproduces the production pile-up: the task page fired ~8
// identical GetPRFeedback requests concurrently, and without singleflight each
// independently hammered GitHub, ballooning durations to 40-73s.
func TestService_GetPRFeedback_CoalescesConcurrentCalls(t *testing.T) {
client := &blockingFeedbackClient{
MockClient: NewMockClient(),
release: make(chan struct{}),
started: make(chan struct{}, 1),
}
svc := newTestService(client)
const n = 16
var done sync.WaitGroup
done.Add(n)
for i := 0; i < n; i++ {
go func() {
defer done.Done()
if _, err := svc.GetPRFeedback(context.Background(), "o", "r", 1); err != nil {
t.Errorf("concurrent fetch: %v", err)
}
}()
}
// Wait for the leader to actually enter the upstream call (not just be
// scheduled). Pre-fix this used launched.Wait, which only guaranteed the
// goroutines had STARTED — late arrivals after release could have seen
// a warm cache and skipped the singleflight, masking regressions.
<-client.started
close(client.release)
done.Wait()
if got := client.calls.Load(); got != 1 {
t.Fatalf("expected concurrent calls to coalesce to 1 upstream call, got %d", got)
}
}