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.
136 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|