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
414 lines
13 KiB
Go
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)
|
|
}
|
|
}
|