Files
ely-eye/ely-eye-mcp/internal/apiclient/client_test.go
T
ZacharyZhang-NY 3d792417cf Give the write path a CLI and an MCP face
remember / supersede / forget now reach the one Python writer from a
terminal, from MCP, and from HTTP. The server, not the caller, decides
who owns a memory.

- cli: remember, supersede, forget. --holder falls back to
  settings.default_holder until the holder registry lands. Timestamps are
  parsed once with one clear message.
- ingestion: import pymupdf as fitz. The deprecated fitz import printed a
  warning to stdout, so every CLI command's JSON answer was unparseable.
- mcp: remember, supersede_memory and forget_memory forward to the Python
  writer over HTTP (ELY_EYE_API, default http://127.0.0.1:8765). SQLite
  stays read-only to the Go side, so the write rules live in one place.
- mcp reads follow schema v2: atoms.time became ingested_at, and reads go
  through v_atoms_current, so a superseded memory stops being findable.
  holder, origin, scenario and event_time now show up in fetch_atom and
  search_atoms.
- scripts/mcp_write_smoke.sh proves the loop end to end: remember, search,
  supersede, forget, then not found.

Evidence: 92 pytest passed; go build, go vet and go test clean; mypy clean
on the 10 touched source files with a fresh cache; ruff clean except a
pre-existing B008 at cli.py:292, identical at HEAD.
2026-10-07 15:09:17 -04:00

88 lines
2.7 KiB
Go

package apiclient
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPostSendsJSONAndDecodesTheAnswer(t *testing.T) {
var gotPath string
var gotBody map[string]any
var gotType string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotType = r.Header.Get("Content-Type")
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &gotBody)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"atom_id":"mem-1:text","holder_id":"wife"}`))
}))
defer server.Close()
var out struct {
AtomID string `json:"atom_id"`
HolderID string `json:"holder_id"`
}
client := New(server.URL)
err := client.Post(context.Background(), "/api/memory", map[string]any{"text": "钥匙在玄关"}, &out)
if err != nil {
t.Fatalf("Post failed: %v", err)
}
if gotPath != "/api/memory" {
t.Errorf("path = %q, want /api/memory", gotPath)
}
if gotType != "application/json" {
t.Errorf("content type = %q, want application/json", gotType)
}
if gotBody["text"] != "钥匙在玄关" {
t.Errorf("body = %v, want the statement to survive the trip", gotBody)
}
if out.AtomID != "mem-1:text" || out.HolderID != "wife" {
t.Errorf("decoded = %+v, want the server's stamped atom", out)
}
}
func TestRejectedWriteCarriesTheServersReason(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"detail":"remember needs a holder; a memory nobody owns cannot be referred to"}`))
}))
defer server.Close()
err := New(server.URL).Post(context.Background(), "/api/memory", map[string]any{}, &struct{}{})
if err == nil {
t.Fatal("a rejected write must not look like success")
}
if !strings.Contains(err.Error(), "remember needs a holder") {
t.Errorf("error = %v, want the rule the caller broke", err)
}
}
func TestUnreachableAPIPointsAtTheServerThatShouldBeRunning(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
url := server.URL
server.Close()
err := New(url).Post(context.Background(), "/api/memory", map[string]any{}, &struct{}{})
if err == nil {
t.Fatal("an unreachable API must fail")
}
if !strings.Contains(err.Error(), "ely-eye serve") {
t.Errorf("error = %v, want the fix the operator can run", err)
}
}
func TestNewDefaultsToLocalServe(t *testing.T) {
if got := New("").BaseURL; got != "http://127.0.0.1:8765" {
t.Errorf("BaseURL = %q, want the default serve address", got)
}
if got := New("http://127.0.0.1:9000/").BaseURL; got != "http://127.0.0.1:9000" {
t.Errorf("BaseURL = %q, want the trailing slash trimmed", got)
}
}