Files
ely-eye/ely-eye-mcp/internal/mcpserver/write_tools_test.go
T
ZacharyZhang-NY eb50367921
ci / backend (push) Failing after 3m25s
ci / mcp (push) Successful in 21s
ci / interface (push) Successful in 19s
ci / image (push) Failing after 1m5s
Move the canonical host to git.zacharyzhang.com and follow the module path
The GitHub account that hosted this repository is suspended, so the remote
is dead and the vanity import github.com/ZacharyZhang-NY/ely-eye/... no
longer resolves. The Go module path is that import, so it moves with the
host: git.zacharyzhang.com serves a go-import meta tag for the nested
ely-eye-mcp submodule, which go build and go get both need.

The installers read release assets from the host too, so their API and
download URLs move with it. The release workflow's -ldflags symbol path is
the module path, so it moves as well; a build stamped with the new path
prints the stamped version.
2026-10-10 11:26:19 -04:00

136 lines
4.7 KiB
Go

package mcpserver
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.zacharyzhang.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/apiclient"
)
func recordingServer(t *testing.T, status int, body string, seen *[]string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
payload, _ := io.ReadAll(r.Body)
var decoded map[string]any
_ = json.Unmarshal(payload, &decoded)
*seen = append(*seen, r.URL.Path+" "+string(payload)+" "+func() string {
if decoded["text"] == nil {
return "<no text>"
}
return decoded["text"].(string)
}())
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
}
func TestRememberSendsTheStatementAndReturnsTheStampedMemory(t *testing.T) {
var seen []string
server := recordingServer(t, http.StatusCreated,
`{"atom_id":"mem-1:text","source_id":"mem-1","holder_id":"husband","origin":"self","text":"钥匙在玄关抽屉"}`, &seen)
defer server.Close()
s := &Server{api: apiclient.New(server.URL)}
_, out, err := s.rememberTool(context.Background(), nil, RememberInput{Text: "钥匙在玄关抽屉", Scenario: "home"})
if err != nil {
t.Fatalf("remember failed: %v", err)
}
if out.Atom.HolderID != "husband" || out.Atom.Origin != "self" {
t.Errorf("stamped memory = %+v, want the server's holder and origin", out.Atom)
}
if len(seen) != 1 || !strings.Contains(seen[0], "/api/memory") {
t.Fatalf("requests = %v, want one POST to /api/memory", seen)
}
if !strings.Contains(seen[0], "钥匙在玄关抽屉") {
t.Errorf("request = %s, want the statement verbatim", seen[0])
}
}
func TestRememberRefusesAnEmptyStatement(t *testing.T) {
var seen []string
server := recordingServer(t, http.StatusCreated, `{}`, &seen)
defer server.Close()
if _, _, err := (&Server{api: apiclient.New(server.URL)}).rememberTool(context.Background(), nil, RememberInput{}); err == nil {
t.Fatal("an empty memory must not be stored")
}
if len(seen) != 0 {
t.Errorf("requests = %v, want the empty write rejected before the network", seen)
}
}
func TestSupersedeNamesTheAtomItReplaces(t *testing.T) {
var seen []string
server := recordingServer(t, http.StatusOK,
`{"atom_id":"mem-2:text","supersedes":["mem-1:text"],"valid_from":"2026-05-02T00:00:00+00:00"}`, &seen)
defer server.Close()
s := &Server{api: apiclient.New(server.URL)}
_, out, err := s.supersedeTool(context.Background(), nil, SupersedeInput{AtomID: "mem-1:text", Text: "钥匙在客厅茶几"})
if err != nil {
t.Fatalf("supersede failed: %v", err)
}
if len(out.Atom.Supersedes) != 1 || out.Atom.Supersedes[0] != "mem-1:text" {
t.Errorf("supersedes = %v, want the replaced atom", out.Atom.Supersedes)
}
if !strings.Contains(seen[0], "/api/memory/supersede") {
t.Errorf("request = %s, want the supersede route", seen[0])
}
}
func TestSupersedeRefusesAMissingAtomID(t *testing.T) {
var seen []string
server := recordingServer(t, http.StatusOK, `{}`, &seen)
defer server.Close()
if _, _, err := (&Server{api: apiclient.New(server.URL)}).supersedeTool(context.Background(), nil, SupersedeInput{Text: "新的说法"}); err == nil {
t.Fatal("supersede must name the atom it replaces")
}
if len(seen) != 0 {
t.Errorf("requests = %v, want no write attempted", seen)
}
}
func TestForgetTakesExactlyOneID(t *testing.T) {
var seen []string
server := recordingServer(t, http.StatusOK, `{"source_id":"mem-1","atoms_deleted":1}`, &seen)
defer server.Close()
s := &Server{api: apiclient.New(server.URL)}
for _, input := range []ForgetInput{{}, {AtomID: "a:text", SourceID: "b"}} {
if _, _, err := s.forgetTool(context.Background(), nil, input); err == nil {
t.Errorf("input %+v accepted, want exactly one id", input)
}
}
_, out, err := s.forgetTool(context.Background(), nil, ForgetInput{SourceID: "mem-1", Reason: "moved house"})
if err != nil {
t.Fatalf("forget failed: %v", err)
}
if out.Report.AtomsDeleted != 1 {
t.Errorf("report = %+v, want the deleted count back", out.Report)
}
if len(seen) != 1 || !strings.Contains(seen[0], "/api/memory/forget") {
t.Errorf("requests = %v, want one POST to the forget route", seen)
}
}
func TestARejectedWriteReachesTheCallerAsAnError(t *testing.T) {
var seen []string
server := recordingServer(t, http.StatusUnprocessableEntity,
`{"detail":"mem-1:text was already closed"}`, &seen)
defer server.Close()
_, _, err := (&Server{api: apiclient.New(server.URL)}).supersedeTool(context.Background(), nil, SupersedeInput{AtomID: "mem-1:text", Text: "再说一次"})
if err == nil {
t.Fatal("a closed memory must not supersede silently")
}
if !strings.Contains(err.Error(), "already closed") {
t.Errorf("error = %v, want the server's reason", err)
}
}