1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
4
5package mcp
6
7import (
8 "context"
9 "encoding/json"
10 "strings"
11 "testing"
12
13 sdk "github.com/modelcontextprotocol/go-sdk/mcp"
14
15 "git.secluded.site/cooked-mcp/internal/cooked"
16)
17
18func TestCallToolACIDToolsSurface1ReadsShoppingList(t *testing.T) {
19 backend := &fakeBackend{shoppingList: cooked.ShoppingList{
20 Aisles: []cooked.Aisle{{
21 ID: "pantry",
22 Name: "Pantry",
23 ProductGroups: []cooked.ProductGroup{{
24 ID: "pasta",
25 Name: "Pasta",
26 Quantity: "200g",
27 }},
28 }},
29 }}
30 server := NewServer(backend, "test")
31
32 output, err := server.CallReadTool(context.Background(), ReadArguments{Target: "shopping_list"})
33 if err != nil {
34 t.Fatalf("CallReadTool() error = %v", err)
35 }
36
37 if len(output.Aisles) != 1 {
38 t.Fatalf("structured aisles = %d, want 1", len(output.Aisles))
39 }
40 if output.Aisles[0].ProductGroups[0].Name != "Pasta" {
41 t.Fatalf("first item = %q, want Pasta", output.Aisles[0].ProductGroups[0].Name)
42 }
43}
44
45func TestCallToolACIDRecipesRead1ListsRecipes(t *testing.T) {
46 backend := &fakeBackend{
47 recipes: []cooked.RecipeCard{
48 {ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
49 },
50 }
51 server := NewServer(backend, "test")
52
53 output, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Page: 2, Limit: 5})
54 if err != nil {
55 t.Fatalf("CallReadTool() error = %v", err)
56 }
57
58 if backend.page != 2 {
59 t.Fatalf("backend page = %d, want 2", backend.page)
60 }
61 if backend.limit != 5 {
62 t.Fatalf("backend limit = %d, want 5", backend.limit)
63 }
64 if len(output.Recipes) != 1 {
65 t.Fatalf("structured recipes = %d, want 1", len(output.Recipes))
66 }
67 if output.Recipes[0].ID != "recipe-1" || output.Recipes[0].Title != "Pasta" {
68 t.Fatalf("first recipe = %#v, want recipe-1 Pasta", output.Recipes[0])
69 }
70}
71
72func TestCallToolACIDRecipesPagination4DefaultsAndCapsRecipeLimit(t *testing.T) {
73 backend := &fakeBackend{}
74 server := NewServer(backend, "test")
75
76 _, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Limit: 99})
77 if err != nil {
78 t.Fatalf("CallReadTool() error = %v", err)
79 }
80
81 if backend.page != 1 {
82 t.Fatalf("backend page = %d, want default page 1", backend.page)
83 }
84 if backend.limit != 30 {
85 t.Fatalf("backend limit = %d, want capped limit 30", backend.limit)
86 }
87}
88
89func TestCallToolACIDRecipesRead2SearchesRecipes(t *testing.T) {
90 backend := &fakeBackend{searchRecipes: []cooked.RecipeCard{
91 {ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
92 {ID: "recipe-2", Title: "Tomato Pasta", ThumbnailURL: "https://example.invalid/thumb2.jpg"},
93 }}
94 server := NewServer(backend, "test")
95
96 output, err := server.CallReadTool(context.Background(), ReadArguments{
97 Target: "recipes",
98 Query: " pasta ",
99 Page: 2,
100 Limit: 1,
101 })
102 if err != nil {
103 t.Fatalf("CallReadTool() error = %v", err)
104 }
105
106 if backend.searchQuery != "pasta" {
107 t.Fatalf("backend search query = %q, want pasta", backend.searchQuery)
108 }
109 if backend.searchPage != 2 {
110 t.Fatalf("backend search page = %d, want 2", backend.searchPage)
111 }
112 if len(output.Recipes) != 1 {
113 t.Fatalf("structured recipes = %d, want truncated result length 1", len(output.Recipes))
114 }
115 if output.Recipes[0].ID != "recipe-1" || output.Recipes[0].Title != "Pasta" {
116 t.Fatalf("first recipe = %#v, want recipe-1 Pasta", output.Recipes[0])
117 }
118}
119
120func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) {
121 backend := &fakeBackend{
122 recipeMetadata: cooked.RecipeMetadata{
123 Title: "Pasta",
124 ImageURLs: []string{"https://example.invalid/pasta.jpg"},
125 Owner: "returned-user",
126 EditPermission: true,
127 },
128 recipeContent: cooked.RecipeContent{
129 Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.",
130 Portions: 2,
131 },
132 }
133 server := NewServer(backend, "test")
134
135 result, output, err := server.callReadTool(
136 context.Background(),
137 nil,
138 ReadArguments{Target: "recipe", RecipeID: "recipe-1"},
139 )
140 if err != nil {
141 t.Fatalf("callReadTool() error = %v", err)
142 }
143
144 requireSingleRecipeBackendCalls(t, backend)
145 requireSingleRecipeOutput(t, output)
146 requireNoImageLeak(t, result, output)
147}
148
149func requireSingleRecipeBackendCalls(t *testing.T, backend *fakeBackend) {
150 t.Helper()
151
152 if backend.metadataRecipeID != "recipe-1" || backend.contentRecipeID != "recipe-1" {
153 t.Fatalf(
154 "backend recipe IDs = metadata %q content %q, want recipe-1",
155 backend.metadataRecipeID,
156 backend.contentRecipeID,
157 )
158 }
159 if backend.metadataCalls != 1 || backend.contentCalls != 1 {
160 t.Fatalf("backend calls = metadata %d content %d, want 1/1", backend.metadataCalls, backend.contentCalls)
161 }
162}
163
164func requireSingleRecipeOutput(t *testing.T, output ReadOutput) {
165 t.Helper()
166
167 if output.Recipe == nil {
168 t.Fatal("structured recipe is nil")
169 }
170 if output.Recipe.ID != "recipe-1" || output.Recipe.Title != "Pasta" {
171 t.Fatalf("structured recipe identity = %#v, want recipe-1 Pasta", output.Recipe)
172 }
173 if output.Recipe.Owner != "returned-user" || !output.Recipe.EditPermission {
174 t.Fatalf(
175 "structured recipe owner/edit = %q/%v, want returned-user/true",
176 output.Recipe.Owner,
177 output.Recipe.EditPermission,
178 )
179 }
180 if output.Recipe.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." || output.Recipe.Portions != 2 {
181 t.Fatalf(
182 "structured recipe content/portions = %q/%d, want markdown/2",
183 output.Recipe.Content,
184 output.Recipe.Portions,
185 )
186 }
187}
188
189func requireNoImageLeak(t *testing.T, result *sdk.CallToolResult, output ReadOutput) {
190 t.Helper()
191
192 encodedOutput, err := json.Marshal(output)
193 if err != nil {
194 t.Fatalf("marshal output: %v", err)
195 }
196 if strings.Contains(string(encodedOutput), "image") ||
197 strings.Contains(string(encodedOutput), "https://example.invalid/pasta.jpg") {
198 t.Fatalf("structured output leaked image data: %s", encodedOutput)
199 }
200 if len(result.Content) != 1 {
201 t.Fatalf("text content length = %d, want 1", len(result.Content))
202 }
203 textContent, ok := result.Content[0].(*sdk.TextContent)
204 if !ok {
205 t.Fatalf("content type = %T, want *sdk.TextContent", result.Content[0])
206 }
207 if strings.Contains(textContent.Text, "https://example.invalid/pasta.jpg") {
208 t.Fatalf("text output leaked image URL: %s", textContent.Text)
209 }
210}
211
212func TestCallToolACIDToolsReadTool3RequiresRecipeIDForRecipeTarget(t *testing.T) {
213 backend := &fakeBackend{}
214 server := NewServer(backend, "test")
215
216 _, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipe", RecipeID: " "})
217 if err == nil {
218 t.Fatal("CallReadTool() error = nil, want missing recipe_id error")
219 }
220
221 if backend.metadataCalls != 0 || backend.contentCalls != 0 {
222 t.Fatalf("backend calls = metadata %d content %d, want none", backend.metadataCalls, backend.contentCalls)
223 }
224}
225
226func TestCallToolACIDRecipesPreviewText1PreviewsRawText(t *testing.T) {
227 backend := &fakeBackend{preview: cooked.RecipeTextPreview{
228 Title: "Pasta with Tomato Sauce",
229 Markdown: "# Pasta\n\n1. Boil pasta.",
230 Portions: 2,
231 }}
232 server := NewServer(backend, "test")
233
234 _, output, err := server.callPreviewRecipeTextTool(
235 context.Background(),
236 nil,
237 PreviewRecipeTextArguments{Title: " Pasta ", Text: " Boil pasta.\n"},
238 )
239 if err != nil {
240 t.Fatalf("callPreviewRecipeTextTool() error = %v", err)
241 }
242
243 if backend.previewCalls != 1 {
244 t.Fatalf("preview calls = %d, want 1", backend.previewCalls)
245 }
246 if backend.previewTitle != "Pasta" {
247 t.Fatalf("backend preview title = %q, want trimmed Pasta", backend.previewTitle)
248 }
249 if backend.previewText != " Boil pasta.\n" {
250 t.Fatalf("backend preview text = %q, want raw text preserved", backend.previewText)
251 }
252 if output.Title != "Pasta with Tomato Sauce" || output.Markdown != "# Pasta\n\n1. Boil pasta." ||
253 output.Portions != 2 {
254 t.Fatalf("preview output = %#v, want title/markdown/portions", output)
255 }
256}
257
258func TestCallToolACIDToolsPreviewRecipeTextTool1RequiresTitle(t *testing.T) {
259 backend := &fakeBackend{}
260 server := NewServer(backend, "test")
261
262 _, _, err := server.callPreviewRecipeTextTool(
263 context.Background(),
264 nil,
265 PreviewRecipeTextArguments{Title: " ", Text: "Boil pasta."},
266 )
267 if err == nil {
268 t.Fatal("callPreviewRecipeTextTool() error = nil, want missing title error")
269 }
270 if backend.previewCalls != 0 {
271 t.Fatalf("preview calls = %d, want none", backend.previewCalls)
272 }
273}
274
275func TestCallToolACIDToolsPreviewRecipeTextTool2RequiresText(t *testing.T) {
276 backend := &fakeBackend{}
277 server := NewServer(backend, "test")
278
279 _, _, err := server.callPreviewRecipeTextTool(
280 context.Background(),
281 nil,
282 PreviewRecipeTextArguments{Title: "Pasta", Text: " "},
283 )
284 if err == nil {
285 t.Fatal("callPreviewRecipeTextTool() error = nil, want missing text error")
286 }
287 if backend.previewCalls != 0 {
288 t.Fatalf("preview calls = %d, want none", backend.previewCalls)
289 }
290}
291
292func TestCallToolACIDRecipesSave2And9SavesPreparedRecipe(t *testing.T) {
293 backend := &fakeBackend{saveRecipeID: "recipe-1"}
294 server := NewServer(backend, "test")
295
296 _, output, err := server.callSaveRecipeTool(
297 context.Background(),
298 nil,
299 SaveRecipeArguments{
300 Source: " prepared ",
301 Title: " Pasta ",
302 Markdown: " # Pasta\n\n1. Boil pasta.\n",
303 Portions: 2,
304 },
305 )
306 if err != nil {
307 t.Fatalf("callSaveRecipeTool() error = %v", err)
308 }
309
310 if backend.saveCalls != 1 {
311 t.Fatalf("save calls = %d, want 1", backend.saveCalls)
312 }
313 if backend.saveTitle != "Pasta" {
314 t.Fatalf("backend save title = %q, want trimmed Pasta", backend.saveTitle)
315 }
316 if backend.saveMarkdown != " # Pasta\n\n1. Boil pasta.\n" {
317 t.Fatalf("backend save markdown = %q, want raw markdown preserved", backend.saveMarkdown)
318 }
319 if backend.savePortions != 2 {
320 t.Fatalf("backend save portions = %d, want 2", backend.savePortions)
321 }
322 if output.RecipeID != "recipe-1" {
323 t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
324 }
325}
326
327func TestCallToolACIDRecipesSave2_1To2_3RequiresPreparedFields(t *testing.T) {
328 tests := []struct {
329 name string
330 arguments SaveRecipeArguments
331 }{
332 {
333 name: "title",
334 arguments: SaveRecipeArguments{Source: "prepared", Title: " ", Markdown: "# Pasta", Portions: 2},
335 },
336 {
337 name: "markdown",
338 arguments: SaveRecipeArguments{Source: "prepared", Title: "Pasta", Markdown: " ", Portions: 2},
339 },
340 {
341 name: "portions",
342 arguments: SaveRecipeArguments{Source: "prepared", Title: "Pasta", Markdown: "# Pasta", Portions: 0},
343 },
344 }
345
346 for _, tt := range tests {
347 t.Run(tt.name, func(t *testing.T) {
348 backend := &fakeBackend{}
349 server := NewServer(backend, "test")
350
351 _, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
352 if err == nil {
353 t.Fatal("callSaveRecipeTool() error = nil, want missing prepared field error")
354 }
355 if backend.saveCalls != 0 {
356 t.Fatalf("save calls = %d, want none", backend.saveCalls)
357 }
358 })
359 }
360}
361
362func TestCallToolACIDRecipesSave10RejectsUnsupportedSourceBeforeCallingCooked(t *testing.T) {
363 backend := &fakeBackend{}
364 server := NewServer(backend, "test")
365
366 _, _, err := server.callSaveRecipeTool(context.Background(), nil, SaveRecipeArguments{Source: "url"})
367 if err == nil {
368 t.Fatal("callSaveRecipeTool() error = nil, want unsupported source error")
369 }
370 if backend.saveCalls != 0 {
371 t.Fatalf("save calls = %d, want none", backend.saveCalls)
372 }
373}
374
375func TestCallToolACIDRecipesSave1And11SavesRawTextRecipe(t *testing.T) {
376 backend := &fakeBackend{
377 preview: cooked.RecipeTextPreview{
378 Title: "Pasta with Tomato Sauce",
379 Markdown: "# Pasta\n\n1. Boil pasta.",
380 Portions: 2,
381 },
382 saveRecipeID: "recipe-1",
383 }
384 server := NewServer(backend, "test")
385
386 _, output, err := server.callSaveRecipeTool(
387 context.Background(),
388 nil,
389 SaveRecipeArguments{Source: " raw_text ", Title: " Pasta ", Text: " Boil pasta.\n"},
390 )
391 if err != nil {
392 t.Fatalf("callSaveRecipeTool() error = %v", err)
393 }
394
395 if backend.previewCalls != 1 || backend.saveCalls != 1 {
396 t.Fatalf("backend calls = preview %d save %d, want 1/1", backend.previewCalls, backend.saveCalls)
397 }
398 if backend.previewTitle != "Pasta" {
399 t.Fatalf("preview title = %q, want trimmed Pasta", backend.previewTitle)
400 }
401 if backend.previewText != " Boil pasta.\n" {
402 t.Fatalf("preview text = %q, want raw text preserved", backend.previewText)
403 }
404 if backend.saveTitle != "Pasta with Tomato Sauce" {
405 t.Fatalf("save title = %q, want preview title", backend.saveTitle)
406 }
407 if backend.saveMarkdown != "# Pasta\n\n1. Boil pasta." {
408 t.Fatalf("save markdown = %q, want preview markdown", backend.saveMarkdown)
409 }
410 if backend.savePortions != 2 {
411 t.Fatalf("save portions = %d, want preview portions 2", backend.savePortions)
412 }
413 if output.RecipeID != "recipe-1" {
414 t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
415 }
416}
417
418func TestCallToolACIDRecipesSave1_1To1_2RequiresRawTextFields(t *testing.T) {
419 tests := []struct {
420 name string
421 arguments SaveRecipeArguments
422 }{
423 {name: "title", arguments: SaveRecipeArguments{Source: "raw_text", Title: " ", Text: "Boil pasta."}},
424 {name: "text", arguments: SaveRecipeArguments{Source: "raw_text", Title: "Pasta", Text: " "}},
425 }
426
427 for _, tt := range tests {
428 t.Run(tt.name, func(t *testing.T) {
429 backend := &fakeBackend{}
430 server := NewServer(backend, "test")
431
432 _, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
433 if err == nil {
434 t.Fatal("callSaveRecipeTool() error = nil, want missing raw text field error")
435 }
436 if backend.previewCalls != 0 || backend.saveCalls != 0 {
437 t.Fatalf("backend calls = preview %d save %d, want none", backend.previewCalls, backend.saveCalls)
438 }
439 })
440 }
441}
442
443func TestCallToolACIDRecipesSave8And9UpdatesExistingRecipe(t *testing.T) {
444 backend := &fakeBackend{}
445 server := NewServer(backend, "test")
446
447 _, output, err := server.callSaveRecipeTool(
448 context.Background(),
449 nil,
450 SaveRecipeArguments{
451 Source: " existing ",
452 RecipeID: " recipe-1 ",
453 Markdown: " # Pasta\n\n1. Boil pasta.\n",
454 Portions: 4,
455 },
456 )
457 if err != nil {
458 t.Fatalf("callSaveRecipeTool() error = %v", err)
459 }
460
461 if backend.updateCalls != 1 {
462 t.Fatalf("update calls = %d, want 1", backend.updateCalls)
463 }
464 if backend.updateRecipeID != "recipe-1" {
465 t.Fatalf("backend update recipe ID = %q, want recipe-1", backend.updateRecipeID)
466 }
467 if backend.updateMarkdown != " # Pasta\n\n1. Boil pasta.\n" {
468 t.Fatalf("backend update markdown = %q, want raw markdown preserved", backend.updateMarkdown)
469 }
470 if backend.updatePortions != 4 {
471 t.Fatalf("backend update portions = %d, want 4", backend.updatePortions)
472 }
473 if output.RecipeID != "recipe-1" {
474 t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
475 }
476}
477
478func TestCallToolACIDRecipesSave8_1To8_3RequiresExistingFields(t *testing.T) {
479 tests := []struct {
480 name string
481 arguments SaveRecipeArguments
482 }{
483 {
484 name: "recipe ID",
485 arguments: SaveRecipeArguments{Source: "existing", RecipeID: " ", Markdown: "# Pasta", Portions: 4},
486 },
487 {
488 name: "markdown",
489 arguments: SaveRecipeArguments{Source: "existing", RecipeID: "recipe-1", Markdown: " ", Portions: 4},
490 },
491 {
492 name: "portions",
493 arguments: SaveRecipeArguments{Source: "existing", RecipeID: "recipe-1", Markdown: "# Pasta", Portions: 0},
494 },
495 }
496
497 for _, tt := range tests {
498 t.Run(tt.name, func(t *testing.T) {
499 backend := &fakeBackend{}
500 server := NewServer(backend, "test")
501
502 _, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
503 if err == nil {
504 t.Fatal("callSaveRecipeTool() error = nil, want missing existing field error")
505 }
506 if backend.updateCalls != 0 {
507 t.Fatalf("update calls = %d, want none", backend.updateCalls)
508 }
509 })
510 }
511}
512
513func TestCallToolACIDRecipesDelete1And2DeletesRecipe(t *testing.T) {
514 backend := &fakeBackend{}
515 server := NewServer(backend, "test")
516
517 _, output, err := server.callDeleteRecipeTool(
518 context.Background(),
519 nil,
520 DeleteRecipeArguments{RecipeID: " recipe-1 "},
521 )
522 if err != nil {
523 t.Fatalf("callDeleteRecipeTool() error = %v", err)
524 }
525
526 if backend.deleteCalls != 1 {
527 t.Fatalf("delete calls = %d, want 1", backend.deleteCalls)
528 }
529 if backend.deleteRecipeID != "recipe-1" {
530 t.Fatalf("backend delete recipe ID = %q, want recipe-1", backend.deleteRecipeID)
531 }
532 if output.RecipeID != "recipe-1" {
533 t.Fatalf("delete output recipe ID = %q, want recipe-1", output.RecipeID)
534 }
535}
536
537func TestCallToolACIDToolsDeleteRecipeTool1RequiresRecipeID(t *testing.T) {
538 backend := &fakeBackend{}
539 server := NewServer(backend, "test")
540
541 _, _, err := server.callDeleteRecipeTool(context.Background(), nil, DeleteRecipeArguments{RecipeID: " "})
542 if err == nil {
543 t.Fatal("callDeleteRecipeTool() error = nil, want missing recipe_id error")
544 }
545 if backend.deleteCalls != 0 {
546 t.Fatalf("delete calls = %d, want none", backend.deleteCalls)
547 }
548}
549
550func TestDeleteRecipeToolACIDToolsAnnotations2To5MarksDestructiveOpenWorldTool(t *testing.T) {
551 tool := deleteRecipeTool()
552 if tool.Name != deleteRecipeToolName {
553 t.Fatalf("tool name = %q, want %q", tool.Name, deleteRecipeToolName)
554 }
555 if tool.Annotations == nil {
556 t.Fatal("tool annotations nil")
557 }
558 if tool.Annotations.ReadOnlyHint {
559 t.Fatal("delete_recipe read-only hint = true, want false")
560 }
561 if tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint {
562 t.Fatalf("delete_recipe destructive hint = %#v, want true", tool.Annotations.DestructiveHint)
563 }
564 if tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint {
565 t.Fatalf("delete_recipe open-world hint = %#v, want true", tool.Annotations.OpenWorldHint)
566 }
567 if tool.Annotations.Title == "" {
568 t.Fatal("delete_recipe annotation title empty")
569 }
570}
571
572func TestCallToolACIDShoppingListClear1And2ClearsShoppingList(t *testing.T) {
573 backend := &fakeBackend{}
574 server := NewServer(backend, "test")
575
576 _, output, err := server.callChangeShoppingListTool(
577 context.Background(),
578 nil,
579 ChangeShoppingListArguments{Action: " clear "},
580 )
581 if err != nil {
582 t.Fatalf("callChangeShoppingListTool() error = %v", err)
583 }
584
585 if backend.clearShoppingListCalls != 1 {
586 t.Fatalf("clear calls = %d, want 1", backend.clearShoppingListCalls)
587 }
588 if !output.Cleared {
589 t.Fatalf("clear output cleared = %v, want true", output.Cleared)
590 }
591}
592
593func TestCallToolACIDShoppingListActions2RejectsUnknownActionBeforeCallingCooked(t *testing.T) {
594 tests := []struct {
595 name string
596 arguments ChangeShoppingListArguments
597 }{
598 {name: "empty", arguments: ChangeShoppingListArguments{Action: " "}},
599 {name: "unknown", arguments: ChangeShoppingListArguments{Action: "dance"}},
600 }
601
602 for _, tt := range tests {
603 t.Run(tt.name, func(t *testing.T) {
604 backend := &fakeBackend{}
605 server := NewServer(backend, "test")
606
607 _, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
608 if err == nil {
609 t.Fatal("callChangeShoppingListTool() error = nil, want action error")
610 }
611 if backend.clearShoppingListCalls != 0 {
612 t.Fatalf("clear calls = %d, want none", backend.clearShoppingListCalls)
613 }
614 })
615 }
616}
617
618func TestCallToolACIDShoppingListAdd1To4AddsIngredients(t *testing.T) {
619 backend := &fakeBackend{addShoppingListResult: cooked.AddShoppingListResult{
620 AddedCount: 2,
621 Ingredients: []string{"200g pasta", "1 cup tomato sauce"},
622 }}
623 server := NewServer(backend, "test")
624
625 _, output, err := server.callChangeShoppingListTool(
626 context.Background(),
627 nil,
628 ChangeShoppingListArguments{
629 Action: " add ",
630 Ingredients: "200g pasta\n1 cup tomato sauce",
631 RecipeID: " recipe-1 ",
632 },
633 )
634 if err != nil {
635 t.Fatalf("callChangeShoppingListTool() error = %v", err)
636 }
637
638 if backend.addShoppingListCalls != 1 {
639 t.Fatalf("add calls = %d, want 1", backend.addShoppingListCalls)
640 }
641 if backend.addIngredients != "200g pasta\n1 cup tomato sauce" {
642 t.Fatalf("backend ingredients = %q, want raw multiline ingredients", backend.addIngredients)
643 }
644 if backend.addRecipeID != "recipe-1" {
645 t.Fatalf("backend recipe ID = %q, want recipe-1", backend.addRecipeID)
646 }
647 if output.AddedCount != 2 {
648 t.Fatalf("output added count = %d, want 2", output.AddedCount)
649 }
650 if len(output.Ingredients) != 2 || output.Ingredients[0] != "200g pasta" {
651 t.Fatalf("output ingredients = %#v, want added ingredients", output.Ingredients)
652 }
653}
654
655func TestCallToolACIDToolsChangeShoppingListTool2RequiresIngredients(t *testing.T) {
656 backend := &fakeBackend{}
657 server := NewServer(backend, "test")
658
659 _, _, err := server.callChangeShoppingListTool(
660 context.Background(),
661 nil,
662 ChangeShoppingListArguments{Action: "add", Ingredients: " "},
663 )
664 if err == nil {
665 t.Fatal("callChangeShoppingListTool() error = nil, want missing ingredients error")
666 }
667 if backend.addShoppingListCalls != 0 {
668 t.Fatalf("add calls = %d, want none", backend.addShoppingListCalls)
669 }
670}
671
672func TestCallToolACIDShoppingListRemove1And2RemovesProductGroups(t *testing.T) {
673 backend := &fakeBackend{}
674 server := NewServer(backend, "test")
675
676 _, output, err := server.callChangeShoppingListTool(
677 context.Background(),
678 nil,
679 ChangeShoppingListArguments{
680 Action: " remove ",
681 ProductGroupIDs: []string{" pasta ", "tomato", " "},
682 },
683 )
684 if err != nil {
685 t.Fatalf("callChangeShoppingListTool() error = %v", err)
686 }
687
688 if backend.removeShoppingListCalls != 1 {
689 t.Fatalf("remove calls = %d, want 1", backend.removeShoppingListCalls)
690 }
691 if len(backend.removeProductGroupIDs) != 2 || backend.removeProductGroupIDs[0] != "pasta" ||
692 backend.removeProductGroupIDs[1] != "tomato" {
693 t.Fatalf("backend remove IDs = %#v, want pasta and tomato", backend.removeProductGroupIDs)
694 }
695 if len(output.RemovedProductGroupIDs) != 2 || output.RemovedProductGroupIDs[0] != "pasta" ||
696 output.RemovedProductGroupIDs[1] != "tomato" {
697 t.Fatalf("output removed IDs = %#v, want pasta and tomato", output.RemovedProductGroupIDs)
698 }
699}
700
701func TestCallToolACIDShoppingListSelection1And5And6And8ReplacesSelectedSet(t *testing.T) {
702 backend := &fakeBackend{}
703 server := NewServer(backend, "test")
704
705 _, output, err := server.callChangeShoppingListTool(
706 context.Background(),
707 nil,
708 ChangeShoppingListArguments{
709 Action: " replace_selection ",
710 ProductGroupIDs: []string{" pasta ", "tomato", " "},
711 },
712 )
713 if err != nil {
714 t.Fatalf("callChangeShoppingListTool() error = %v", err)
715 }
716
717 if backend.replaceSelectionCalls != 1 {
718 t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
719 }
720 if len(backend.replaceSelectionProductGroupIDs) != 2 || backend.replaceSelectionProductGroupIDs[0] != "pasta" ||
721 backend.replaceSelectionProductGroupIDs[1] != "tomato" {
722 t.Fatalf("backend selected IDs = %#v, want pasta and tomato", backend.replaceSelectionProductGroupIDs)
723 }
724 if len(output.SelectedProductGroupIDs) != 2 || output.SelectedProductGroupIDs[0] != "pasta" ||
725 output.SelectedProductGroupIDs[1] != "tomato" {
726 t.Fatalf("output selected IDs = %#v, want pasta and tomato", output.SelectedProductGroupIDs)
727 }
728}
729
730func TestCallToolACIDShoppingListSelection7RequiresReplaceSelectionProductGroupIDs(t *testing.T) {
731 tests := []struct {
732 name string
733 arguments ChangeShoppingListArguments
734 }{
735 {name: "nil", arguments: ChangeShoppingListArguments{Action: "replace_selection"}},
736 {
737 name: "blank",
738 arguments: ChangeShoppingListArguments{Action: "replace_selection", ProductGroupIDs: []string{" "}},
739 },
740 }
741
742 for _, tt := range tests {
743 t.Run(tt.name, func(t *testing.T) {
744 backend := &fakeBackend{}
745 server := NewServer(backend, "test")
746
747 _, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
748 if err == nil {
749 t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
750 }
751 if backend.replaceSelectionCalls != 0 {
752 t.Fatalf("replace selection calls = %d, want none", backend.replaceSelectionCalls)
753 }
754 })
755 }
756}
757
758func TestCallToolACIDShoppingListSafety1RequiresRemoveProductGroupIDs(t *testing.T) {
759 tests := []struct {
760 name string
761 arguments ChangeShoppingListArguments
762 }{
763 {name: "nil", arguments: ChangeShoppingListArguments{Action: "remove"}},
764 {name: "blank", arguments: ChangeShoppingListArguments{Action: "remove", ProductGroupIDs: []string{" "}}},
765 }
766
767 for _, tt := range tests {
768 t.Run(tt.name, func(t *testing.T) {
769 backend := &fakeBackend{}
770 server := NewServer(backend, "test")
771
772 _, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
773 if err == nil {
774 t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
775 }
776 if backend.removeShoppingListCalls != 0 {
777 t.Fatalf("remove calls = %d, want none", backend.removeShoppingListCalls)
778 }
779 })
780 }
781}
782
783func TestChangeShoppingListToolACIDToolsAnnotations2And4To6MarksDestructiveOpenWorldTool(t *testing.T) {
784 tool := changeShoppingListTool()
785 if tool.Name != changeShoppingListToolName {
786 t.Fatalf("tool name = %q, want %q", tool.Name, changeShoppingListToolName)
787 }
788 if tool.Annotations == nil {
789 t.Fatal("tool annotations nil")
790 }
791 if tool.Annotations.ReadOnlyHint {
792 t.Fatal("change_shopping_list read-only hint = true, want false")
793 }
794 if tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint {
795 t.Fatalf("change_shopping_list destructive hint = %#v, want true", tool.Annotations.DestructiveHint)
796 }
797 if tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint {
798 t.Fatalf("change_shopping_list open-world hint = %#v, want true", tool.Annotations.OpenWorldHint)
799 }
800 if tool.Annotations.Title == "" {
801 t.Fatal("change_shopping_list annotation title empty")
802 }
803 if !strings.Contains(tool.Description, "clear") || !strings.Contains(tool.Description, "destructive") {
804 t.Fatalf("change_shopping_list description = %q, want clear/destructive warning", tool.Description)
805 }
806}
807
808type fakeBackend struct {
809 shoppingList cooked.ShoppingList
810 recipes []cooked.RecipeCard
811 searchRecipes []cooked.RecipeCard
812 recipeMetadata cooked.RecipeMetadata
813 recipeContent cooked.RecipeContent
814 preview cooked.RecipeTextPreview
815 saveRecipeID string
816 page int
817 limit int
818 searchQuery string
819 searchPage int
820 metadataRecipeID string
821 contentRecipeID string
822 previewTitle string
823 previewText string
824 saveTitle string
825 saveMarkdown string
826 savePortions int
827 updateRecipeID string
828 updateMarkdown string
829 updatePortions int
830 deleteRecipeID string
831 addShoppingListResult cooked.AddShoppingListResult
832 addIngredients string
833 addRecipeID string
834 removeProductGroupIDs []string
835 replaceSelectionProductGroupIDs []string
836 metadataCalls int
837 contentCalls int
838 previewCalls int
839 saveCalls int
840 updateCalls int
841 deleteCalls int
842 clearShoppingListCalls int
843 addShoppingListCalls int
844 removeShoppingListCalls int
845 replaceSelectionCalls int
846}
847
848func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
849 return f.shoppingList, nil
850}
851
852func (f *fakeBackend) ListRecipes(_ context.Context, page, limit int) ([]cooked.RecipeCard, error) {
853 f.page = page
854 f.limit = limit
855
856 return f.recipes, nil
857}
858
859func (f *fakeBackend) SearchRecipes(_ context.Context, query string, page int) ([]cooked.RecipeCard, error) {
860 f.searchQuery = query
861 f.searchPage = page
862
863 return f.searchRecipes, nil
864}
865
866func (f *fakeBackend) ReadRecipeMetadata(_ context.Context, recipeID string) (cooked.RecipeMetadata, error) {
867 f.metadataRecipeID = recipeID
868 f.metadataCalls++
869
870 return f.recipeMetadata, nil
871}
872
873func (f *fakeBackend) ReadRecipeContent(_ context.Context, recipeID string) (cooked.RecipeContent, error) {
874 f.contentRecipeID = recipeID
875 f.contentCalls++
876
877 return f.recipeContent, nil
878}
879
880func (f *fakeBackend) PreviewRecipeText(_ context.Context, title, text string) (cooked.RecipeTextPreview, error) {
881 f.previewTitle = title
882 f.previewText = text
883 f.previewCalls++
884
885 return f.preview, nil
886}
887
888func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown string, portions int) (string, error) {
889 f.saveTitle = title
890 f.saveMarkdown = markdown
891 f.savePortions = portions
892 f.saveCalls++
893 if f.saveRecipeID != "" {
894 return f.saveRecipeID, nil
895 }
896
897 return "recipe-1", nil
898}
899
900func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown string, portions int) error {
901 f.updateRecipeID = recipeID
902 f.updateMarkdown = markdown
903 f.updatePortions = portions
904 f.updateCalls++
905
906 return nil
907}
908
909func (f *fakeBackend) DeleteRecipe(_ context.Context, recipeID string) error {
910 f.deleteRecipeID = recipeID
911 f.deleteCalls++
912
913 return nil
914}
915
916func (f *fakeBackend) ClearShoppingList(context.Context) error {
917 f.clearShoppingListCalls++
918
919 return nil
920}
921
922func (f *fakeBackend) AddShoppingListIngredients(
923 _ context.Context,
924 ingredients, recipeID string,
925) (cooked.AddShoppingListResult, error) {
926 f.addIngredients = ingredients
927 f.addRecipeID = recipeID
928 f.addShoppingListCalls++
929
930 return f.addShoppingListResult, nil
931}
932
933func (f *fakeBackend) RemoveShoppingListProductGroups(_ context.Context, ids []string) error {
934 f.removeProductGroupIDs = ids
935 f.removeShoppingListCalls++
936
937 return nil
938}
939
940func (f *fakeBackend) ReplaceShoppingListSelection(_ context.Context, ids []string) error {
941 f.replaceSelectionProductGroupIDs = ids
942 f.replaceSelectionCalls++
943
944 return nil
945}