Files
projax/mcp/timeline_test.go
mAi bc56733bc8 feat(mcp+cmd): Phase 6 Slice C — atomic PROJAX_BACKEND flip across web + MCP
- cmd/projax/main.go: PROJAX_BACKEND=mbrian now sets BOTH srv.Items
  (reader) AND srv.Writes (writer=MBrianWriter HTTP client, reading
  PROJAX_MBRIAN_API_URL/PROJAX_MBRIAN_API_TOKEN). =store sets both to the
  legacy *Store. The flip is atomic — the slice-B half-flip (reader only)
  was the production bug. Warns (not exits) if mbrian is selected without
  the API env vars: reads work direct-DB, writes fail closed legibly.

- mcp/tools.go: RegisterProjaxTools split from a single *Store into
  (reader, writer, legacy *Store, agg). Read tools take the reader, write
  tools take reader+writer, both flipping with the backend. Leaving MCP
  reads on projax.items while writes targeted mBrian would recreate the
  slice-B bug on the MCP surface (read an id from one backend, write it to
  the other). The timeline tool keeps the legacy *Store + aggregator
  (out of slice-C scope, consistent with the web dashboard). main.go
  passes srv.Items/srv.Writes so MCP follows the same flip as the web UI.

  NOTE: this widens slice C beyond the handover's 'MCP reads deferred' —
  necessary because migrating MCP writes alone is incoherent with
  atomicity. Flagged to head.

- store/mbrian_writer_test.go: httptest-backed unit tests for request
  construction + error mapping (401/403/404→ErrNotFound/503/500/400),
  fail-closed on empty token/URL (no empty Bearer sent), AddLink self-edge
  + metadata shaping, AddLinkDated date+note, per-ref_type edge metadata,
  projax bundle defaults + public nesting, uuid v4 format. Pool-backed
  read-backs (Create/Update round-trip) are covered by head's live cutover
  test + the reader parity tests.

Build + vet green. store/mcp/itemwrite tests pass in isolation.
2026-06-01 12:33:46 +02:00

153 lines
5.2 KiB
Go

