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