返回 last30days-skill
research_test.go
根目录 / mcp / internal / tools / research_test.go
1 package tools
2
3 import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8
9 mcplib "github.com/mark3labs/mcp-go/mcp"
10
11 "github.com/mvanhorn/last30days-skill/mcp/internal/engine"
12 )
13
14 func newCallToolRequest(args map[string]any) mcplib.CallToolRequest {
15 var req mcplib.CallToolRequest
16 req.Params.Arguments = args
17 return req
18 }
19
20 // resultText pulls text content out of a tool result so tests can assert on
21 // the body Claude will see. Returns empty string when the result is nil or
22 // has no text content.
23 func resultText(res *mcplib.CallToolResult) string {
24 if res == nil {
25 return ""
26 }
27 var out strings.Builder
28 for _, item := range res.Content {
29 if tc, ok := item.(mcplib.TextContent); ok {
30 out.WriteString(tc.Text)
31 }
32 }
33 return out.String()
34 }
35
36 func TestRequireStringRejectsMissingAndBlank(t *testing.T) {
37 if _, err := requireString(map[string]any{}, "topic"); err == nil {
38 t.Fatal("expected error for missing topic")
39 }
40 if _, err := requireString(map[string]any{"topic": ""}, "topic"); err == nil {
41 t.Fatal("expected error for empty topic")
42 }
43 if _, err := requireString(map[string]any{"topic": " "}, "topic"); err == nil {
44 t.Fatal("expected error for whitespace-only topic")
45 }
46 if _, err := requireString(map[string]any{"topic": 42}, "topic"); err == nil {
47 t.Fatal("expected error for non-string topic")
48 }
49 v, err := requireString(map[string]any{"topic": "OpenAI"}, "topic")
50 if err != nil || v != "OpenAI" {
51 t.Fatalf("requireString ok = %q, %v", v, err)
52 }
53 }
54
55 func TestEmitArgumentDefaultsAndValidates(t *testing.T) {
56 cases := []struct {
57 name string
58 args map[string]any
59 want string
60 wantErr bool
61 }{
62 {"missing defaults to compact", map[string]any{}, "compact", false},
63 {"empty string defaults to compact", map[string]any{"emit": ""}, "compact", false},
64 {"compact passes through", map[string]any{"emit": "compact"}, "compact", false},
65 {"html passes through", map[string]any{"emit": "html"}, "html", false},
66 {"invalid value rejected", map[string]any{"emit": "json"}, "", true},
67 {"non-string rejected", map[string]any{"emit": 7}, "", true},
68 }
69 for _, tc := range cases {
70 t.Run(tc.name, func(t *testing.T) {
71 got, err := emitArgument(tc.args)
72 if (err != nil) != tc.wantErr {
73 t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr)
74 }
75 if got != tc.want {
76 t.Fatalf("got %q, want %q", got, tc.want)
77 }
78 })
79 }
80 }
81
82 func TestBoolArgument(t *testing.T) {
83 v, err := boolArgument(map[string]any{}, "save")
84 if err != nil || v {
85 t.Fatalf("missing: %v, %v", v, err)
86 }
87 v, err = boolArgument(map[string]any{"save": true}, "save")
88 if err != nil || !v {
89 t.Fatalf("true: %v, %v", v, err)
90 }
91 v, err = boolArgument(map[string]any{"save": false}, "save")
92 if err != nil || v {
93 t.Fatalf("false: %v, %v", v, err)
94 }
95 if _, err := boolArgument(map[string]any{"save": "true"}, "save"); err == nil {
96 t.Fatal("expected error for string value")
97 }
98 }
99
100 func TestResearchRunArgsIncludesNoBrowserCookies(t *testing.T) {
101 args := researchRunArgs("OpenAI", "compact", false)
102 want := []string{"OpenAI", "--emit=compact", "--no-browser-cookies"}
103 if strings.Join(args, "\x00") != strings.Join(want, "\x00") {
104 t.Fatalf("args = %#v, want %#v", args, want)
105 }
106 }
107
108 func TestResearchRunArgsSaveUsesSupportedSaveDir(t *testing.T) {
109 t.Setenv("LAST30DAYS_MEMORY_DIR", "")
110 args := researchRunArgs("OpenAI", "html", true)
111 got := strings.Join(args, "\x00")
112 if strings.Contains(got, "--save\x00") || strings.HasSuffix(got, "--save") {
113 t.Fatalf("args still include unsupported --save: %#v", args)
114 }
115 want := []string{"OpenAI", "--emit=html", "--no-browser-cookies", "--save-dir", "~/Documents/Last30Days"}
116 if got != strings.Join(want, "\x00") {
117 t.Fatalf("args = %#v, want %#v", args, want)
118 }
119 }
120
121 func TestResearchRunArgsSaveUsesMemoryDirEnvOverride(t *testing.T) {
122 t.Setenv("LAST30DAYS_MEMORY_DIR", "/tmp/last30days-reports")
123 args := researchRunArgs("OpenAI", "html", true)
124 want := []string{"OpenAI", "--emit=html", "--no-browser-cookies", "--save-dir", "/tmp/last30days-reports"}
125 if strings.Join(args, "\x00") != strings.Join(want, "\x00") {
126 t.Fatalf("args = %#v, want %#v", args, want)
127 }
128 }
129
130 func TestResearchHandlerValidationErrorsAreToolErrors(t *testing.T) {
131 // Validation failures are returned as MCP tool errors (not Go errors)
132 // so Claude sees a structured failure with a readable message rather
133 // than a transport-level fault.
134 handler := makeResearchHandler(Config{Version: "test"})
135
136 cases := []struct {
137 name string
138 args map[string]any
139 wantSub string
140 }{
141 {"missing topic", map[string]any{}, "topic is required"},
142 {"blank topic", map[string]any{"topic": " "}, "non-empty string"},
143 {"invalid emit", map[string]any{"topic": "OpenAI", "emit": "json"}, "must be 'compact' or 'html'"},
144 {"non-bool save", map[string]any{"topic": "OpenAI", "save": "yes"}, "save must be a boolean"},
145 }
146 for _, tc := range cases {
147 t.Run(tc.name, func(t *testing.T) {
148 res, err := handler(context.Background(), newCallToolRequest(tc.args))
149 if err != nil {
150 t.Fatalf("handler should not return Go error for validation; got %v", err)
151 }
152 if res == nil || !res.IsError {
153 t.Fatalf("expected IsError result, got %+v", res)
154 }
155 if !strings.Contains(resultText(res), tc.wantSub) {
156 t.Fatalf("result text %q missing substring %q", resultText(res), tc.wantSub)
157 }
158 })
159 }
160 }
161
162 func TestFormatRunErrorIncludesStderr(t *testing.T) {
163 res := &engine.RunResult{Stderr: []byte("engine exploded\n")}
164 msg := formatRunError(errors.New("boom"), res)
165 if !strings.Contains(msg, "boom") || !strings.Contains(msg, "engine exploded") {
166 t.Fatalf("formatRunError missed pieces: %q", msg)
167 }
168 }
169
170 func TestFormatRunErrorHandlesNilResult(t *testing.T) {
171 msg := formatRunError(errors.New("boom"), nil)
172 if msg != "boom" {
173 t.Fatalf("nil result: got %q, want %q", msg, "boom")
174 }
175 }
176
176 lines GO