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
121// recipes.READ.5 recipes.READ.8-1
122func TestCallToolACIDRecipesRead5And8_1ReadsSingleRecipe(t *testing.T) {
123 backend := &fakeBackend{
124 recipeMetadata: cooked.RecipeMetadata{
125 Title: "Pasta",
126 ImageURLs: []string{"https://example.invalid/pasta.jpg"},
127 Owner: "returned-user",
128 EditPermission: true,
129 },
130 recipeContent: cooked.RecipeContent{
131 Content: "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.",
132 Portions: 1.5,
133 },
134 }
135 server := NewServer(backend, "test")
136
137 result, output, err := server.callReadTool(
138 context.Background(),
139 nil,
140 ReadArguments{Target: "recipe", RecipeID: "recipe-1"},
141 )
142 if err != nil {
143 t.Fatalf("callReadTool() error = %v", err)
144 }
145
146 requireSingleRecipeBackendCalls(t, backend)
147 requireSingleRecipeOutput(t, output)
148 requireStructuredContent(t, result, output)
149 requireNoImageLeak(t, result, output)
150}
151
152func requireSingleRecipeBackendCalls(t *testing.T, backend *fakeBackend) {
153 t.Helper()
154
155 if backend.metadataRecipeID != "recipe-1" || backend.contentRecipeID != "recipe-1" {
156 t.Fatalf(
157 "backend recipe IDs = metadata %q content %q, want recipe-1",
158 backend.metadataRecipeID,
159 backend.contentRecipeID,
160 )
161 }
162 if backend.metadataCalls != 1 || backend.contentCalls != 1 {
163 t.Fatalf("backend calls = metadata %d content %d, want 1/1", backend.metadataCalls, backend.contentCalls)
164 }
165}
166
167func requireSingleRecipeOutput(t *testing.T, output ReadOutput) {
168 t.Helper()
169
170 if output.Recipe == nil {
171 t.Fatal("structured recipe is nil")
172 }
173 if output.Recipe.ID != "recipe-1" || output.Recipe.Title != "Pasta" {
174 t.Fatalf("structured recipe identity = %#v, want recipe-1 Pasta", output.Recipe)
175 }
176 if output.Recipe.Owner != "returned-user" {
177 t.Fatalf("structured recipe owner = %q, want returned-user", output.Recipe.Owner)
178 }
179 if output.Recipe.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." || output.Recipe.Portions != 1.5 {
180 t.Fatalf(
181 "structured recipe content/portions = %q/%g, want markdown/1.5",
182 output.Recipe.Content,
183 output.Recipe.Portions,
184 )
185 }
186}
187
188func requireNoImageLeak(t *testing.T, result *sdk.CallToolResult, output ReadOutput) {
189 t.Helper()
190
191 encodedOutput, err := json.Marshal(output)
192 if err != nil {
193 t.Fatalf("marshal output: %v", err)
194 }
195 if strings.Contains(string(encodedOutput), "image") ||
196 strings.Contains(string(encodedOutput), "https://example.invalid/pasta.jpg") {
197 t.Fatalf("structured output leaked image data: %s", encodedOutput)
198 }
199 text := requireTextContent(t, result)
200 if strings.Contains(text, "https://example.invalid/pasta.jpg") {
201 t.Fatalf("text output leaked image URL: %s", text)
202 }
203}
204
205func TestCallToolACIDToolsReadTool3RequiresRecipeIDForRecipeTarget(t *testing.T) {
206 backend := &fakeBackend{}
207 server := NewServer(backend, "test")
208
209 _, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipe", RecipeID: " "})
210 if err == nil {
211 t.Fatal("CallReadTool() error = nil, want missing recipe_id error")
212 }
213
214 if backend.metadataCalls != 0 || backend.contentCalls != 0 {
215 t.Fatalf("backend calls = metadata %d content %d, want none", backend.metadataCalls, backend.contentCalls)
216 }
217}
218
219// recipes.PREVIEW_TEXT.1 recipes.PREVIEW_TEXT.4-1
220func TestCallToolACIDRecipesPreviewText1And4_1PreviewsRawText(t *testing.T) {
221 backend := &fakeBackend{preview: cooked.RecipeTextPreview{
222 Title: "Pasta with Tomato Sauce",
223 Markdown: "# Pasta\n\n1. Boil pasta.",
224 Portions: 2.5,
225 }}
226 server := NewServer(backend, "test")
227
228 result, output, err := server.callPreviewRecipeTextTool(
229 context.Background(),
230 nil,
231 PreviewRecipeTextArguments{Title: " Pasta ", Text: " Boil pasta.\n"},
232 )
233 if err != nil {
234 t.Fatalf("callPreviewRecipeTextTool() error = %v", err)
235 }
236
237 if backend.previewCalls != 1 {
238 t.Fatalf("preview calls = %d, want 1", backend.previewCalls)
239 }
240 if backend.previewTitle != "Pasta" {
241 t.Fatalf("backend preview title = %q, want trimmed Pasta", backend.previewTitle)
242 }
243 if backend.previewText != " Boil pasta.\n" {
244 t.Fatalf("backend preview text = %q, want raw text preserved", backend.previewText)
245 }
246 if output.Title != "Pasta with Tomato Sauce" || output.Markdown != "# Pasta\n\n1. Boil pasta." ||
247 output.Portions != 2.5 {
248 t.Fatalf("preview output = %#v, want title/markdown/portions", output)
249 }
250 requireStructuredContent(t, result, output)
251}
252
253func TestCallToolACIDToolsPreviewRecipeTextTool1RequiresTitle(t *testing.T) {
254 backend := &fakeBackend{}
255 server := NewServer(backend, "test")
256
257 _, _, err := server.callPreviewRecipeTextTool(
258 context.Background(),
259 nil,
260 PreviewRecipeTextArguments{Title: " ", Text: "Boil pasta."},
261 )
262 if err == nil {
263 t.Fatal("callPreviewRecipeTextTool() error = nil, want missing title error")
264 }
265 if backend.previewCalls != 0 {
266 t.Fatalf("preview calls = %d, want none", backend.previewCalls)
267 }
268}
269
270func TestCallToolACIDToolsPreviewRecipeTextTool2RequiresText(t *testing.T) {
271 backend := &fakeBackend{}
272 server := NewServer(backend, "test")
273
274 _, _, err := server.callPreviewRecipeTextTool(
275 context.Background(),
276 nil,
277 PreviewRecipeTextArguments{Title: "Pasta", Text: " "},
278 )
279 if err == nil {
280 t.Fatal("callPreviewRecipeTextTool() error = nil, want missing text error")
281 }
282 if backend.previewCalls != 0 {
283 t.Fatalf("preview calls = %d, want none", backend.previewCalls)
284 }
285}
286
287// recipes.SAVE.2 recipes.SAVE.8-4 recipes.SAVE.9
288func TestCallToolACIDRecipesSave2And8_4And9SavesPreparedRecipe(t *testing.T) {
289 backend := &fakeBackend{saveRecipeID: "recipe-1"}
290 server := NewServer(backend, "test")
291
292 result, output, err := server.callSaveRecipeTool(
293 context.Background(),
294 nil,
295 SaveRecipeArguments{
296 Source: " prepared ",
297 Title: " Pasta ",
298 Markdown: " # Pasta\n\n1. Boil pasta.\n",
299 Portions: 2.5,
300 },
301 )
302 if err != nil {
303 t.Fatalf("callSaveRecipeTool() error = %v", err)
304 }
305
306 if backend.saveCalls != 1 {
307 t.Fatalf("save calls = %d, want 1", backend.saveCalls)
308 }
309 if backend.saveTitle != "Pasta" {
310 t.Fatalf("backend save title = %q, want trimmed Pasta", backend.saveTitle)
311 }
312 if backend.saveMarkdown != " # Pasta\n\n1. Boil pasta.\n" {
313 t.Fatalf("backend save markdown = %q, want raw markdown preserved", backend.saveMarkdown)
314 }
315 if backend.savePortions != 2.5 {
316 t.Fatalf("backend save portions = %g, want 2.5", backend.savePortions)
317 }
318 if output.RecipeID != "recipe-1" {
319 t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
320 }
321 requireStructuredContent(t, result, output)
322}
323
324func TestCallToolACIDRecipesSave2_1To2_3RequiresPreparedFields(t *testing.T) {
325 tests := []struct {
326 name string
327 arguments SaveRecipeArguments
328 }{
329 {
330 name: "title",
331 arguments: SaveRecipeArguments{Source: "prepared", Title: " ", Markdown: "# Pasta", Portions: 2},
332 },
333 {
334 name: "markdown",
335 arguments: SaveRecipeArguments{Source: "prepared", Title: "Pasta", Markdown: " ", Portions: 2},
336 },
337 {
338 name: "portions",
339 arguments: SaveRecipeArguments{Source: "prepared", Title: "Pasta", Markdown: "# Pasta", Portions: 0},
340 },
341 }
342
343 for _, tt := range tests {
344 t.Run(tt.name, func(t *testing.T) {
345 backend := &fakeBackend{}
346 server := NewServer(backend, "test")
347
348 _, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
349 if err == nil {
350 t.Fatal("callSaveRecipeTool() error = nil, want missing prepared field error")
351 }
352 if backend.saveCalls != 0 {
353 t.Fatalf("save calls = %d, want none", backend.saveCalls)
354 }
355 })
356 }
357}
358
359func TestCallToolACIDRecipesSave1And11SavesRawTextRecipe(t *testing.T) {
360 backend := &fakeBackend{
361 preview: cooked.RecipeTextPreview{
362 Title: "Pasta with Tomato Sauce",
363 Markdown: "# Pasta\n\n1. Boil pasta.",
364 Portions: 2,
365 },
366 saveRecipeID: "recipe-1",
367 }
368 server := NewServer(backend, "test")
369
370 _, output, err := server.callSaveRecipeTool(
371 context.Background(),
372 nil,
373 SaveRecipeArguments{Source: " raw_text ", Title: " Pasta ", Text: " Boil pasta.\n"},
374 )
375 if err != nil {
376 t.Fatalf("callSaveRecipeTool() error = %v", err)
377 }
378
379 if backend.previewCalls != 1 || backend.saveCalls != 1 {
380 t.Fatalf("backend calls = preview %d save %d, want 1/1", backend.previewCalls, backend.saveCalls)
381 }
382 if backend.previewTitle != "Pasta" {
383 t.Fatalf("preview title = %q, want trimmed Pasta", backend.previewTitle)
384 }
385 if backend.previewText != " Boil pasta.\n" {
386 t.Fatalf("preview text = %q, want raw text preserved", backend.previewText)
387 }
388 if backend.saveTitle != "Pasta with Tomato Sauce" {
389 t.Fatalf("save title = %q, want preview title", backend.saveTitle)
390 }
391 if backend.saveMarkdown != "# Pasta\n\n1. Boil pasta." {
392 t.Fatalf("save markdown = %q, want preview markdown", backend.saveMarkdown)
393 }
394 if backend.savePortions != 2 {
395 t.Fatalf("save portions = %g, want preview portions 2", backend.savePortions)
396 }
397 if output.RecipeID != "recipe-1" {
398 t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
399 }
400}
401
402func TestCallToolACIDRecipesSave1_1To1_2RequiresRawTextFields(t *testing.T) {
403 tests := []struct {
404 name string
405 arguments SaveRecipeArguments
406 }{
407 {name: "title", arguments: SaveRecipeArguments{Source: "raw_text", Title: " ", Text: "Boil pasta."}},
408 {name: "text", arguments: SaveRecipeArguments{Source: "raw_text", Title: "Pasta", Text: " "}},
409 }
410
411 for _, tt := range tests {
412 t.Run(tt.name, func(t *testing.T) {
413 backend := &fakeBackend{}
414 server := NewServer(backend, "test")
415
416 _, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
417 if err == nil {
418 t.Fatal("callSaveRecipeTool() error = nil, want missing raw text field error")
419 }
420 if backend.previewCalls != 0 || backend.saveCalls != 0 {
421 t.Fatalf("backend calls = preview %d save %d, want none", backend.previewCalls, backend.saveCalls)
422 }
423 })
424 }
425}
426
427// recipes.SAVE.8 recipes.SAVE.8-4 recipes.SAVE.9
428func TestCallToolACIDRecipesSave8And8_4And9UpdatesExistingRecipe(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.5,
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.5 {
456 t.Fatalf("backend update portions = %g, want 4.5", 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 float64
962 draftSaveID string
963 draftSaveMarkdown string
964 draftSavePortions float64
965 updateRecipeID string
966 updateMarkdown string
967 updatePortions float64
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 float64) (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 float64) (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 float64) 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}