package mcp
import (
"context"
"io"
"log/slog"
"testing"
"time"
"github.com/m/projax/internal/aggregate"
"github.com/m/projax/store"
)
func mustTime(t *testing.T, s string) time.Time {
t.Helper()
tt, err := time.Parse(time.RFC3339, s)
if err != nil {
t.Fatalf("mustTime: %v", err)
}
return tt
}
// stubStore is a minimal LinkLister + ListByFilters-aware shim for unit
// tests. We embed the absence of *store.Store fields the timeline tool
// needs and surface only ListByFilters via a fake — the rest of mcp tests
// pass nil because they don't exercise this path.
type stubLinkLister struct{}
func (stubLinkLister) LinksByType(_ context.Context, _, _ string) ([]*store.ItemLink, error) {
return nil, nil
}
func (stubLinkLister) DatedLinksRange(_ context.Context, _, _ time.Time) ([]*store.ItemLinkWithItem, error) {
return nil, nil
}
func (stubLinkLister) ItemsCreatedInRange(_ context.Context, _, _ time.Time) ([]*store.Item, error) {
return nil, nil
}
func newToolServer(t *testing.T, agg *aggregate.Aggregator) *Server {
t.Helper()
srv := New("projax-test", "0.0.1", "tok", slog.New(slog.NewTextHandler(io.Discard, nil)))
// Pass nil reader/writer/legacy store — the timeline tool's store-backed
// paths short-circuit cleanly on errors in this test surface (we just
// probe registration + arg parsing here).
RegisterProjaxTools(srv, nil, nil, nil, agg)
return srv
}
func newAggregator() *aggregate.Aggregator {
return aggregate.New(stubLinkLister{}, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
}
// TestTimelineToolUnregisteredWhenAggregatorNil proves passing nil yields a
// missing "timeline" tool — same kill-switch contract as the pre-Phase-5a
// TimelineBuilder=nil case.
func TestTimelineToolUnregisteredWhenAggregatorNil(t *testing.T) {
srv := newToolServer(t, nil)
if _, ok := srv.tools["timeline"]; ok {
t.Fatalf("expected timeline tool to be absent when agg is nil")
}
}
// TestTimelineToolRegisteredWhenAggregatorSet proves the tool shows up when
// an aggregator is plumbed through.
func TestTimelineToolRegisteredWhenAggregatorSet(t *testing.T) {
srv := newToolServer(t, newAggregator())
tool, ok := srv.tools["timeline"]
if !ok {
t.Fatalf("expected timeline tool to be registered")
}
if tool.Description == "" {
t.Errorf("timeline tool should carry a Description")
}
if len(tool.InputSchema) == 0 {
t.Errorf("timeline tool should carry an InputSchema")
}
}
// TestTimelineToolBadDateReturnsError proves a malformed `from` rejects with
// a tool-level error rather than panicking.
func TestTimelineToolBadDateReturnsError(t *testing.T) {
srv := newToolServer(t, newAggregator())
_, err := srv.tools["timeline"].Handler(context.Background(), []byte(`{"from":"not-a-date"}`))
if err == nil {
t.Fatalf("expected timeline tool to error on bad date")
}
}
// TestTimelineToolBadJSONReturnsError proves a malformed args body produces
// a tool-level error rather than panicking.
func TestTimelineToolBadJSONReturnsError(t *testing.T) {
srv := newToolServer(t, newAggregator())
_, err := srv.tools["timeline"].Handler(context.Background(), []byte(`not-json`))
if err == nil {
t.Fatalf("expected timeline tool to error on bad JSON")
}
}
// TestTimelineViewSerializesTimes proves the buildTimelineView helper emits
// the expected stringified time shape so the JSON-RPC envelope stays
// language-agnostic.
func TestTimelineViewSerializesTimes(t *testing.T) {
now := mustTime(t, "2026-05-17T10:30:00Z")
v := buildTimelineView(nil, now.AddDate(0, 0, -3), now.AddDate(0, 0, 4), "desc", []string{aggregate.KindTodo}, 0, now)
if v.From != "2026-05-14" {
t.Errorf("From should be YYYY-MM-DD, got %q", v.From)
}
if v.To != "2026-05-21" {
t.Errorf("To should be YYYY-MM-DD, got %q", v.To)
}
if v.ToInclusive != "2026-05-20" {
t.Errorf("ToInclusive should be exclusive-To minus one day, got %q", v.ToInclusive)
}
if v.BuiltAt != "2026-05-17T10:30:00Z" {
t.Errorf("BuiltAt should be ISO-8601 UTC, got %q", v.BuiltAt)
}
if v.Days == nil {
t.Errorf("Days slice should be initialised (empty array), not nil")
}
}
// TestResolveTimelineKindsSanitises proves resolveTimelineKinds drops
// unknown values, dedupes, and sorts.
func TestResolveTimelineKindsSanitises(t *testing.T) {
got := resolveTimelineKinds([]string{"todo", "junk", "event", "todo", "DOC"})
want := []string{"doc", "event", "todo"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("pos %d: got %q, want %q", i, got[i], want[i])
}
}
}
// TestResolveTimelineWindowDefaults proves no args → default 30/+90 window.
func TestResolveTimelineWindowDefaults(t *testing.T) {
now := mustTime(t, "2026-05-21T12:00:00Z")
from, to, err := resolveTimelineWindow(TimelineArgs{}, now)
if err != nil {
t.Fatalf("default window: %v", err)
}
wantFrom := now.AddDate(0, 0, -timelineDefaultPastDays).Format("2006-01-02")
if from.Format("2006-01-02") != wantFrom {
t.Errorf("from = %s, want %s", from.Format("2006-01-02"), wantFrom)
}
wantTo := now.AddDate(0, 0, timelineDefaultFutureDays).Format("2006-01-02")
if to.Format("2006-01-02") != wantTo {
t.Errorf("to = %s, want %s", to.Format("2006-01-02"), wantTo)
}
}