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

409 lines
14 KiB
Go

package github
import (
"context"
"errors"
"strconv"
"sync/atomic"
"testing"
"time"
"github.com/kandev/kandev/internal/common/logger"
)
// accessibleReposStubClient lets each test wire its own ListAccessibleRepos
// behaviour without touching the broader stubClient (which other tests depend
// on with its current shape). It also records the context each call observed so
// the detached-context regression test can assert the fetch is not cancelled
// when the caller's request is.
type accessibleReposStubClient struct {
stubClient
repos []GitHubRepo
reposErr error
calls atomic.Int32
// lastCtxErr records ctx.Err() observed at call time. The detached-context
// test cancels the caller context before calling and asserts this stays nil
// — proving the service ran the fetch under a context that survives the
// request abort.
lastCtxErr atomic.Value // error
}
func (s *accessibleReposStubClient) ListAccessibleRepos(ctx context.Context, query string, _ int) ([]GitHubRepo, error) {
s.calls.Add(1)
s.lastCtxErr.Store(errBox{ctx.Err()})
if s.reposErr != nil {
return nil, s.reposErr
}
return filterReposByQuery(s.repos, query), nil
}
// errBox wraps an error so it can be stored in an atomic.Value even when nil.
type errBox struct{ err error }
func (s *accessibleReposStubClient) observedCtxErr() error {
v, ok := s.lastCtxErr.Load().(errBox)
if !ok {
return nil
}
return v.err
}
func newAccessibleReposTestService(client Client) *Service {
return &Service{
client: client,
authMethod: AuthMethodPAT,
logger: logger.Default(),
accessibleReposCache: newAccessibleReposCache(),
}
}
// ptrTime is a tiny helper to take the address of a time.Time literal for the
// pointer-typed GitHubRepo.PushedAt field used in these tests.
func ptrTime(t time.Time) *time.Time { return &t }
func TestListAccessibleRepos_ReturnsRepos(t *testing.T) {
t0 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
sc := &accessibleReposStubClient{
repos: []GitHubRepo{
{FullName: "alice/personal", Owner: "alice", Name: "personal", DefaultBranch: "main", Description: "Alice's personal repo", PushedAt: ptrTime(t0.Add(3 * time.Hour))},
{FullName: "acme/widget", Owner: "acme", Name: "widget", DefaultBranch: "trunk", Description: "Widget service", PushedAt: ptrTime(t0.Add(2 * time.Hour))},
{FullName: "globex/foo", Owner: "globex", Name: "foo", DefaultBranch: "main", PushedAt: ptrTime(t0.Add(time.Hour))},
},
}
svc := newAccessibleReposTestService(sc)
got, err := svc.ListAccessibleRepos(context.Background(), "", 50)
if err != nil {
t.Fatalf("ListAccessibleRepos: %v", err)
}
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
wantByName := map[string]struct {
defaultBranch string
description string
}{
"alice/personal": {"main", "Alice's personal repo"},
"acme/widget": {"trunk", "Widget service"},
"globex/foo": {"main", ""},
}
for _, r := range got {
want, ok := wantByName[r.FullName]
if !ok {
t.Errorf("unexpected repo %q in result", r.FullName)
continue
}
if r.DefaultBranch != want.defaultBranch {
t.Errorf("repo %q DefaultBranch = %q, want %q", r.FullName, r.DefaultBranch, want.defaultBranch)
}
if r.Description != want.description {
t.Errorf("repo %q Description = %q, want %q", r.FullName, r.Description, want.description)
}
}
}
func TestListAccessibleRepos_SortsByPushedAt(t *testing.T) {
t0 := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
sc := &accessibleReposStubClient{
repos: []GitHubRepo{
{FullName: "u/middle", Owner: "u", Name: "middle", PushedAt: ptrTime(t0.Add(2 * time.Hour))},
{FullName: "acme/oldest", Owner: "acme", Name: "oldest", PushedAt: ptrTime(t0)},
{FullName: "acme/newest", Owner: "acme", Name: "newest", PushedAt: ptrTime(t0.Add(5 * time.Hour))},
},
}
svc := newAccessibleReposTestService(sc)
got, err := svc.ListAccessibleRepos(context.Background(), "", 50)
if err != nil {
t.Fatalf("ListAccessibleRepos: %v", err)
}
wantOrder := []string{"acme/newest", "u/middle", "acme/oldest"}
if len(got) != len(wantOrder) {
t.Fatalf("len = %d, want %d", len(got), len(wantOrder))
}
for i, name := range wantOrder {
if got[i].FullName != name {
t.Errorf("pos %d = %q, want %q", i, got[i].FullName, name)
}
}
}
func TestListAccessibleRepos_QueryFilter(t *testing.T) {
sc := &accessibleReposStubClient{
repos: []GitHubRepo{
{FullName: "acme/widget", Owner: "acme", Name: "widget"},
{FullName: "acme/gadget", Owner: "acme", Name: "gadget"},
{FullName: "u/other", Owner: "u", Name: "other"},
},
}
svc := newAccessibleReposTestService(sc)
// Case-insensitive substring match on full_name.
got, err := svc.ListAccessibleRepos(context.Background(), "ACME/W", 50)
if err != nil {
t.Fatalf("ListAccessibleRepos: %v", err)
}
if len(got) != 1 || got[0].FullName != "acme/widget" {
t.Fatalf("got %v, want [acme/widget]", got)
}
}
func TestListAccessibleRepos_LimitClamp(t *testing.T) {
repos := make([]GitHubRepo, 150)
base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
for i := range repos {
repos[i] = GitHubRepo{
FullName: "acme/repo" + strconv.Itoa(i),
Owner: "acme",
Name: "repo" + strconv.Itoa(i),
PushedAt: ptrTime(base.Add(time.Duration(i) * time.Minute)),
}
}
sc := &accessibleReposStubClient{repos: repos}
svc := newAccessibleReposTestService(sc)
got, err := svc.ListAccessibleRepos(context.Background(), "", 10000)
if err != nil {
t.Fatalf("ListAccessibleRepos: %v", err)
}
if len(got) != maxAccessibleReposLimit {
t.Errorf("len = %d, want %d (clamped to cap)", len(got), maxAccessibleReposLimit)
}
}
func TestListAccessibleRepos_DefaultLimit(t *testing.T) {
// limit=0 must resolve to the default (50); we verify by feeding 60
// distinct repos and observing the truncation to exactly 50.
repos := make([]GitHubRepo, 60)
base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
for i := range repos {
repos[i] = GitHubRepo{
FullName: "acme/r" + strconv.Itoa(i),
Owner: "acme",
Name: "r" + strconv.Itoa(i),
PushedAt: ptrTime(base.Add(time.Duration(i) * time.Minute)),
}
}
sc := &accessibleReposStubClient{repos: repos}
svc := newAccessibleReposTestService(sc)
got, err := svc.ListAccessibleRepos(context.Background(), "", 0)
if err != nil {
t.Fatalf("ListAccessibleRepos: %v", err)
}
if len(got) != defaultAccessibleReposLimit {
t.Errorf("len = %d, want %d (default limit)", len(got), defaultAccessibleReposLimit)
}
}
func TestListAccessibleRepos_NotAuthenticated(t *testing.T) {
// nil client => ErrNoClient straight from the guard (matches the
// behaviour required by the 503-mapping handler test).
svc := newAccessibleReposTestService(nil)
got, err := svc.ListAccessibleRepos(context.Background(), "", 50)
if !errors.Is(err, ErrNoClient) {
t.Fatalf("err = %v, want ErrNoClient", err)
}
if got != nil {
t.Errorf("got = %v, want nil", got)
}
// Same sentinel must bubble up when the client itself is the noop
// (e.g. GH CLI absent and no PAT configured).
svcNoop := newAccessibleReposTestService(&NoopClient{})
gotNoop, errNoop := svcNoop.ListAccessibleRepos(context.Background(), "", 50)
if !errors.Is(errNoop, ErrNoClient) {
t.Fatalf("noop err = %v, want ErrNoClient", errNoop)
}
if gotNoop != nil {
t.Errorf("noop got = %v, want nil", gotNoop)
}
}
func TestListAccessibleRepos_UsesCache(t *testing.T) {
// Subsequent calls within the cache window must coalesce — the fetch runs
// once and later calls hit the 60s cache.
sc := &accessibleReposStubClient{
repos: []GitHubRepo{
{FullName: "u/own", Owner: "u", Name: "own"},
{FullName: "acme/widget", Owner: "acme", Name: "widget"},
},
}
svc := newAccessibleReposTestService(sc)
for i := 0; i < 3; i++ {
if _, err := svc.ListAccessibleRepos(context.Background(), "", 50); err != nil {
t.Fatalf("call %d: %v", i, err)
}
}
if got := sc.calls.Load(); got != 1 {
t.Errorf("ListAccessibleRepos called %d times, want 1 (cached)", got)
}
}
// TestListAccessibleRepos_DetachedContext is the key regression test for the
// `signal: killed` 500: the picker frontend aborts its request via an
// AbortController, which previously cancelled the shared fetch's context and
// SIGKILLed the `gh` subprocess. The service must run the fetch under a context
// detached from the caller's request, so an already-cancelled caller context
// does NOT propagate into the client call.
func TestListAccessibleRepos_DetachedContext(t *testing.T) {
sc := &accessibleReposStubClient{
repos: []GitHubRepo{{FullName: "u/own", Owner: "u", Name: "own"}},
}
svc := newAccessibleReposTestService(sc)
// Cancel the caller context BEFORE calling the service.
ctx, cancel := context.WithCancel(context.Background())
cancel()
if ctx.Err() == nil {
t.Fatal("expected caller context to be cancelled")
}
got, err := svc.ListAccessibleRepos(ctx, "", 50)
if err != nil {
t.Fatalf("ListAccessibleRepos with cancelled caller ctx: %v", err)
}
if len(got) != 1 || got[0].FullName != "u/own" {
t.Fatalf("got %v, want [u/own]", got)
}
// The fetch must have observed a NON-cancelled context.
if ctxErr := sc.observedCtxErr(); ctxErr != nil {
t.Errorf("fetch observed ctx.Err() = %v, want nil (context must be detached)", ctxErr)
}
if got := sc.calls.Load(); got != 1 {
t.Errorf("fetch called %d times, want 1", got)
}
}
// TestListAccessibleRepos_ErrorNotCached verifies a failed fetch is NOT cached:
// the next call must retry rather than serve a poisoned cache entry.
func TestListAccessibleRepos_ErrorNotCached(t *testing.T) {
sc := &accessibleReposStubClient{
reposErr: errors.New("simulated rate limit"),
}
svc := newAccessibleReposTestService(sc)
if _, err := svc.ListAccessibleRepos(context.Background(), "", 50); err == nil {
t.Fatal("first call: expected error, got nil")
}
if _, err := svc.ListAccessibleRepos(context.Background(), "", 50); err == nil {
t.Fatal("second call: expected error, got nil")
}
if got := sc.calls.Load(); got != 2 {
t.Errorf("fetch called %d times, want 2 (error must not be cached)", got)
}
}
// TestListAccessibleRepos_SuccessIsCached guards against accidentally turning
// off caching while reworking the endpoint.
func TestListAccessibleRepos_SuccessIsCached(t *testing.T) {
sc := &accessibleReposStubClient{
repos: []GitHubRepo{{FullName: "u/own", Owner: "u", Name: "own"}},
}
svc := newAccessibleReposTestService(sc)
if _, err := svc.ListAccessibleRepos(context.Background(), "", 50); err != nil {
t.Fatalf("first call: %v", err)
}
if _, err := svc.ListAccessibleRepos(context.Background(), "", 50); err != nil {
t.Fatalf("second call: %v", err)
}
if got := sc.calls.Load(); got != 1 {
t.Errorf("fetch called %d times, want 1 (second call should hit cache)", got)
}
}
// TestListAccessibleRepos_CacheClearedOnTokenChange verifies the
// accessible-repos cache is invalidated when authentication changes. The
// Service lives across token swaps (ConfigureToken / ClearToken mutate s.client
// in place rather than rebuilding the Service), so without an explicit
// invalidation a token swap would surface the previous user's repos for up to
// the 60s TTL. We exercise the invalidation path directly via
// ClearAccessibleReposCaches — both ConfigureToken and ClearToken call it.
func TestListAccessibleRepos_CacheClearedOnTokenChange(t *testing.T) {
scA := &accessibleReposStubClient{
repos: []GitHubRepo{{FullName: "user-a/repo", Owner: "user-a", Name: "repo"}},
}
svc := newAccessibleReposTestService(scA)
if _, err := svc.ListAccessibleRepos(context.Background(), "", 50); err != nil {
t.Fatalf("initial ListAccessibleRepos: %v", err)
}
if got := scA.calls.Load(); got != 1 {
t.Fatalf("user-a fetch called %d times, want 1", got)
}
// Swap the client to a "user B" stub and clear caches — this mirrors what
// ConfigureToken / ClearToken do when auth changes.
scB := &accessibleReposStubClient{
repos: []GitHubRepo{{FullName: "user-b/repo", Owner: "user-b", Name: "repo"}},
}
svc.client = scB
svc.ClearAccessibleReposCaches()
got, err := svc.ListAccessibleRepos(context.Background(), "", 50)
if err != nil {
t.Fatalf("post-swap ListAccessibleRepos: %v", err)
}
if calls := scB.calls.Load(); calls != 1 {
t.Errorf("user-b fetch called %d times, want 1 (cache miss expected)", calls)
}
names := map[string]bool{}
for _, r := range got {
names[r.FullName] = true
}
if !names["user-b/repo"] {
t.Errorf("missing user-b repo in result: %v", names)
}
if names["user-a/repo"] {
t.Errorf("user-a repo leaked across token swap: %v", names)
}
}
// blockingAccessibleReposClient blocks inside ListAccessibleRepos until the test
// releases it, letting the test race a ClearAccessibleReposCaches() with an
// in-flight fetch.
type blockingAccessibleReposClient struct {
stubClient
repos []GitHubRepo
started chan struct{}
release chan struct{}
}
func (c *blockingAccessibleReposClient) ListAccessibleRepos(_ context.Context, query string, _ int) ([]GitHubRepo, error) {
close(c.started)
<-c.release
return filterReposByQuery(c.repos, query), nil
}
// TestListAccessibleRepos_ClearDuringFetchDropsStaleWrite is the service-level
// mirror of the ttl_cache TestTTLCache_ClearInvalidatesInFlightFetch regression:
// a fetch that started BEFORE a clear() (token swap) must not write its stale
// result back into the just-cleared cache. The fix snapshots the cache
// generation before the fetch and writes via setIfCurrentGeneration so the bump
// from clear() drops the late write.
func TestListAccessibleRepos_ClearDuringFetchDropsStaleWrite(t *testing.T) {
sc := &blockingAccessibleReposClient{
repos: []GitHubRepo{{FullName: "user-a/repo", Owner: "user-a", Name: "repo"}},
started: make(chan struct{}),
release: make(chan struct{}),
}
svc := newAccessibleReposTestService(sc)
done := make(chan struct{})
go func() {
_, _ = svc.ListAccessibleRepos(context.Background(), "", 50)
close(done)
}()
// Wait until the fetch is in flight, then clear (simulating a token swap),
// then let the stale fetch complete.
<-sc.started
svc.ClearAccessibleReposCaches()
close(sc.release)
<-done
// The stale write must have been dropped: the cache key must miss so the
// next call refetches under the new generation rather than serving the
// pre-clear (prior-user) repos.
key := accessibleReposCacheKey("", 50)
if v, ok := svc.accessibleReposCache.get(key); ok {
t.Fatalf("expected cache to miss after clear() during in-flight fetch; got %v", v)
}
}