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