Files
projax/internal/aggregate/aggregator_test.go
mAi 326f4c83b9 feat(aggregate): introduce internal/aggregate/ for fan-out + day-grouping
Phase 5a slice A: a new package that concentrates the "fan out across
linked items" pattern web/dashboard.go, web/timeline.go and mcp/tools.go
each had separate copies of. No callers touch it yet — slices B/C/D
migrate them in turn.

- Aggregator with five methods (Todos/Events/Issues/Docs/Creations) plus
  All convenience for the MCP timeline. Each method takes a *store.Item
  slice and (optionally) a Window, returns typed Row slices.
- Row types embed the underlying caldav.Todo / caldav.Event / gitea.Issue
  so existing html/template field accesses (.Todo.UID, .Event.Summary,
  …) keep resolving via Go field promotion in slices B/C.
- TimelineRow sum-type wrapper (with pointer slots per Kind) plus the
  flat template-friendly fields. Lifted-but-untouched from web/.
- BuildTimelineDays + SortTimelineRows + EventStartLabel +
  EventDurationHint lifted near-verbatim from web/timeline.go.
- CalDAV/Gitea/Store interfaces in the aggregator so unit tests stub IO
  cleanly. Real *caldav.Client / *gitea.Client / *store.Store satisfy
  by method set.
- Per-source error handling preserved: log at WARN + skip the bad
  fetch, return surviving rows.

Tests cover empty inputs, fan-out call counts, per-source error
recovery, window narrowing for todos, issue-cache hit path, doc/creation
allow-list filtering, BuildTimelineDays asc/desc order, sticky pills,
far-future fade, within-day sort.

Plan doc captures the slicing strategy + design decisions:
docs/plans/aggregator-refactor.md.

Task: t-projax-5a-aggregator
2026-05-21 23:57:54 +02:00

414 lines
13 KiB
Go

