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
287 lines
9.3 KiB
Go
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
|
|
}
|
|
}
|