diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go index fc7b93d..abf47fc 100644 --- a/pkg/auth/auth_test.go +++ b/pkg/auth/auth_test.go @@ -57,6 +57,37 @@ func TestHTTPHubClientWhoAmI(t *testing.T) { } } +func TestHTTPHubClientWhoAmIBearer(t *testing.T) { + hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != WhoAmIPath { + http.NotFound(w, r) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer good-token" { + http.Error(w, `{"error":"expired"}`, http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(Identity{Username: "bear", DisplayName: "Bearer User"}) + })) + defer hub.Close() + + c := &HTTPHubClient{BaseURL: hub.URL} + id, err := c.WhoAmIBearer(context.Background(), "good-token") + if err != nil { + t.Fatalf("valid bearer token: %v", err) + } + if id.Username != "bear" || id.DisplayName != "Bearer User" { + t.Fatalf("identity mismatch: %+v", id) + } + if _, err := c.WhoAmIBearer(context.Background(), "bad-token"); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("bad bearer token: want ErrUnauthenticated, got %v", err) + } + if _, err := c.WhoAmIBearer(context.Background(), ""); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("empty bearer token: want ErrUnauthenticated, got %v", err) + } +} + func newTestMiddleware(hub HubClient) *Middleware { return &Middleware{ Hub: hub, diff --git a/pkg/history/history_test.go b/pkg/history/history_test.go index 063906f..6f57deb 100644 --- a/pkg/history/history_test.go +++ b/pkg/history/history_test.go @@ -5,10 +5,14 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "reflect" + "strings" + "sync" "testing" "time" + "github.com/hivecommons/dibs/pkg/registry" "github.com/hivecommons/dibs/pkg/settle" ) @@ -262,3 +266,143 @@ func TestBackfillRateLimitDoesNotPersistPartialPulls(t *testing.T) { t.Fatalf("rate-limited pulls should not persist partial history") } } + +func TestNewBackfillerDefaults(t *testing.T) { + st, err := NewStore(t.TempDir()) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + b := NewBackfiller(st, "token") + if b.Store != st || b.Token != "token" { + t.Fatalf("backfiller dependencies not wired: %+v", b) + } + if b.Client == nil || b.Logf == nil { + t.Fatal("backfiller defaults missing client or logger") + } + if b.now().IsZero() { + t.Fatal("default clock returned zero time") + } +} + +func TestStoreListCopiesAndSorts(t *testing.T) { + st, err := NewStore(t.TempDir()) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + if err := st.Upsert("z/repo", []DayActivity{{Date: "2026-08-18", MergedPRs: 1}}, fixedNow()); err != nil { + t.Fatalf("Upsert z/repo: %v", err) + } + if err := st.Upsert("a/repo", []DayActivity{{Date: "2026-08-18", MergedPRs: 2}}, fixedNow()); err != nil { + t.Fatalf("Upsert a/repo: %v", err) + } + list := st.List() + if len(list) != 2 || list[0].RepoID != "a/repo" || list[1].RepoID != "z/repo" { + t.Fatalf("List not sorted by repo: %+v", list) + } + list[0].Days[0].MergedPRs = 99 + got, _ := st.Get("a/repo") + if got.Days[0].MergedPRs != 2 { + t.Fatalf("List returned mutable store internals: %+v", got.Days[0]) + } +} + +func TestRefreshAsyncRunsOnceAndSkipsFresh(t *testing.T) { + release := make(chan struct{}) + issueCalls := make(chan string, 4) + var once sync.Once + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/repos/org/repo/issues": + issueCalls <- r.URL.RawQuery + once.Do(func() { <-release }) + _ = json.NewEncoder(w).Encode([]map[string]any{}) + case "/repos/org/repo/pulls": + _ = json.NewEncoder(w).Encode([]map[string]any{}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + st, err := NewStore(t.TempDir()) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + b := &Backfiller{Store: st, BaseURL: srv.URL, Client: srv.Client(), Now: fixedNow, Logf: t.Logf} + repos := []registry.RepoProfile{{RepoID: "org/repo"}} + b.RefreshAsync(repos) + select { + case <-issueCalls: + case <-time.After(5 * time.Second): + t.Fatal("first RefreshAsync never started") + } + b.RefreshAsync(repos) + select { + case q := <-issueCalls: + t.Fatalf("second RefreshAsync started while first was active: %s", q) + case <-time.After(50 * time.Millisecond): + } + close(release) + + deadline := time.After(5 * time.Second) + for { + if h, ok := st.Get("org/repo"); ok && len(h.Days) == windowDays { + break + } + select { + case <-deadline: + t.Fatal("RefreshAsync never persisted history") + case <-time.After(10 * time.Millisecond): + } + } + b.RefreshAsync(repos) + select { + case q := <-issueCalls: + t.Fatalf("fresh history should not refresh again: %s", q) + case <-time.After(50 * time.Millisecond): + } + var nilBackfiller *Backfiller + nilBackfiller.RefreshAsync(repos) + (&Backfiller{}).RefreshAsync(repos) +} + +func TestFetchMergedPullRequestsFiltersSortsAndAuthenticates(t *testing.T) { + newer := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC) + older := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC) + var sawAuth, sawVersion string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/org/repo/pulls" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + q, _ := url.ParseQuery(r.URL.RawQuery) + if q.Get("state") != "closed" || q.Get("sort") != "updated" || q.Get("direction") != "desc" { + t.Fatalf("unexpected query %s", r.URL.RawQuery) + } + sawAuth = r.Header.Get("Authorization") + sawVersion = r.Header.Get("X-GitHub-Api-Version") + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"title": " older merge ", "merged_at": older, "user": map[string]string{"login": "alice"}}, + {"title": "closed only", "merged_at": nil, "user": map[string]string{"login": "bob"}}, + {"title": "newer merge", "merged_at": newer, "user": map[string]string{"login": "carol"}}, + }) + })) + defer srv.Close() + + b := &Backfiller{BaseURL: srv.URL, Token: "token", Client: srv.Client(), Now: fixedNow} + got, err := b.FetchMergedPullRequests(context.Background(), "org/repo") + if err != nil { + t.Fatalf("FetchMergedPullRequests: %v", err) + } + if sawAuth != "Bearer token" || sawVersion != "2022-11-28" { + t.Fatalf("headers Authorization=%q X-GitHub-Api-Version=%q", sawAuth, sawVersion) + } + if len(got) != 2 { + t.Fatalf("got %d merged PRs: %+v", len(got), got) + } + if got[0].Title != "newer merge" || got[0].Author != "carol" || !got[0].MergedAt.Equal(newer) { + t.Fatalf("newest PR not first/trimmed: %+v", got[0]) + } + if got[1].Title != "older merge" || strings.TrimSpace(got[1].Title) != got[1].Title { + t.Fatalf("older PR not trimmed: %+v", got[1]) + } +} diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go index 01bab03..91e9d50 100644 --- a/pkg/registry/registry_test.go +++ b/pkg/registry/registry_test.go @@ -82,6 +82,28 @@ func TestSyncAndList(t *testing.T) { } } +func TestListByOwnerFiltersAndCopies(t *testing.T) { + r, _ := newTestRegistry(t) + if err := r.Merge(sampleRepos()); err != nil { + t.Fatalf("Merge: %v", err) + } + alice := r.ListByOwner("alice") + if len(alice) != 1 || alice[0].RepoID != "kubestellar/kubestellar" { + t.Fatalf("alice repos = %+v", alice) + } + alice[0].Description = "mutated" + rp, err := r.Get("kubestellar/kubestellar") + if err != nil { + t.Fatalf("Get: %v", err) + } + if rp.Description == "mutated" { + t.Fatal("ListByOwner returned mutable registry internals") + } + if got := r.ListByOwner("nobody"); len(got) != 0 { + t.Fatalf("unknown owner repos = %+v, want none", got) + } +} + // TestSyncPreservesLocalEdits: a hub re-sync must not clobber owner toggles. func TestSyncPreservesLocalEdits(t *testing.T) { r, _ := newTestRegistry(t) diff --git a/pkg/server/wave2_test.go b/pkg/server/wave2_test.go index 1d7552f..5da8467 100644 --- a/pkg/server/wave2_test.go +++ b/pkg/server/wave2_test.go @@ -212,6 +212,33 @@ func TestPrivateIdeaNeverSurfacesBeforeOffer(t *testing.T) { } } +func TestIdeaMatchesSeenMarksCurrentSuggestionsForAuthor(t *testing.T) { + f := newWave2Server(t, &settle.Fake{}) + idea := f.createIdea(t, "bob-session", "Kubernetes marketplace boost", + "A kubernetes marketplace for ideas.", "public") + matchesUpdatedAt := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC) + if _, err := f.store.Mutate(idea.ID, false, func(i *store.Idea) error { + i.MatchesUpdatedAt = matchesUpdatedAt + i.Matches = []store.Match{{RepoID: "kubestellar/dibs", Score: 91, RepoHash: "hash"}} + return nil + }); err != nil { + t.Fatalf("seed matches: %v", err) + } + + rec := doJSON(t, f.h, "POST", "/api/ideas/"+idea.ID+"/matches/seen", "alice-session", nil) + if rec.Code != http.StatusForbidden { + t.Fatalf("non-author matches/seen status = %d, want 403", rec.Code) + } + rec = doJSON(t, f.h, "POST", "/api/ideas/"+idea.ID+"/matches/seen", "bob-session", nil) + if rec.Code != http.StatusOK { + t.Fatalf("matches/seen: %d %s", rec.Code, rec.Body.String()) + } + got := decode[store.Idea](t, rec) + if !got.SuggestionsSeenAt.Equal(matchesUpdatedAt) { + t.Fatalf("SuggestionsSeenAt = %v, want %v", got.SuggestionsSeenAt, matchesUpdatedAt) + } +} + // TestOfferAcceptSettleFlow: the LEGACY happy path end to end (a GitHub // client is configured, so accept still opens the credited issue // server-side) — match, offer, accept, credited issue, notifications.