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