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