409 lines
14 KiB
Go
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)
|
|
}
|
|
}
|