From bc56733bc84e6f76a8ad1cb5aafa9ba83ff1d4fc Mon Sep 17 00:00:00 2001 From: mAi Date: Mon, 1 Jun 2026 12:33:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(mcp+cmd):=20Phase=206=20Slice=20C=20?= =?UTF-8?q?=E2=80=94=20atomic=20PROJAX=5FBACKEND=20flip=20across=20web=20+?= =?UTF-8?q?=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- cmd/projax/main.go | 36 +++-- mcp/timeline_test.go | 8 +- mcp/tools.go | 114 +++++++-------- mcp/tools_test.go | 2 +- store/mbrian_writer_test.go | 268 ++++++++++++++++++++++++++++++++++++ 5 files changed, 361 insertions(+), 67 deletions(-) create mode 100644 store/mbrian_writer_test.go diff --git a/cmd/projax/main.go b/cmd/projax/main.go index e0608b3..f8f7f29 100644 --- a/cmd/projax/main.go +++ b/cmd/projax/main.go @@ -74,18 +74,34 @@ func main() { } srv.Version = gitCommit - // Phase 6 Slice B — backend selector. PROJAX_BACKEND=mbrian flips the - // read path to the mBrian-backed adapter; default keeps the legacy - // pgx-against-projax.items path so production rollback is one env - // flip. Writes still flow through srv.Store either way. + // Phase 6 Slice C — backend selector. PROJAX_BACKEND=mbrian flips BOTH + // the read path (srv.Items → MBrianReader, direct DB) AND the write path + // (srv.Writes → MBrianWriter, scoped HTTP API) together; default keeps + // the legacy pgx-against-projax.items path on both so production + // rollback is one env flip. + // + // The flip MUST be atomic: slice B flipped only the reader, so a + // read-then-write round-trip read an mBrian uuid then wrote it against + // projax.items and was rejected. Reads and writes now always share a + // backend — never one without the other. backend := strings.ToLower(strings.TrimSpace(os.Getenv("PROJAX_BACKEND"))) switch backend { case "mbrian": + apiURL := strings.TrimSpace(os.Getenv("PROJAX_MBRIAN_API_URL")) + apiToken := strings.TrimSpace(os.Getenv("PROJAX_MBRIAN_API_TOKEN")) srv.Items = store.NewMBrianReader(pool) - logger.Info("backend=mbrian (read path via store.MBrianReader)") + srv.Writes = store.NewMBrianWriter(apiURL, apiToken, pool) + if apiURL == "" || apiToken == "" { + // Reads work direct-DB without these, but writes fail closed + // (clean 503-style error) until both are set. Warn loudly rather + // than exit — head sets them in Dokploy at cutover and the writer + // surfaces a legible error if a write lands first. + logger.Warn("backend=mbrian but PROJAX_MBRIAN_API_URL/PROJAX_MBRIAN_API_TOKEN not both set — writes will fail closed until configured") + } + logger.Info("backend=mbrian (reads via store.MBrianReader, writes via store.MBrianWriter HTTP API)", "api_url", apiURL) case "", "store": - // Default — srv.Items is the *Store from web.New. - logger.Info("backend=store (read path via legacy *store.Store)") + // Default — srv.Items and srv.Writes are both the *Store from web.New. + logger.Info("backend=store (reads + writes via legacy *store.Store)") default: logger.Error("unknown PROJAX_BACKEND value", "value", backend) os.Exit(1) @@ -139,7 +155,11 @@ func main() { // shared *aggregate.Aggregator instead of pointing back at // *web.Server. Passing nil here disables the `timeline` tool // cleanly; the rest of the projax MCP toolset stays usable. - mcp.RegisterProjaxTools(mcpSrv, store.New(pool), srv.Aggregator()) + // MCP flips with the backend too: pass the same reader+writer the web + // handlers use (srv.Items / srv.Writes, already set by the + // PROJAX_BACKEND switch above). The `timeline` tool keeps the legacy + // *Store (st) + aggregator — out of slice-C scope. + mcp.RegisterProjaxTools(mcpSrv, srv.Items, srv.Writes, st, srv.Aggregator()) mcpMux := http.NewServeMux() mcpSrv.Routes(mcpMux) srv.MCP = mcpMux diff --git a/mcp/timeline_test.go b/mcp/timeline_test.go index b0ed9d5..2e5aa9d 100644 --- a/mcp/timeline_test.go +++ b/mcp/timeline_test.go @@ -39,10 +39,10 @@ func (stubLinkLister) ItemsCreatedInRange(_ context.Context, _, _ time.Time) ([] 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 a nil *store.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, agg) + // 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 } diff --git a/mcp/tools.go b/mcp/tools.go index 5b374d3..542a9fa 100644 --- a/mcp/tools.go +++ b/mcp/tools.go @@ -46,12 +46,18 @@ type TimelineArgs struct { IncludeExcluded bool `json:"include_excluded"` // ignore per-item timeline_exclude arrays } -// RegisterProjaxTools wires every projax-flavoured tool onto an *mcp.Server. -// All tools delegate to *store.Store directly so business logic is shared -// with the web UI — no duplication. The optional agg argument adds the -// timeline tool when non-nil (it needs the fan-out aggregator; passing nil -// keeps the rest of the toolset usable without aggregate deps). -func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) { +// RegisterProjaxTools wires the projax MCP toolset. Phase 6 Slice C splits +// the single *Store dependency into a reader (rd) + writer (wr) so the MCP +// item/link surface flips with PROJAX_BACKEND atomically — the same +// reader/writer the web handlers use. If MCP reads stayed on projax.items +// while writes targeted mBrian (or vice-versa) an MCP client could read an +// id from one backend and write it to the other: the exact slice-B +// half-flip bug, just on the MCP surface. +// +// The `timeline` tool stays on the legacy *Store (its companion +// *aggregate.Aggregator is out of slice-C scope), consistent with the web +// dashboard/timeline which also aggregate via *Store. +func RegisterProjaxTools(s *Server, rd store.ItemReader, wr store.ItemWriter, legacy *store.Store, agg *aggregate.Aggregator) { s.Register(Tool{ Name: "list_items", Description: "List projax items with optional filters (parent_path, tags, management, kind, status, q, has_repo, has_caldav, public).", @@ -70,7 +76,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) "limit": {"type": "integer", "minimum": 0} } }`), - Handler: listItemsTool(st), + Handler: listItemsTool(rd), }) s.Register(Tool{ Name: "get_item", @@ -83,7 +89,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) "include_links": {"type": "boolean", "description": "Include item_links in the response (default true)"} } }`), - Handler: getItemTool(st), + Handler: getItemTool(rd), }) s.Register(Tool{ Name: "create_item", @@ -103,7 +109,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) "metadata": {"type": "object"} } }`), - Handler: createItemTool(st), + Handler: createItemTool(rd, wr), }) s.Register(Tool{ Name: "update_item", @@ -130,7 +136,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) "timeline_exclude": {"type": "array", "items": {"type": "string", "enum": ["todos","events","docs","creation"]}, "description": "Phase 4f — kinds to hide from /timeline (per item)"} } }`), - Handler: updateItemTool(st), + Handler: updateItemTool(rd, wr), }) s.Register(Tool{ Name: "delete_item", @@ -143,7 +149,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) "cascade": {"type": "boolean", "description": "Soft-delete every descendant too"} } }`), - Handler: deleteItemTool(st), + Handler: deleteItemTool(rd, wr), }) s.Register(Tool{ Name: "list_links", @@ -156,7 +162,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) "ref_type": {"type": "string", "description": "Optional ref_type filter (e.g. 'gitea-repo')"} } }`), - Handler: listLinksTool(st), + Handler: listLinksTool(rd), }) s.Register(Tool{ Name: "add_link", @@ -175,7 +181,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) "metadata": {"type": "object"} } }`), - Handler: addLinkTool(st), + Handler: addLinkTool(rd, wr), }) s.Register(Tool{ Name: "remove_link", @@ -185,7 +191,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) "required": ["link_id"], "properties": {"link_id": {"type": "string"}} }`), - Handler: removeLinkTool(st), + Handler: removeLinkTool(wr), }) s.Register(Tool{ Name: "search", @@ -198,7 +204,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) "limit": {"type": "integer", "minimum": 1, "maximum": 200} } }`), - Handler: searchTool(st), + Handler: searchTool(rd), }) s.Register(Tool{ Name: "tree", @@ -210,7 +216,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) "depth": {"type": "integer", "minimum": 0, "description": "Max depth (0 = unlimited)"} } }`), - Handler: treeTool(st), + Handler: treeTool(rd), }) if agg != nil { s.Register(Tool{ @@ -230,7 +236,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) "q": {"type": "string", "description": "Substring match against title/slug/aliases/content_md"} } }`), - Handler: timelineTool(st, agg), + Handler: timelineTool(legacy, agg), }) } } @@ -710,14 +716,14 @@ func mapOr(v map[string]any) map[string]any { } // resolveItem turns an id-or-path argument pair into a concrete *store.Item. -func resolveItem(ctx context.Context, st *store.Store, id, path string) (*store.Item, error) { +func resolveItem(ctx context.Context, rd store.ItemReader, id, path string) (*store.Item, error) { id = strings.TrimSpace(id) path = strings.TrimSpace(path) if id != "" { - return st.GetByID(ctx, id) + return rd.GetByID(ctx, id) } if path != "" { - return st.GetByPathOrSlug(ctx, path) + return rd.GetByPathOrSlug(ctx, path) } return nil, errors.New("either id or path is required") } @@ -731,7 +737,7 @@ func parseInput[T any](raw json.RawMessage, dst *T) error { // --- list_items --- -func listItemsTool(st *store.Store) ToolHandler { +func listItemsTool(rd store.ItemReader) ToolHandler { type input struct { ParentPath string `json:"parent_path"` Tags []string `json:"tags"` @@ -749,7 +755,7 @@ func listItemsTool(st *store.Store) ToolHandler { if err := parseInput(raw, &in); err != nil { return nil, InternalError(fmt.Errorf("bad params: %w", err)) } - items, err := st.ListByFilters(ctx, store.SearchFilters{ + items, err := rd.ListByFilters(ctx, store.SearchFilters{ ParentPath: in.ParentPath, Tags: in.Tags, Management: in.Management, @@ -774,7 +780,7 @@ func listItemsTool(st *store.Store) ToolHandler { // --- get_item --- -func getItemTool(st *store.Store) ToolHandler { +func getItemTool(rd store.ItemReader) ToolHandler { type input struct { ID string `json:"id"` Path string `json:"path"` @@ -785,7 +791,7 @@ func getItemTool(st *store.Store) ToolHandler { if err := parseInput(raw, &in); err != nil { return nil, InternalError(fmt.Errorf("bad params: %w", err)) } - it, err := resolveItem(ctx, st, in.ID, in.Path) + it, err := resolveItem(ctx, rd, in.ID, in.Path) if err != nil { return nil, InternalError(err) } @@ -795,13 +801,13 @@ func getItemTool(st *store.Store) ToolHandler { include = *in.IncludeLinks } if include { - links, err := st.LinksByType(ctx, it.ID, "") // pass "" → all types + links, err := rd.LinksByType(ctx, it.ID, "") // pass "" → all types // LinksByType filters by ref_type — empty would return nothing. So // we explicitly list_all by fanning across the known types. _ = err links = nil for _, t := range []string{"caldav-list", "gitea-repo", "mai-project", "mbrian-node", "url", "mai-task"} { - ll, err := st.LinksByType(ctx, it.ID, t) + ll, err := rd.LinksByType(ctx, it.ID, t) if err != nil { continue } @@ -819,7 +825,7 @@ func getItemTool(st *store.Store) ToolHandler { // --- create_item --- -func createItemTool(st *store.Store) ToolHandler { +func createItemTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler { type input struct { Slug string `json:"slug"` Title string `json:"title"` @@ -836,7 +842,7 @@ func createItemTool(st *store.Store) ToolHandler { if err := parseInput(raw, &in); err != nil { return nil, InternalError(fmt.Errorf("bad params: %w", err)) } - parentIDs, err := resolveParentPaths(ctx, st, in.ParentPaths) + parentIDs, err := resolveParentPaths(ctx, rd, in.ParentPaths) if err != nil { return nil, InternalError(err) } @@ -851,12 +857,12 @@ func createItemTool(st *store.Store) ToolHandler { }); ve != nil { return nil, ValidationToolError(ve) } - if ve := itemwrite.ValidateAgainstStore(ctx, st, itemwrite.Input{ + if ve := itemwrite.ValidateAgainstStore(ctx, rd, itemwrite.Input{ Title: in.Title, Slug: in.Slug, Status: in.Status, ParentIDs: parentIDs, }); ve != nil { return nil, ValidationToolError(ve) } - it, err := st.Create(ctx, store.CreateInput{ + it, err := wr.Create(ctx, store.CreateInput{ Kind: kind, Title: in.Title, Slug: in.Slug, @@ -874,14 +880,14 @@ func createItemTool(st *store.Store) ToolHandler { } } -func resolveParentPaths(ctx context.Context, st *store.Store, paths []string) ([]string, error) { +func resolveParentPaths(ctx context.Context, rd store.ItemReader, paths []string) ([]string, error) { out := make([]string, 0, len(paths)) for _, p := range paths { p = strings.TrimSpace(p) if p == "" { continue } - it, err := st.GetByPathOrSlug(ctx, p) + it, err := rd.GetByPathOrSlug(ctx, p) if err != nil { return nil, fmt.Errorf("parent path %q: %w", p, err) } @@ -892,7 +898,7 @@ func resolveParentPaths(ctx context.Context, st *store.Store, paths []string) ([ // --- update_item --- -func updateItemTool(st *store.Store) ToolHandler { +func updateItemTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler { type input struct { ID string `json:"id"` Path string `json:"path"` @@ -917,7 +923,7 @@ func updateItemTool(st *store.Store) ToolHandler { if err := parseInput(raw, &in); err != nil { return nil, InternalError(fmt.Errorf("bad params: %w", err)) } - it, err := resolveItem(ctx, st, in.ID, in.Path) + it, err := resolveItem(ctx, rd, in.ID, in.Path) if err != nil { return nil, InternalError(err) } @@ -996,7 +1002,7 @@ func updateItemTool(st *store.Store) ToolHandler { patch.TimelineExclude = out } if in.ParentPaths != nil { - pids, err := resolveParentPaths(ctx, st, *in.ParentPaths) + pids, err := resolveParentPaths(ctx, rd, *in.ParentPaths) if err != nil { return nil, InternalError(err) } @@ -1015,10 +1021,10 @@ func updateItemTool(st *store.Store) ToolHandler { if ve := itemwrite.ValidateFormat(validateIn); ve != nil { return nil, ValidationToolError(ve) } - if ve := itemwrite.ValidateAgainstStore(ctx, st, validateIn); ve != nil { + if ve := itemwrite.ValidateAgainstStore(ctx, rd, validateIn); ve != nil { return nil, ValidationToolError(ve) } - updated, err := st.Update(ctx, it.ID, patch) + updated, err := wr.Update(ctx, it.ID, patch) if err != nil { return nil, InternalError(err) } @@ -1028,7 +1034,7 @@ func updateItemTool(st *store.Store) ToolHandler { // --- delete_item --- -func deleteItemTool(st *store.Store) ToolHandler { +func deleteItemTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler { type input struct { ID string `json:"id"` Path string `json:"path"` @@ -1039,11 +1045,11 @@ func deleteItemTool(st *store.Store) ToolHandler { if err := parseInput(raw, &in); err != nil { return nil, InternalError(fmt.Errorf("bad params: %w", err)) } - it, err := resolveItem(ctx, st, in.ID, in.Path) + it, err := resolveItem(ctx, rd, in.ID, in.Path) if err != nil { return nil, InternalError(err) } - if err := st.SoftDeleteCascade(ctx, it.ID, in.Cascade); err != nil { + if err := wr.SoftDeleteCascade(ctx, it.ID, in.Cascade); err != nil { return nil, InternalError(err) } return map[string]any{"deleted": it.ID, "cascade": in.Cascade}, nil @@ -1052,7 +1058,7 @@ func deleteItemTool(st *store.Store) ToolHandler { // --- list_links --- -func listLinksTool(st *store.Store) ToolHandler { +func listLinksTool(rd store.ItemReader) ToolHandler { type input struct { ID string `json:"id"` Path string `json:"path"` @@ -1063,16 +1069,16 @@ func listLinksTool(st *store.Store) ToolHandler { if err := parseInput(raw, &in); err != nil { return nil, InternalError(fmt.Errorf("bad params: %w", err)) } - it, err := resolveItem(ctx, st, in.ID, in.Path) + it, err := resolveItem(ctx, rd, in.ID, in.Path) if err != nil { return nil, InternalError(err) } var links []*store.ItemLink if in.RefType != "" { - links, err = st.LinksByType(ctx, it.ID, in.RefType) + links, err = rd.LinksByType(ctx, it.ID, in.RefType) } else { for _, t := range []string{"caldav-list", "gitea-repo", "mai-project", "mbrian-node", "url", "mai-task"} { - ll, lerr := st.LinksByType(ctx, it.ID, t) + ll, lerr := rd.LinksByType(ctx, it.ID, t) if lerr != nil { continue } @@ -1092,7 +1098,7 @@ func listLinksTool(st *store.Store) ToolHandler { // --- add_link / remove_link --- -func addLinkTool(st *store.Store) ToolHandler { +func addLinkTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler { type input struct { ID string `json:"id"` Path string `json:"path"` @@ -1111,7 +1117,7 @@ func addLinkTool(st *store.Store) ToolHandler { if in.RefType == "" || in.RefID == "" { return nil, &ToolError{Code: codeInternalError, Msg: "ref_type and ref_id are required"} } - it, err := resolveItem(ctx, st, in.ID, in.Path) + it, err := resolveItem(ctx, rd, in.ID, in.Path) if err != nil { return nil, InternalError(err) } @@ -1132,7 +1138,7 @@ func addLinkTool(st *store.Store) ToolHandler { } datePtr = &t } - link, err := st.AddLinkDated(ctx, it.ID, in.RefType, in.RefID, in.Rel, notePtr, datePtr, md) + link, err := wr.AddLinkDated(ctx, it.ID, in.RefType, in.RefID, in.Rel, notePtr, datePtr, md) if err != nil { return nil, InternalError(err) } @@ -1140,7 +1146,7 @@ func addLinkTool(st *store.Store) ToolHandler { } } -func removeLinkTool(st *store.Store) ToolHandler { +func removeLinkTool(wr store.ItemWriter) ToolHandler { type input struct { LinkID string `json:"link_id"` } @@ -1152,7 +1158,7 @@ func removeLinkTool(st *store.Store) ToolHandler { if in.LinkID == "" { return nil, &ToolError{Code: codeInternalError, Msg: "link_id is required"} } - if err := st.DeleteLink(ctx, in.LinkID); err != nil { + if err := wr.DeleteLink(ctx, in.LinkID); err != nil { return nil, InternalError(err) } return map[string]any{"deleted": in.LinkID}, nil @@ -1161,7 +1167,7 @@ func removeLinkTool(st *store.Store) ToolHandler { // --- search --- -func searchTool(st *store.Store) ToolHandler { +func searchTool(rd store.ItemReader) ToolHandler { type input struct { Query string `json:"query"` Limit int `json:"limit"` @@ -1174,7 +1180,7 @@ func searchTool(st *store.Store) ToolHandler { if in.Query == "" { return nil, &ToolError{Code: codeInternalError, Msg: "query is required"} } - items, err := st.Search(ctx, in.Query, in.Limit) + items, err := rd.Search(ctx, in.Query, in.Limit) if err != nil { return nil, InternalError(err) } @@ -1194,7 +1200,7 @@ type treeNode struct { Children []*treeNode `json:"children"` } -func treeTool(st *store.Store) ToolHandler { +func treeTool(rd store.ItemReader) ToolHandler { type input struct { RootPath string `json:"root_path"` Depth int `json:"depth"` @@ -1204,7 +1210,7 @@ func treeTool(st *store.Store) ToolHandler { if err := parseInput(raw, &in); err != nil { return nil, InternalError(fmt.Errorf("bad params: %w", err)) } - items, err := st.ListAll(ctx) + items, err := rd.ListAll(ctx) if err != nil { return nil, InternalError(err) } @@ -1235,7 +1241,7 @@ func treeTool(st *store.Store) ToolHandler { } var out []*treeNode if in.RootPath != "" { - root, err := st.GetByPathOrSlug(ctx, in.RootPath) + root, err := rd.GetByPathOrSlug(ctx, in.RootPath) if err != nil { return nil, InternalError(err) } diff --git a/mcp/tools_test.go b/mcp/tools_test.go index 0cba471..054ae65 100644 --- a/mcp/tools_test.go +++ b/mcp/tools_test.go @@ -42,7 +42,7 @@ func mustDBServer(t *testing.T) (*Server, *pgxpool.Pool) { srv := New("projax-test", "0.0.1", "tok", slog.New(slog.NewTextHandler(io.Discard, nil))) // The MCP tests don't need a real timeline builder — passing nil keeps // the timeline tool unregistered without requiring a web.Server here. - RegisterProjaxTools(srv, st, nil) + RegisterProjaxTools(srv, st, st, st, nil) t.Cleanup(func() { pool.Close() }) return srv, pool } diff --git a/store/mbrian_writer_test.go b/store/mbrian_writer_test.go new file mode 100644 index 0000000..8fc50e9 --- /dev/null +++ b/store/mbrian_writer_test.go @@ -0,0 +1,268 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + "time" +) + +// newTestWriter points an MBrianWriter at an httptest server. pool is nil: +// the HTTP-only paths under test (do, postEdge, deleteEdge, AddLink) never +// touch it. The pool-backed read-backs (Create/Update/Reparent/SetPublic/ +// DeleteLink) are exercised by the live cutover round-trip + the reader +// parity tests, not here. +func newTestWriter(baseURL, token string) *MBrianWriter { + return NewMBrianWriter(baseURL, token, nil) +} + +func TestMBrianWriterErrorMapping(t *testing.T) { + cases := []struct { + status int + body string + wantNotFn bool // expect errors.Is(err, ErrNotFound) + wantText string + }{ + {http.StatusUnauthorized, `{"error":"bad token"}`, false, "unauthorized"}, + {http.StatusForbidden, `{"error":"not projax-owned"}`, false, "not projax-owned"}, + {http.StatusNotFound, `{"error":"missing"}`, true, ""}, + {http.StatusServiceUnavailable, `{"error":"token not configured"}`, false, "write backend not ready"}, + {http.StatusInternalServerError, `{"error":"db boom"}`, false, "db boom"}, + {http.StatusBadRequest, `{"error":"disallowed rel"}`, false, "disallowed rel"}, + } + for _, c := range cases { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(c.status) + io.WriteString(w, c.body) + })) + w := newTestWriter(srv.URL, "tok") + err := w.do(context.Background(), http.MethodDelete, "/api/projax/nodes/x", nil, nil) + srv.Close() + if err == nil { + t.Fatalf("status %d: expected error, got nil", c.status) + } + if c.wantNotFn && !errors.Is(err, ErrNotFound) { + t.Errorf("status %d: expected ErrNotFound wrap, got %v", c.status, err) + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Errorf("status %d: expected *APIError in chain, got %v", c.status, err) + continue + } + if apiErr.Status != c.status { + t.Errorf("status %d: APIError.Status = %d", c.status, apiErr.Status) + } + if c.wantText != "" && !strings.Contains(err.Error(), c.wantText) { + t.Errorf("status %d: error %q missing %q", c.status, err.Error(), c.wantText) + } + } +} + +func TestMBrianWriterFailsClosedWithoutToken(t *testing.T) { + // No token → must not fire a request with an empty Bearer. + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + w := newTestWriter(srv.URL, "") + err := w.do(context.Background(), http.MethodPost, "/api/projax/nodes", map[string]any{"x": 1}, nil) + if err == nil { + t.Fatal("expected fail-closed error with empty token") + } + if called { + t.Error("request was sent despite empty token — must fail closed") + } + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Status != http.StatusServiceUnavailable { + t.Errorf("expected 503-style APIError, got %v", err) + } +} + +func TestMBrianWriterFailsClosedWithoutURL(t *testing.T) { + w := newTestWriter("", "tok") + err := w.do(context.Background(), http.MethodPost, "/api/projax/nodes", nil, nil) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Status != http.StatusServiceUnavailable { + t.Errorf("expected 503-style APIError for empty base URL, got %v", err) + } +} + +func TestMBrianWriterSendsBearerAndJSON(t *testing.T) { + var gotAuth, gotCT, gotMethod, gotPath string + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotCT = r.Header.Get("Content-Type") + gotMethod = r.Method + gotPath = r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusCreated) + io.WriteString(w, `{"id":"e1"}`) + })) + defer srv.Close() + w := newTestWriter(srv.URL, "sekrit") + var out struct { + ID string `json:"id"` + } + if err := w.do(context.Background(), http.MethodPost, "/api/projax/edges", map[string]any{"rel": "child_of"}, &out); err != nil { + t.Fatalf("do: %v", err) + } + if gotAuth != "Bearer sekrit" { + t.Errorf("Authorization = %q, want Bearer sekrit", gotAuth) + } + if gotCT != "application/json" { + t.Errorf("Content-Type = %q", gotCT) + } + if gotMethod != http.MethodPost || gotPath != "/api/projax/edges" { + t.Errorf("method/path = %s %s", gotMethod, gotPath) + } + if gotBody["rel"] != "child_of" { + t.Errorf("body rel = %v", gotBody["rel"]) + } + if out.ID != "e1" { + t.Errorf("decoded id = %q", out.ID) + } +} + +func TestMBrianWriterAddLinkConstructsSelfEdge(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/projax/edges" || r.Method != http.MethodPost { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusCreated) + io.WriteString(w, `{"id":"edge-123"}`) + })) + defer srv.Close() + w := newTestWriter(srv.URL, "tok") + + link, err := w.AddLink(context.Background(), "item-1", "caldav-list", "https://dav/cal", "contains", + map[string]any{"display_name": "Work"}) + if err != nil { + t.Fatalf("AddLink: %v", err) + } + // Self-edge: source == target == item, rel namespaced. + if gotBody["source"] != "item-1" || gotBody["target"] != "item-1" { + t.Errorf("self-edge source/target = %v/%v", gotBody["source"], gotBody["target"]) + } + if gotBody["rel"] != "projax-caldav-list" { + t.Errorf("rel = %v, want projax-caldav-list", gotBody["rel"]) + } + meta, _ := gotBody["metadata"].(map[string]any) + if meta["url"] != "https://dav/cal" { + t.Errorf("metadata.url = %v (reader decodes caldav RefID from here)", meta["url"]) + } + if meta["ref_id"] != "https://dav/cal" { + t.Errorf("metadata.ref_id = %v", meta["ref_id"]) + } + if meta["projax_rel"] != "contains" { + t.Errorf("metadata.projax_rel = %v", meta["projax_rel"]) + } + if meta["display_name"] != "Work" { + t.Errorf("caller metadata not merged: %v", meta["display_name"]) + } + if link.ID != "edge-123" || link.ItemID != "item-1" || link.RefType != "caldav-list" { + t.Errorf("returned link = %+v", link) + } +} + +func TestMBrianWriterAddLinkDatedCarriesDateAndNote(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusCreated) + io.WriteString(w, `{"id":"e9"}`) + })) + defer srv.Close() + w := newTestWriter(srv.URL, "tok") + note := "filed brief" + d := time.Date(2026, 3, 14, 0, 0, 0, 0, time.UTC) + _, err := w.AddLinkDated(context.Background(), "i1", "doc", "/docs/brief.pdf", "source", ¬e, &d, nil) + if err != nil { + t.Fatalf("AddLinkDated: %v", err) + } + meta, _ := gotBody["metadata"].(map[string]any) + if meta["event_date"] != "2026-03-14" { + t.Errorf("event_date = %v", meta["event_date"]) + } + if meta["note"] != "filed brief" { + t.Errorf("note = %v (gap G2: rides in metadata, not edge.note)", meta["note"]) + } + if meta["url"] != "/docs/brief.pdf" { + t.Errorf("doc url = %v", meta["url"]) + } +} + +func TestEdgeMetadataForLinkPerRefType(t *testing.T) { + mGitRepo := edgeMetadataForLink("gitea-repo", "m/projax", "contains", nil, nil, nil) + if mGitRepo["owner"] != "m" || mGitRepo["repo"] != "projax" { + t.Errorf("gitea-repo owner/repo = %v/%v", mGitRepo["owner"], mGitRepo["repo"]) + } + mIssue := edgeMetadataForLink("gitea-issue", "m/projax#5", "contains", nil, nil, nil) + if mIssue["owner"] != "m" || mIssue["repo"] != "projax" || mIssue["number"] != 5 { + t.Errorf("gitea-issue parse = %v/%v/#%v", mIssue["owner"], mIssue["repo"], mIssue["number"]) + } + mMai := edgeMetadataForLink("mai-project", "proj-uuid", "contains", nil, nil, nil) + if mMai["mai_project_id"] != "proj-uuid" { + t.Errorf("mai-project id = %v", mMai["mai_project_id"]) + } +} + +func TestProjaxBundleForCreateDefaults(t *testing.T) { + b := projaxBundleForCreate(CreateInput{Kind: []string{"project"}, Title: "x"}) + if b["kind"] != "project" { + t.Errorf("kind = %v", b["kind"]) + } + if b["status"] != "active" { + t.Errorf("status default = %v, want active", b["status"]) + } + // area co-kind + ba := projaxBundleForCreate(CreateInput{Kind: []string{"project", "area"}, Title: "x", Status: "done"}) + if ba["kind"] != "area" { + t.Errorf("area kind = %v", ba["kind"]) + } + if ba["status"] != "done" { + t.Errorf("explicit status = %v", ba["status"]) + } +} + +func TestProjaxBundleForUpdateNestsPublic(t *testing.T) { + b := projaxBundleForUpdate(UpdateInput{ + Status: "active", Public: true, PublicDescription: "desc", PublicLiveURL: "https://x", + }) + pub, ok := b["public"].(map[string]any) + if !ok { + t.Fatalf("public not a nested object: %T", b["public"]) + } + if pub["enabled"] != true || pub["description"] != "desc" || pub["live_url"] != "https://x" { + t.Errorf("public bundle = %v (must match reader's projax.public.* shape)", pub) + } +} + +var uuidV4Re = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + +func TestNewUUIDv4Format(t *testing.T) { + seen := map[string]bool{} + for range 100 { + u, err := newUUIDv4() + if err != nil { + t.Fatalf("newUUIDv4: %v", err) + } + if !uuidV4Re.MatchString(u) { + t.Fatalf("not a v4 uuid: %q", u) + } + if seen[u] { + t.Fatalf("duplicate uuid %q", u) + } + seen[u] = true + } +}