diff --git a/pkg/api/matchnotifier_test.go b/pkg/api/matchnotifier_test.go new file mode 100644 index 0000000..c09b887 --- /dev/null +++ b/pkg/api/matchnotifier_test.go @@ -0,0 +1,65 @@ +package api + +import ( + "testing" + + "github.com/hivecommons/dibs/pkg/notify" + "github.com/hivecommons/dibs/pkg/registry" + "github.com/hivecommons/dibs/pkg/store" +) + +func TestMatchNotifierNewMatch(t *testing.T) { + repo := ®istry.RepoProfile{RepoID: "org/repo", Owner: "olivia"} + + t.Run("nil store is a no-op", func(t *testing.T) { + n := &MatchNotifier{} + n.NewMatch("alice", "olivia", &store.Idea{ID: "i1", Title: "T", Visibility: store.VisibilityPublic}, repo, 0.9) + }) + + newNotifier := func(t *testing.T) (*MatchNotifier, *notify.Store) { + t.Helper() + ns, err := notify.New(t.TempDir()) + if err != nil { + t.Fatalf("notify.New: %v", err) + } + return &MatchNotifier{Notify: ns}, ns + } + + t.Run("public idea notifies author and repo owner", func(t *testing.T) { + n, ns := newNotifier(t) + idea := &store.Idea{ID: "i1", Title: "Great idea", Visibility: store.VisibilityPublic} + n.NewMatch("alice", "olivia", idea, repo, 0.9) + + author := ns.ListByUser("alice", false) + if len(author) != 1 || author[0].Kind != notify.KindMatch || author[0].IdeaID != "i1" || author[0].RepoID != "org/repo" { + t.Fatalf("author notifications = %+v", author) + } + owner := ns.ListByUser("olivia", false) + if len(owner) != 1 || owner[0].Kind != notify.KindMatch { + t.Fatalf("owner notifications = %+v", owner) + } + }) + + t.Run("private idea never notifies repo owner", func(t *testing.T) { + n, ns := newNotifier(t) + idea := &store.Idea{ID: "i2", Title: "Secret idea", Visibility: store.VisibilityPrivate} + n.NewMatch("alice", "olivia", idea, repo, 0.9) + + if got := ns.ListByUser("alice", false); len(got) != 1 { + t.Fatalf("author notifications = %+v, want 1", got) + } + if got := ns.ListByUser("olivia", false); len(got) != 0 { + t.Fatalf("private idea leaked to repo owner: %+v", got) + } + }) + + t.Run("self-match notifies only once", func(t *testing.T) { + n, ns := newNotifier(t) + idea := &store.Idea{ID: "i3", Title: "Own repo idea", Visibility: store.VisibilityPublic} + n.NewMatch("olivia", "olivia", idea, repo, 0.9) + + if got := ns.ListByUser("olivia", false); len(got) != 1 { + t.Fatalf("self-match notifications = %+v, want exactly 1", got) + } + }) +} diff --git a/pkg/indexformula/formula_test.go b/pkg/indexformula/formula_test.go index 9d3ae96..41fb45a 100644 --- a/pkg/indexformula/formula_test.go +++ b/pkg/indexformula/formula_test.go @@ -24,3 +24,22 @@ func TestContribution(t *testing.T) { }) } } + +func TestActivity(t *testing.T) { + tests := []struct { + name string + in Counts + want int + }{ + {name: "quiet"}, + {name: "single source", in: Counts{IdeasFiled: 3}, want: 3}, + {name: "unweighted sum", in: Counts{RegularIssuesCreated: 1, RegularPRsMerged: 2, ClankerPRsCreated: 3, IdeasFiled: 4, ClankerPRsMerged: 5}, want: 15}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Activity(tt.in); got != tt.want { + t.Fatalf("Activity(%+v) = %d, want %d", tt.in, got, tt.want) + } + }) + } +} diff --git a/pkg/news/generator_async_test.go b/pkg/news/generator_async_test.go new file mode 100644 index 0000000..bd0682c --- /dev/null +++ b/pkg/news/generator_async_test.go @@ -0,0 +1,116 @@ +package news + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/hivecommons/dibs/pkg/history" + "github.com/hivecommons/dibs/pkg/registry" +) + +// blockingFetcher parks FetchMergedPullRequests until release is closed so +// tests can observe the in-flight dedup window. +type blockingFetcher struct { + mu sync.Mutex + calls int + started chan struct{} + release chan struct{} + prs []history.MergedPullRequest +} + +func (f *blockingFetcher) FetchMergedPullRequests(ctx context.Context, repoID string) ([]history.MergedPullRequest, error) { + f.mu.Lock() + f.calls++ + if f.calls == 1 { + close(f.started) + } + f.mu.Unlock() + <-f.release + return append([]history.MergedPullRequest(nil), f.prs...), nil +} + +func (f *blockingFetcher) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls +} + +func TestNewGeneratorWiring(t *testing.T) { + st, err := NewStore(t.TempDir()) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + f := &fakeFetcher{} + g := NewGenerator(st, f, nil) + if g.Store != st || g.Fetcher == nil || g.Logf == nil { + t.Fatalf("NewGenerator wiring incomplete: %+v", g) + } +} + +func TestRefreshAsyncGuardsAndDedup(t *testing.T) { + // Nil / unwired generators must be safe no-ops. + var nilGen *Generator + nilGen.RefreshAsync([]registry.RepoProfile{{RepoID: "org/repo"}}) + (&Generator{}).RefreshAsync([]registry.RepoProfile{{RepoID: "org/repo"}}) + + st, err := NewStore(t.TempDir()) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + f := &blockingFetcher{ + started: make(chan struct{}), + release: make(chan struct{}), + prs: []history.MergedPullRequest{pr("merged work", "2026-08-19T09:00:00Z", "alice")}, + } + g := NewGenerator(st, f, nil) + g.Now = newsNow + g.Logf = nil + + repos := []registry.RepoProfile{{RepoID: "org/repo"}} + g.RefreshAsync(repos) + select { + case <-f.started: + case <-time.After(5 * time.Second): + t.Fatal("refresh goroutine never started") + } + + // While org/repo is active, further RefreshAsync calls must be dropped. + g.RefreshAsync(repos) + if got := f.callCount(); got != 1 { + t.Fatalf("fetch calls during active refresh = %d, want 1", got) + } + + close(f.release) + deadline := time.Now().Add(5 * time.Second) + for len(st.Get("org/repo")) == 0 { + if time.Now().After(deadline) { + t.Fatal("refresh never persisted news items") + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestTruncateOneLine(t *testing.T) { + tests := []struct { + name string + in string + max int + want string + }{ + {name: "short passes through", in: "hello world", max: 20, want: "hello world"}, + {name: "whitespace collapses", in: " a\n\tb c ", max: 20, want: "a b c"}, + {name: "truncates with ellipsis", in: "abcdefgh", max: 5, want: "abcd…"}, + {name: "trims before ellipsis", in: "abc defgh", max: 5, want: "abc…"}, + {name: "max one keeps one rune", in: "abc", max: 1, want: "a"}, + {name: "multibyte runes counted", in: "héllo wörld", max: 6, want: "héllo…"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := truncateOneLine(tt.in, tt.max); got != tt.want { + t.Fatalf("truncateOneLine(%q, %d) = %q, want %q", tt.in, tt.max, got, tt.want) + } + }) + } +} diff --git a/pkg/registry/passed_test.go b/pkg/registry/passed_test.go new file mode 100644 index 0000000..2a884f8 --- /dev/null +++ b/pkg/registry/passed_test.go @@ -0,0 +1,61 @@ +package registry + +import ( + "errors" + "testing" +) + +func TestRepoProfileHasPassed(t *testing.T) { + rp := &RepoProfile{PassedIdeas: []string{"idea-1", "idea-2"}} + if !rp.HasPassed("idea-2") { + t.Fatal("HasPassed(idea-2) = false, want true") + } + if rp.HasPassed("idea-3") { + t.Fatal("HasPassed(idea-3) = true, want false") + } + if (&RepoProfile{}).HasPassed("idea-1") { + t.Fatal("empty profile HasPassed = true, want false") + } +} + +func TestAddPassedIdea(t *testing.T) { + r, dir := newTestRegistry(t) + if err := r.Merge([]RepoProfile{{RepoID: "org/repo", HiveID: "h", Owner: "alice"}}); err != nil { + t.Fatalf("Merge: %v", err) + } + + if err := r.AddPassedIdea("org/missing", "alice", "idea-1"); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown repo err = %v, want ErrNotFound", err) + } + if err := r.AddPassedIdea("org/repo", "mallory", "idea-1"); !errors.Is(err, ErrForbidden) { + t.Fatalf("non-owner err = %v, want ErrForbidden", err) + } + + if err := r.AddPassedIdea("org/repo", "alice", "idea-1"); err != nil { + t.Fatalf("AddPassedIdea: %v", err) + } + // Idempotent: a second swipe must not duplicate the entry. + if err := r.AddPassedIdea("org/repo", "alice", "idea-1"); err != nil { + t.Fatalf("repeat AddPassedIdea: %v", err) + } + rp, err := r.Get("org/repo") + if err != nil { + t.Fatalf("Get: %v", err) + } + if len(rp.PassedIdeas) != 1 || rp.PassedIdeas[0] != "idea-1" { + t.Fatalf("PassedIdeas = %v, want [idea-1]", rp.PassedIdeas) + } + + // The pass must survive a reopen (persisted to disk). + r2, err := New(dir) + if err != nil { + t.Fatalf("reopen: %v", err) + } + rp2, err := r2.Get("org/repo") + if err != nil { + t.Fatalf("Get after reopen: %v", err) + } + if !rp2.HasPassed("idea-1") { + t.Fatal("passed idea lost after reopen") + } +} diff --git a/pkg/registry/symbol_test.go b/pkg/registry/symbol_test.go index 369021b..ac9d82f 100644 --- a/pkg/registry/symbol_test.go +++ b/pkg/registry/symbol_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" ) @@ -86,6 +87,31 @@ func TestUniqueRepoTickerSymbolCollisionSequence(t *testing.T) { } } +func TestUniqueRepoTickerSymbolTwoLetterTail(t *testing.T) { + // Every base[:3]+letter candidate taken: fall back to base[:2] + two-letter tail. + taken := func(s string) bool { return strings.HasPrefix(s, "HIV") } + if got := UniqueRepoTickerSymbol("org/hive", taken); got != "HIAA" { + t.Fatalf("two-letter tail fallback = %q, want HIAA", got) + } +} + +func TestUniqueRepoTickerSymbolFullScan(t *testing.T) { + // Every HI?? candidate also taken: fall back to the full 4-letter scan. + taken := func(s string) bool { return strings.HasPrefix(s, "HI") } + if got := UniqueRepoTickerSymbol("org/hive", taken); got != "AAAA" { + t.Fatalf("full-scan fallback = %q, want AAAA", got) + } +} + +func TestUniqueRepoTickerSymbolExhaustionPanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic when every 4-letter symbol is taken") + } + }() + UniqueRepoTickerSymbol("org/hive", func(string) bool { return true }) +} + func TestRepoSymbolsPersistAndSurviveResync(t *testing.T) { r, dir := newTestRegistry(t) hub := &FakeHub{Repos: []RepoProfile{ diff --git a/pkg/store/idea_helpers_test.go b/pkg/store/idea_helpers_test.go new file mode 100644 index 0000000..6c95a17 --- /dev/null +++ b/pkg/store/idea_helpers_test.go @@ -0,0 +1,96 @@ +package store + +import ( + "errors" + "testing" +) + +func TestOfferTo(t *testing.T) { + idea := &Idea{Offers: []Offer{ + {RepoID: "org/alpha", Status: OfferPending}, + {RepoID: "org/beta", Status: OfferAccepted}, + }} + if got := idea.OfferTo("org/beta"); got == nil || got.Status != OfferAccepted { + t.Fatalf("OfferTo(org/beta) = %+v, want accepted offer", got) + } + // The returned pointer must alias the slice element so callers can mutate it. + idea.OfferTo("org/alpha").Status = OfferDeclined + if idea.Offers[0].Status != OfferDeclined { + t.Fatalf("OfferTo result does not alias Offers: %+v", idea.Offers[0]) + } + if got := idea.OfferTo("org/missing"); got != nil { + t.Fatalf("OfferTo(missing) = %+v, want nil", got) + } +} + +func TestIdeaHasPassed(t *testing.T) { + idea := &Idea{PassedRepos: []string{"org/alpha", "org/beta"}} + if !idea.HasPassed("org/beta") { + t.Fatal("HasPassed(org/beta) = false, want true") + } + if idea.HasPassed("org/gamma") { + t.Fatal("HasPassed(org/gamma) = true, want false") + } + if (&Idea{}).HasPassed("org/alpha") { + t.Fatal("empty idea HasPassed = true, want false") + } +} + +func TestValidationErrorMessage(t *testing.T) { + err := &ValidationError{"title is required"} + if got := err.Error(); got != "store: title is required" { + t.Fatalf("Error() = %q", got) + } + var ve *ValidationError + if !errors.As(error(err), &ve) { + t.Fatal("errors.As failed for *ValidationError") + } +} + +func TestListAllAndListSettled(t *testing.T) { + s, _ := newTestStore(t) + + pub := validIdea("alice") + if err := s.Create(pub); err != nil { + t.Fatalf("Create public: %v", err) + } + priv := validIdea("bob") + priv.Visibility = VisibilityPrivate + if err := s.Create(priv); err != nil { + t.Fatalf("Create private: %v", err) + } + + all, err := s.ListAll() + if err != nil { + t.Fatalf("ListAll: %v", err) + } + if len(all) != 2 { + t.Fatalf("ListAll returned %d ideas, want 2 (must include private)", len(all)) + } + + settled, err := s.ListSettled() + if err != nil { + t.Fatalf("ListSettled: %v", err) + } + if len(settled) != 0 { + t.Fatalf("ListSettled with no settled ideas = %d, want 0 (and non-nil)", len(settled)) + } + if settled == nil { + t.Fatal("ListSettled must return an empty non-nil slice") + } + + if _, err := s.Mutate(pub.ID, true, func(i *Idea) error { + i.Status = StatusSettled + return nil + }); err != nil { + t.Fatalf("Mutate to settled: %v", err) + } + + settled, err = s.ListSettled() + if err != nil { + t.Fatalf("ListSettled: %v", err) + } + if len(settled) != 1 || settled[0].ID != pub.ID { + t.Fatalf("ListSettled = %+v, want only the settled idea %s", settled, pub.ID) + } +}