- 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.
269 lines
9.1 KiB
Go
269 lines
9.1 KiB
Go
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
|
|
}
|
|
}
|