Files
zzet--gortex/internal/mcp/tools_notebook_test.go
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

287 lines
9.3 KiB
Go

package mcp
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/persistence"
)
func newNotebookTestServer(t *testing.T) (*Server, string) {
t.Helper()
dir := t.TempDir()
s := &Server{
graph: graph.New(),
session: newSessionState(),
tokenStats: &tokenStats{},
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
sessions: newSessionMap(),
toolScopes: newScopeRegistry(),
}
s.InitNotebook(dir)
return s, dir
}
func callNotebookHandler(t *testing.T, h func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error), args map[string]any) map[string]any {
t.Helper()
req := mcp.CallToolRequest{}
req.Params.Arguments = args
res, err := h(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, res)
if res.IsError {
return map[string]any{"is_error": true}
}
tc, ok := res.Content[0].(mcp.TextContent)
require.True(t, ok)
var m map[string]any
require.NoError(t, json.Unmarshal([]byte(tc.Text), &m))
return m
}
func TestNotebook_SavePersists(t *testing.T) {
s, _ := newNotebookTestServer(t)
out := callNotebookHandler(t, s.handleNotebookSave, map[string]any{
"title": "auth invariant",
"body": "Bar must hold the mutex.",
"tags": "invariant,auth",
})
id := out["id"].(string)
require.NotEmpty(t, id)
// The entry round-trips through the sidecar DB (no markdown file).
shown := callNotebookHandler(t, s.handleNotebookShow, map[string]any{"id": id})
assert.Equal(t, "auth invariant", shown["title"])
assert.Contains(t, shown["body"], "Bar must hold the mutex")
tags, _ := shown["tags"].([]any)
require.Len(t, tags, 2)
assert.Equal(t, "invariant", tags[0])
assert.Equal(t, "auth", tags[1])
}
func TestNotebook_UpdatePreservesCreated(t *testing.T) {
s, _ := newNotebookTestServer(t)
created := callNotebookHandler(t, s.handleNotebookSave, map[string]any{
"title": "first", "body": "body1",
})
id := created["id"].(string)
originalCreated := created["created"].(string)
// Sleep tiny bit to make sure Updated bumps.
time.Sleep(10 * time.Millisecond)
updated := callNotebookHandler(t, s.handleNotebookSave, map[string]any{
"id": id,
"body": "body2",
})
assert.Equal(t, originalCreated, updated["created"], "Created preserved across update")
assert.NotEqual(t, originalCreated, updated["updated"], "Updated bumped on save")
}
func TestNotebook_RejectsEmptySave(t *testing.T) {
s, _ := newNotebookTestServer(t)
out := callNotebookHandler(t, s.handleNotebookSave, map[string]any{})
assert.True(t, out["is_error"] == true)
}
func TestNotebook_FindByBody(t *testing.T) {
s, _ := newNotebookTestServer(t)
_ = callNotebookHandler(t, s.handleNotebookSave, map[string]any{
"title": "alpha", "body": "tokio runtime quirks",
})
_ = callNotebookHandler(t, s.handleNotebookSave, map[string]any{
"title": "beta", "body": "axum router setup",
})
out := callNotebookHandler(t, s.handleNotebookFind, map[string]any{"query": "tokio"})
entries, _ := out["entries"].([]any)
require.Len(t, entries, 1)
assert.Equal(t, "alpha", entries[0].(map[string]any)["title"])
}
func TestNotebook_FindByTag(t *testing.T) {
s, _ := newNotebookTestServer(t)
_ = callNotebookHandler(t, s.handleNotebookSave, map[string]any{
"title": "a", "body": "x", "tags": "decision,api",
})
_ = callNotebookHandler(t, s.handleNotebookSave, map[string]any{
"title": "b", "body": "y", "tags": "gotcha",
})
out := callNotebookHandler(t, s.handleNotebookFind, map[string]any{"query": "decision"})
entries, _ := out["entries"].([]any)
require.Len(t, entries, 1)
}
func TestNotebook_FindCaseInsensitive(t *testing.T) {
s, _ := newNotebookTestServer(t)
_ = callNotebookHandler(t, s.handleNotebookSave, map[string]any{
"title": "Tokio Stuff", "body": "x",
})
out := callNotebookHandler(t, s.handleNotebookFind, map[string]any{"query": "tokio"})
entries, _ := out["entries"].([]any)
assert.Len(t, entries, 1)
}
func TestNotebook_ListReturnsAllSortedDesc(t *testing.T) {
s, _ := newNotebookTestServer(t)
for i := range 3 {
_ = callNotebookHandler(t, s.handleNotebookSave, map[string]any{
"title": "t" + string(rune('A'+i)), "body": "x",
})
time.Sleep(5 * time.Millisecond)
}
out := callNotebookHandler(t, s.handleNotebookList, map[string]any{})
entries, _ := out["entries"].([]any)
require.Len(t, entries, 3)
// Most recently updated first.
assert.Equal(t, "tC", entries[0].(map[string]any)["title"])
}
func TestNotebook_ShowReturnsBody(t *testing.T) {
s, _ := newNotebookTestServer(t)
created := callNotebookHandler(t, s.handleNotebookSave, map[string]any{
"title": "x", "body": "the full markdown body here",
})
id := created["id"].(string)
out := callNotebookHandler(t, s.handleNotebookShow, map[string]any{"id": id})
assert.Equal(t, "the full markdown body here", out["body"], "show returns the verbatim body")
}
func TestNotebook_ShowUnknownIDErrors(t *testing.T) {
s, _ := newNotebookTestServer(t)
out := callNotebookHandler(t, s.handleNotebookShow, map[string]any{"id": "doesnotexist"})
assert.True(t, out["is_error"] == true)
}
func TestNotebook_UsedBumpsCount(t *testing.T) {
s, _ := newNotebookTestServer(t)
created := callNotebookHandler(t, s.handleNotebookSave, map[string]any{
"title": "x", "body": "y",
})
id := created["id"].(string)
for range 3 {
_ = callNotebookHandler(t, s.handleNotebookUsed, map[string]any{"id": id})
}
out := callNotebookHandler(t, s.handleNotebookShow, map[string]any{"id": id})
assert.EqualValues(t, 3, out["used_count"].(float64))
_, hasLastUsed := out["last_used"]
assert.True(t, hasLastUsed)
}
func TestNotebook_RejectsMissingNotebook(t *testing.T) {
s := &Server{
graph: graph.New(),
session: newSessionState(),
tokenStats: &tokenStats{},
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
sessions: newSessionMap(),
toolScopes: newScopeRegistry(),
}
// No InitNotebook — s.notebook is nil.
out := callNotebookHandler(t, s.handleNotebookSave, map[string]any{"body": "x"})
assert.True(t, out["is_error"] == true)
}
func TestNotebook_FrontmatterRoundTrip(t *testing.T) {
entry := notebookEntry{
ID: "abc",
Title: "a title: with colon",
Tags: []string{"x", "y"},
Created: time.Date(2026, 5, 19, 10, 0, 0, 0, time.UTC),
Updated: time.Date(2026, 5, 19, 11, 0, 0, 0, time.UTC),
Body: "line one\nline two\n",
}
md := notebookMarshal(entry)
parsed, err := notebookUnmarshal(md)
require.NoError(t, err)
assert.Equal(t, entry.Title, parsed.Title)
assert.Equal(t, entry.Tags, parsed.Tags)
assert.Equal(t, entry.Created.Unix(), parsed.Created.Unix())
assert.Equal(t, entry.Body, parsed.Body)
}
func TestNotebook_NoDirManagerStillSafe(t *testing.T) {
nm := newNotebookManager("")
// Save must NOT silently succeed — the prior behaviour returned a
// fresh ID and timestamps but never persisted, so subsequent
// list/find/show/used all returned empty and the caller had no
// signal anything was wrong. Honest error here.
_, err := nm.Save(notebookEntry{Title: "x"})
require.Error(t, err)
require.Contains(t, err.Error(), "not initialised")
// Get / List / Find continue to return empty/false consistently.
_, ok := nm.Get("anything")
assert.False(t, ok)
assert.Empty(t, nm.List())
}
func TestNotebook_PrunesByTTL(t *testing.T) {
dir := t.TempDir()
nm := newNotebookManager(dir)
// TTL must comfortably exceed the Save→prune latency. pruneLocked's
// cutoff is computed *after* the fresh entry's Updated stamp is
// written, so a sub-millisecond TTL lets a loaded runner sweep the
// just-saved entry itself (0 remain instead of 1). A minute sits far
// below the stale entry's 1h age yet far above any realistic save
// latency, so the prune is deterministic.
nm.ttl = time.Minute
require.NotNil(t, nm.sidecar)
// Insert a row with Updated far in the past directly into the
// sidecar so the next Save's prune sweeps it.
require.NoError(t, nm.sidecar.UpsertNotebook(nm.repoKey, persistence.NotebookRow{
ID: "stale",
Title: "stale",
Updated: time.Now().UTC().Add(-time.Hour),
}))
// Trigger a save which fires the prune.
_, err := nm.Save(notebookEntry{Title: "fresh", Body: "x"})
require.NoError(t, err)
// The stale entry should be gone; the fresh one survives.
_, ok := nm.Get("stale")
assert.False(t, ok, "TTL-expired entry pruned")
assert.Len(t, nm.List(), 1, "only the fresh entry remains")
}
func TestNotebook_DeleteIdempotent(t *testing.T) {
dir := t.TempDir()
nm := newNotebookManager(dir)
_, _ = nm.Save(notebookEntry{ID: "x", Title: "x"})
require.NoError(t, nm.Delete("x"))
require.NoError(t, nm.Delete("x"), "second delete on missing entry is not an error")
}
func TestParseYAMLInlineList(t *testing.T) {
assert.Equal(t, []string{"a", "b"}, parseYAMLInlineList("[a, b]"))
assert.Equal(t, []string{"a", "b"}, parseYAMLInlineList(" a , b "))
assert.Nil(t, parseYAMLInlineList("[]"))
assert.Nil(t, parseYAMLInlineList(""))
}
func TestYAMLEscapeRoundTrip(t *testing.T) {
cases := []string{
`plain`,
`with: colon`,
`with "quote"`,
``,
}
for _, c := range cases {
escaped := yamlEscapeOneLine(c)
back := yamlUnescapeOneLine(escaped)
assert.Equal(t, c, back, "roundtrip %q", c)
_ = strings.HasPrefix(escaped, `"`) // suppress unused
}
}