package aggregate
import (
"context"
"errors"
"io"
"log/slog"
"sort"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/m/projax/caldav"
"github.com/m/projax/gitea"
"github.com/m/projax/store"
)
// --- stubs ---
type stubStore struct {
links map[string]map[string][]*store.ItemLink // itemID → refType → links
docs []*store.ItemLinkWithItem
created []*store.Item
linksErr error
}
func (s *stubStore) LinksByType(_ context.Context, itemID, refType string) ([]*store.ItemLink, error) {
if s.linksErr != nil {
return nil, s.linksErr
}
if s.links == nil {
return nil, nil
}
return s.links[itemID][refType], nil
}
func (s *stubStore) DatedLinksRange(_ context.Context, from, to time.Time) ([]*store.ItemLinkWithItem, error) {
out := []*store.ItemLinkWithItem{}
for _, d := range s.docs {
if d.Link.EventDate == nil {
continue
}
ed := *d.Link.EventDate
if ed.Before(from) || !ed.Before(to) {
continue
}
out = append(out, d)
}
return out, nil
}
func (s *stubStore) ItemsCreatedInRange(_ context.Context, from, to time.Time) ([]*store.Item, error) {
out := []*store.Item{}
for _, it := range s.created {
if it.CreatedAt.Before(from) || !it.CreatedAt.Before(to) {
continue
}
out = append(out, it)
}
return out, nil
}
type stubCalDAV struct {
todosByCal map[string][]caldav.Todo
eventsByCal map[string][]caldav.Event
todosErr map[string]error
eventsErr map[string]error
todoCalls atomic.Int32
eventCalls atomic.Int32
}
func (c *stubCalDAV) ListTodos(_ context.Context, calendarURL string) ([]caldav.Todo, error) {
c.todoCalls.Add(1)
if err, ok := c.todosErr[calendarURL]; ok {
return nil, err
}
return c.todosByCal[calendarURL], nil
}
func (c *stubCalDAV) ListEvents(_ context.Context, calendarURL string, _ caldav.ListEventsOpts) ([]caldav.Event, error) {
c.eventCalls.Add(1)
if err, ok := c.eventsErr[calendarURL]; ok {
return nil, err
}
return c.eventsByCal[calendarURL], nil
}
type stubGitea struct {
issuesByRepo map[string][]gitea.Issue
errByRepo map[string]error
calls atomic.Int32
}
func (g *stubGitea) ListIssues(_ context.Context, owner, repo string, _ gitea.ListOpts) ([]gitea.Issue, error) {
g.calls.Add(1)
key := owner + "/" + repo
if err, ok := g.errByRepo[key]; ok {
return nil, err
}
return g.issuesByRepo[key], nil
}
type stubCache struct {
mu sync.Mutex
store map[string][]gitea.Issue
}
func newStubCache() *stubCache { return &stubCache{store: map[string][]gitea.Issue{}} }
func (c *stubCache) Get(key string) ([]gitea.Issue, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.store[key]
return v, ok
}
func (c *stubCache) Set(key string, v []gitea.Issue) {
c.mu.Lock()
defer c.mu.Unlock()
c.store[key] = v
}
// --- helpers ---
func silentLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
}
func mkItem(id, slug string) *store.Item {
return &store.Item{ID: id, Slug: slug, Title: slug, Paths: []string{slug}}
}
func mkLink(id, itemID, refType, refID string) *store.ItemLink {
return &store.ItemLink{ID: id, ItemID: itemID, RefType: refType, RefID: refID, Rel: "contains"}
}
// --- tests ---
func TestTodosEmptyItems(t *testing.T) {
cal := &stubCalDAV{}
a := New(&stubStore{}, cal, nil, nil, silentLogger())
got := a.Todos(context.Background(), nil, Window{})
if len(got) != 0 {
t.Fatalf("expected no rows, got %d", len(got))
}
if cal.todoCalls.Load() != 0 {
t.Fatalf("expected zero ListTodos calls, got %d", cal.todoCalls.Load())
}
}
func TestTodosFanOut(t *testing.T) {
due := time.Now()
st := &stubStore{
links: map[string]map[string][]*store.ItemLink{
"i1": {RefTypeCalDAV: {mkLink("l1", "i1", RefTypeCalDAV, "cal-a"), mkLink("l2", "i1", RefTypeCalDAV, "cal-b")}},
"i2": {RefTypeCalDAV: {mkLink("l3", "i2", RefTypeCalDAV, "cal-c")}},
},
}
cal := &stubCalDAV{
todosByCal: map[string][]caldav.Todo{
"cal-a": {{UID: "a1", Summary: "task A1", Status: "NEEDS-ACTION", Due: &due}, {UID: "a2", Summary: "done", Status: "COMPLETED"}},
"cal-b": {{UID: "b1", Summary: "task B1", Status: "IN-PROCESS"}},
"cal-c": {{UID: "c1", Summary: "task C1", Status: "NEEDS-ACTION"}},
},
}
a := New(st, cal, nil, nil, silentLogger())
got := a.Todos(context.Background(), []*store.Item{mkItem("i1", "alpha"), mkItem("i2", "beta")}, Window{})
if len(got) != 4 {
t.Fatalf("expected 4 rows (no window narrowing), got %d", len(got))
}
if cal.todoCalls.Load() != 3 {
t.Fatalf("expected 3 ListTodos calls (one per linked calendar), got %d", cal.todoCalls.Load())
}
// Every row must carry its owning item + calendar URL.
for _, r := range got {
if r.Item == nil || r.CalendarURL == "" {
t.Fatalf("row missing item or calendar URL: %+v", r)
}
}
}
func TestTodosWindowNarrowing(t *testing.T) {
now := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)
inWindow := now
outWindow := now.AddDate(0, 0, 60)
lastMod := now.AddDate(0, 0, -5)
st := &stubStore{
links: map[string]map[string][]*store.ItemLink{
"i1": {RefTypeCalDAV: {mkLink("l1", "i1", RefTypeCalDAV, "cal-a")}},
},
}
cal := &stubCalDAV{
todosByCal: map[string][]caldav.Todo{
"cal-a": {
{UID: "a1", Summary: "in", Status: "NEEDS-ACTION", Due: &inWindow},
{UID: "a2", Summary: "out", Status: "NEEDS-ACTION", Due: &outWindow},
{UID: "a3", Summary: "no-due-open", Status: "NEEDS-ACTION"},
{UID: "a4", Summary: "completed-recent", Status: "COMPLETED", LastModified: &lastMod},
},
},
}
a := New(st, cal, nil, nil, silentLogger())
w := Window{From: startOfDay(now.AddDate(0, 0, -30)), To: startOfDay(now.AddDate(0, 0, 30))}
got := a.Todos(context.Background(), []*store.Item{mkItem("i1", "alpha")}, w)
uids := map[string]bool{}
for _, r := range got {
uids[r.Todo.UID] = true
}
if !uids["a1"] {
t.Errorf("expected a1 (in-window due) to be kept")
}
if uids["a2"] {
t.Errorf("a2 (out-of-window due) should be dropped")
}
if uids["a3"] {
t.Errorf("a3 (no due, no last-modified) should be dropped under a window")
}
if !uids["a4"] {
t.Errorf("a4 (completed within window via LastModified) should be kept")
}
}
func TestTodosErrorSurvives(t *testing.T) {
st := &stubStore{
links: map[string]map[string][]*store.ItemLink{
"i1": {RefTypeCalDAV: {mkLink("l1", "i1", RefTypeCalDAV, "cal-good"), mkLink("l2", "i1", RefTypeCalDAV, "cal-bad")}},
},
}
cal := &stubCalDAV{
todosByCal: map[string][]caldav.Todo{"cal-good": {{UID: "x", Summary: "ok", Status: "NEEDS-ACTION"}}},
todosErr: map[string]error{"cal-bad": errors.New("dav unreachable")},
}
a := New(st, cal, nil, nil, silentLogger())
got := a.Todos(context.Background(), []*store.Item{mkItem("i1", "alpha")}, Window{})
if len(got) != 1 || got[0].Todo.UID != "x" {
t.Fatalf("expected 1 surviving row from cal-good, got %+v", got)
}
}
func TestTodosNilCalDAV(t *testing.T) {
a := New(&stubStore{}, nil, nil, nil, silentLogger())
if got := a.Todos(context.Background(), []*store.Item{mkItem("i1", "a")}, Window{}); got != nil {
t.Fatalf("nil CalDAV must return nil, got %+v", got)
}
}
func TestEventsFanOut(t *testing.T) {
now := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC)
st := &stubStore{
links: map[string]map[string][]*store.ItemLink{
"i1": {RefTypeCalDAV: {mkLink("l1", "i1", RefTypeCalDAV, "cal-a")}},
},
}
cal := &stubCalDAV{
eventsByCal: map[string][]caldav.Event{
"cal-a": {{UID: "e1", Summary: "Meeting", Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour)}},
},
}
a := New(st, cal, nil, nil, silentLogger())
w := Window{From: now, To: now.AddDate(0, 0, 7)}
got := a.Events(context.Background(), []*store.Item{mkItem("i1", "alpha")}, w)
if len(got) != 1 || got[0].Event.UID != "e1" {
t.Fatalf("expected one EventRow, got %+v", got)
}
}
func TestEventsErrorSurvives(t *testing.T) {
now := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC)
st := &stubStore{
links: map[string]map[string][]*store.ItemLink{
"i1": {RefTypeCalDAV: {mkLink("l1", "i1", RefTypeCalDAV, "cal-good"), mkLink("l2", "i1", RefTypeCalDAV, "cal-bad")}},
},
}
cal := &stubCalDAV{
eventsByCal: map[string][]caldav.Event{"cal-good": {{UID: "e1", Summary: "ok", Start: now.Add(time.Hour)}}},
eventsErr: map[string]error{"cal-bad": errors.New("server 500")},
}
a := New(st, cal, nil, nil, silentLogger())
got := a.Events(context.Background(), []*store.Item{mkItem("i1", "a")}, Window{From: now, To: now.AddDate(0, 0, 7)})
if len(got) != 1 || got[0].Event.UID != "e1" {
t.Fatalf("expected 1 surviving event row, got %+v", got)
}
}
func TestIssuesFanOutAndCache(t *testing.T) {
st := &stubStore{
links: map[string]map[string][]*store.ItemLink{
"i1": {RefTypeGiteaRepo: {mkLink("l1", "i1", RefTypeGiteaRepo, "m/projax")}},
"i2": {RefTypeGiteaRepo: {mkLink("l2", "i2", RefTypeGiteaRepo, "m/mAi")}},
},
}
git := &stubGitea{
issuesByRepo: map[string][]gitea.Issue{
"m/projax": {{Number: 1, Title: "first", State: "open"}, {Number: 2, Title: "second", State: "open"}},
"m/mAi": {{Number: 10, Title: "other", State: "open"}},
},
}
cache := newStubCache()
a := New(st, nil, git, cache, silentLogger())
items := []*store.Item{mkItem("i1", "alpha"), mkItem("i2", "beta")}
got := a.Issues(context.Background(), items)
if len(got) != 3 {
t.Fatalf("expected 3 issue rows, got %d", len(got))
}
if git.calls.Load() != 2 {
t.Fatalf("expected 2 upstream calls, got %d", git.calls.Load())
}
// Second call should hit the cache.
got2 := a.Issues(context.Background(), items)
if len(got2) != 3 {
t.Fatalf("second call: expected 3 rows, got %d", len(got2))
}
if git.calls.Load() != 2 {
t.Fatalf("cache miss: upstream calls should still be 2, got %d", git.calls.Load())
}
}
func TestIssuesBadRepoSkipped(t *testing.T) {
st := &stubStore{
links: map[string]map[string][]*store.ItemLink{
"i1": {RefTypeGiteaRepo: {mkLink("l1", "i1", RefTypeGiteaRepo, "not-a-repo-ref")}},
},
}
git := &stubGitea{issuesByRepo: map[string][]gitea.Issue{}}
a := New(st, nil, git, nil, silentLogger())
got := a.Issues(context.Background(), []*store.Item{mkItem("i1", "alpha")})
if len(got) != 0 {
t.Fatalf("expected zero rows for malformed ref, got %d", len(got))
}
if git.calls.Load() != 0 {
t.Fatalf("expected no upstream calls for malformed ref, got %d", git.calls.Load())
}
}
func TestDocsFilterByItem(t *testing.T) {
d1 := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)
d2 := time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC)
docs := []*store.ItemLinkWithItem{
{Link: store.ItemLink{ID: "l1", ItemID: "i1", RefType: "url", RefID: "https://x", EventDate: &d1}, ItemPaths: []string{"alpha"}},
{Link: store.ItemLink{ID: "l2", ItemID: "i2", RefType: "url", RefID: "https://y", EventDate: &d1}, ItemPaths: []string{"beta"}},
{Link: store.ItemLink{ID: "l3", ItemID: "i-not-in-allowlist", RefType: "url", RefID: "https://z", EventDate: &d2}, ItemPaths: []string{"gamma"}},
}
st := &stubStore{docs: docs}
a := New(st, nil, nil, nil, silentLogger())
got := a.Docs(context.Background(), []*store.Item{mkItem("i1", "alpha"), mkItem("i2", "beta")}, Window{From: d1, To: d2.AddDate(0, 0, 1)})
if len(got) != 2 {
t.Fatalf("expected 2 doc rows (allow-listed items only), got %d", len(got))
}
}
func TestCreationsFilterByItem(t *testing.T) {
now := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)
created := []*store.Item{
{ID: "i1", Slug: "alpha", Paths: []string{"alpha"}, CreatedAt: now},
{ID: "i2", Slug: "beta", Paths: []string{"beta"}, CreatedAt: now.AddDate(0, 0, 1)},
{ID: "i-orphan", Slug: "gamma", Paths: []string{"gamma"}, CreatedAt: now.AddDate(0, 0, 2)},
}
st := &stubStore{created: created}
a := New(st, nil, nil, nil, silentLogger())
got := a.Creations(context.Background(), []*store.Item{mkItem("i1", "alpha"), mkItem("i2", "beta")}, Window{From: now.AddDate(0, 0, -1), To: now.AddDate(0, 0, 10)})
if len(got) != 2 {
t.Fatalf("expected 2 creation rows after allow-list filter, got %d (%+v)", len(got), got)
}
}
func TestAllRespectsKinds(t *testing.T) {
st := &stubStore{
links: map[string]map[string][]*store.ItemLink{
"i1": {RefTypeCalDAV: {mkLink("l1", "i1", RefTypeCalDAV, "cal-a")}},
},
}
cal := &stubCalDAV{todosByCal: map[string][]caldav.Todo{"cal-a": {{UID: "t1", Status: "NEEDS-ACTION"}}}}
a := New(st, cal, nil, nil, silentLogger())
r := a.All(context.Background(), []*store.Item{mkItem("i1", "alpha")}, AllOpts{Kinds: []string{KindTodo}})
if len(r.Todos) != 1 {
t.Fatalf("expected todos populated, got %+v", r.Todos)
}
if len(r.Events) != 0 || len(r.Issues) != 0 || len(r.Docs) != 0 || len(r.Creations) != 0 {
t.Fatalf("only Todos should populate, got %+v", r)
}
}
// stable ordering check for fan-out: row count must match across runs.
func TestFanOutCounts(t *testing.T) {
items := make([]*store.Item, 0, 5)
links := map[string]map[string][]*store.ItemLink{}
todos := map[string][]caldav.Todo{}
for i := 0; i < 5; i++ {
id := "i" + strconv.Itoa(i)
items = append(items, mkItem(id, id))
cal := "cal-" + id
links[id] = map[string][]*store.ItemLink{RefTypeCalDAV: {mkLink("l-"+id, id, RefTypeCalDAV, cal)}}
todos[cal] = []caldav.Todo{{UID: "t-" + id, Status: "NEEDS-ACTION"}}
}
st := &stubStore{links: links}
cal := &stubCalDAV{todosByCal: todos}
a := New(st, cal, nil, nil, silentLogger())
got := a.Todos(context.Background(), items, Window{})
if len(got) != 5 {
t.Fatalf("expected 5 rows, got %d", len(got))
}
uids := []string{}
for _, r := range got {
uids = append(uids, r.Todo.UID)
}
sort.Strings(uids)
if uids[0] != "t-i0" || uids[4] != "t-i4" {
t.Fatalf("unexpected uid set: %v", uids)
}
}