- 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.
153 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|