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: testRecipeID, 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 != testRecipeID || output.Recipes[0].Title != "Pasta" {
69 t.Fatalf("first recipe = %#v, want test recipe ID 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: testRecipeID, Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
93 {ID: testOtherRecipeID, 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 != testRecipeID || output.Recipes[0].Title != "Pasta" {
117 t.Fatalf("first recipe = %#v, want test recipe ID 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: testRecipeID},
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 != testRecipeID || backend.contentRecipeID != testRecipeID {
156 t.Fatalf(
157 "backend recipe IDs = metadata %q content %q, want test recipe ID",
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 != testRecipeID || output.Recipe.Title != "Pasta" {
174 t.Fatalf("structured recipe identity = %#v, want test recipe ID 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.callExtractIntoRecipeTool(
229 context.Background(),
230 nil,
231 ExtractIntoRecipeArguments{Title: " Pasta ", Text: " Boil pasta.\n"},
232 )
233 if err != nil {
234 t.Fatalf("callExtractIntoRecipeTool() 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("extraction output = %#v, want title/markdown/portions", output)
249 }
250 requireStructuredContent(t, result, output)
251}
252
253func TestCallToolACIDToolsExtractIntoRecipeTool1RequiresTitle(t *testing.T) {
254 backend := &fakeBackend{}
255 server := NewServer(backend, "test")
256
257 _, _, err := server.callExtractIntoRecipeTool(
258 context.Background(),
259 nil,
260 ExtractIntoRecipeArguments{Title: " ", Text: "Boil pasta."},
261 )
262 if err == nil {
263 t.Fatal("callExtractIntoRecipeTool() 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 TestCallToolACIDToolsExtractIntoRecipeTool2RequiresText(t *testing.T) {
271 backend := &fakeBackend{}
272 server := NewServer(backend, "test")
273
274 _, _, err := server.callExtractIntoRecipeTool(
275 context.Background(),
276 nil,
277 ExtractIntoRecipeArguments{Title: "Pasta", Text: " "},
278 )
279 if err == nil {
280 t.Fatal("callExtractIntoRecipeTool() 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: testRecipeID}
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 != testRecipeID {
319 t.Fatalf("save output recipe ID = %q, want test recipe ID", output.RecipeID)
320 }
321 requireStructuredContent(t, result, output)
322}
323
324func TestCallToolACIDRecipesSave2_1To2_2RequiresPreparedTitleAndMarkdown(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
339 for _, tt := range tests {
340 t.Run(tt.name, func(t *testing.T) {
341 backend := &fakeBackend{}
342 server := NewServer(backend, "test")
343
344 _, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
345 if err == nil {
346 t.Fatal("callSaveRecipeTool() error = nil, want missing prepared field error")
347 }
348 if backend.saveCalls != 0 {
349 t.Fatalf("save calls = %d, want none", backend.saveCalls)
350 }
351 })
352 }
353}
354
355func TestCallToolACIDRecipesSave1And11SavesRawTextRecipe(t *testing.T) {
356 backend := &fakeBackend{
357 preview: cooked.RecipeTextPreview{
358 Title: "Pasta with Tomato Sauce",
359 Markdown: "# Pasta\n\n1. Boil pasta.",
360 Portions: 2,
361 },
362 saveRecipeID: testRecipeID,
363 }
364 server := NewServer(backend, "test")
365
366 _, output, err := server.callSaveRecipeTool(
367 context.Background(),
368 nil,
369 SaveRecipeArguments{Source: " raw_text ", Title: " Pasta ", Text: " Boil pasta.\n"},
370 )
371 if err != nil {
372 t.Fatalf("callSaveRecipeTool() error = %v", err)
373 }
374
375 if backend.previewCalls != 1 || backend.saveCalls != 1 {
376 t.Fatalf("backend calls = preview %d save %d, want 1/1", backend.previewCalls, backend.saveCalls)
377 }
378 if backend.previewTitle != "Pasta" {
379 t.Fatalf("preview title = %q, want trimmed Pasta", backend.previewTitle)
380 }
381 if backend.previewText != " Boil pasta.\n" {
382 t.Fatalf("preview text = %q, want raw text preserved", backend.previewText)
383 }
384 if backend.saveTitle != "Pasta with Tomato Sauce" {
385 t.Fatalf("save title = %q, want preview title", backend.saveTitle)
386 }
387 if backend.saveMarkdown != "# Pasta\n\n1. Boil pasta." {
388 t.Fatalf("save markdown = %q, want preview markdown", backend.saveMarkdown)
389 }
390 if backend.savePortions != 2 {
391 t.Fatalf("save portions = %g, want preview portions 2", backend.savePortions)
392 }
393 if output.RecipeID != testRecipeID {
394 t.Fatalf("save output recipe ID = %q, want test recipe ID", output.RecipeID)
395 }
396}
397
398func TestCallToolACIDRecipesSave1_1To1_2RequiresRawTextFields(t *testing.T) {
399 tests := []struct {
400 name string
401 arguments SaveRecipeArguments
402 }{
403 {name: "title", arguments: SaveRecipeArguments{Source: "raw_text", Title: " ", Text: "Boil pasta."}},
404 {name: "text", arguments: SaveRecipeArguments{Source: "raw_text", Title: "Pasta", Text: " "}},
405 }
406
407 for _, tt := range tests {
408 t.Run(tt.name, func(t *testing.T) {
409 backend := &fakeBackend{}
410 server := NewServer(backend, "test")
411
412 _, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
413 if err == nil {
414 t.Fatal("callSaveRecipeTool() error = nil, want missing raw text field error")
415 }
416 if backend.previewCalls != 0 || backend.saveCalls != 0 {
417 t.Fatalf("backend calls = preview %d save %d, want none", backend.previewCalls, backend.saveCalls)
418 }
419 })
420 }
421}
422
423// recipes.SAVE.8 recipes.SAVE.8-4 recipes.SAVE.9
424func TestCallToolACIDRecipesSave8And8_4And9UpdatesExistingRecipe(t *testing.T) {
425 backend := &fakeBackend{}
426 server := NewServer(backend, "test")
427
428 _, output, err := server.callSaveRecipeTool(
429 context.Background(),
430 nil,
431 SaveRecipeArguments{
432 Source: " existing ",
433 RecipeID: " " + testRecipeID + " ",
434 Markdown: " # Pasta\n\n1. Boil pasta.\n",
435 Portions: 4.5,
436 },
437 )
438 if err != nil {
439 t.Fatalf("callSaveRecipeTool() error = %v", err)
440 }
441
442 if backend.updateCalls != 1 {
443 t.Fatalf("update calls = %d, want 1", backend.updateCalls)
444 }
445 if backend.updateRecipeID != testRecipeID {
446 t.Fatalf("backend update recipe ID = %q, want test recipe ID", backend.updateRecipeID)
447 }
448 if backend.updateMarkdown != " # Pasta\n\n1. Boil pasta.\n" {
449 t.Fatalf("backend update markdown = %q, want raw markdown preserved", backend.updateMarkdown)
450 }
451 if backend.updatePortions != 4.5 {
452 t.Fatalf("backend update portions = %g, want 4.5", backend.updatePortions)
453 }
454 if output.RecipeID != testRecipeID {
455 t.Fatalf("save output recipe ID = %q, want test recipe ID", output.RecipeID)
456 }
457}
458
459func TestCallToolACIDRecipesSave8_1To8_2RequiresExistingRecipeIDAndMarkdown(t *testing.T) {
460 tests := []struct {
461 name string
462 arguments SaveRecipeArguments
463 }{
464 {
465 name: "recipe ID",
466 arguments: SaveRecipeArguments{Source: "existing", RecipeID: " ", Markdown: "# Pasta", Portions: 4},
467 },
468 {
469 name: "markdown",
470 arguments: SaveRecipeArguments{Source: "existing", RecipeID: testRecipeID, Markdown: " ", Portions: 4},
471 },
472 }
473
474 for _, tt := range tests {
475 t.Run(tt.name, func(t *testing.T) {
476 backend := &fakeBackend{}
477 server := NewServer(backend, "test")
478
479 _, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
480 if err == nil {
481 t.Fatal("callSaveRecipeTool() error = nil, want missing existing field error")
482 }
483 if backend.updateCalls != 0 {
484 t.Fatalf("update calls = %d, want none", backend.updateCalls)
485 }
486 })
487 }
488}
489
490func TestCallToolACIDRecipesDelete1And2DeletesRecipe(t *testing.T) {
491 backend := &fakeBackend{}
492 server := NewServer(backend, "test")
493
494 result, output, err := server.callDeleteRecipeTool(
495 context.Background(),
496 nil,
497 DeleteRecipeArguments{RecipeID: " " + testRecipeID + " "},
498 )
499 if err != nil {
500 t.Fatalf("callDeleteRecipeTool() error = %v", err)
501 }
502
503 if backend.deleteCalls != 1 {
504 t.Fatalf("delete calls = %d, want 1", backend.deleteCalls)
505 }
506 if backend.deleteRecipeID != testRecipeID {
507 t.Fatalf("backend delete recipe ID = %q, want test recipe ID", backend.deleteRecipeID)
508 }
509 if output.RecipeID != testRecipeID {
510 t.Fatalf("delete output recipe ID = %q, want test recipe ID", output.RecipeID)
511 }
512 requireStructuredContent(t, result, output)
513}
514
515func TestCallToolACIDToolsDeleteRecipeTool1RequiresRecipeID(t *testing.T) {
516 backend := &fakeBackend{}
517 server := NewServer(backend, "test")
518
519 _, _, err := server.callDeleteRecipeTool(context.Background(), nil, DeleteRecipeArguments{RecipeID: " "})
520 if err == nil {
521 t.Fatal("callDeleteRecipeTool() error = nil, want missing recipe_id error")
522 }
523 if backend.deleteCalls != 0 {
524 t.Fatalf("delete calls = %d, want none", backend.deleteCalls)
525 }
526}
527
528func TestDeleteRecipeToolACIDToolsAnnotations2To5MarksDestructiveOpenWorldTool(t *testing.T) {
529 tool := deleteRecipeTool()
530 if tool.Name != deleteRecipeToolName {
531 t.Fatalf("tool name = %q, want %q", tool.Name, deleteRecipeToolName)
532 }
533 if tool.Annotations == nil {
534 t.Fatal("tool annotations nil")
535 }
536 if tool.Annotations.ReadOnlyHint {
537 t.Fatal("delete_recipe read-only hint = true, want false")
538 }
539 if tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint {
540 t.Fatalf("delete_recipe destructive hint = %#v, want true", tool.Annotations.DestructiveHint)
541 }
542 if tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint {
543 t.Fatalf("delete_recipe open-world hint = %#v, want true", tool.Annotations.OpenWorldHint)
544 }
545 if tool.Annotations.Title == "" {
546 t.Fatal("delete_recipe annotation title empty")
547 }
548}
549
550func TestCallToolACIDShoppingListClear1And2ClearsShoppingList(t *testing.T) {
551 backend := &fakeBackend{}
552 server := NewServer(backend, "test")
553
554 result, output, err := server.callChangeShoppingListTool(
555 context.Background(),
556 nil,
557 ChangeShoppingListArguments{Action: " clear "},
558 )
559 if err != nil {
560 t.Fatalf("callChangeShoppingListTool() error = %v", err)
561 }
562
563 if backend.clearShoppingListCalls != 1 {
564 t.Fatalf("clear calls = %d, want 1", backend.clearShoppingListCalls)
565 }
566 if !output.Cleared {
567 t.Fatalf("clear output cleared = %v, want true", output.Cleared)
568 }
569 requireStructuredContent(t, result, output)
570}
571
572func TestCallToolACIDShoppingListActions2RejectsUnknownActionBeforeCallingCooked(t *testing.T) {
573 tests := []struct {
574 name string
575 arguments ChangeShoppingListArguments
576 wantError string
577 }{
578 {name: "empty", arguments: ChangeShoppingListArguments{Action: " "}, wantError: "action is required"},
579 {
580 name: "unknown",
581 arguments: ChangeShoppingListArguments{Action: "dance"},
582 wantError: `unsupported change_shopping_list action "dance" (supported: add, update_item, replace_selection, add_selection, remove_selection, remove, clear)`,
583 },
584 }
585
586 for _, tt := range tests {
587 t.Run(tt.name, func(t *testing.T) {
588 backend := &fakeBackend{}
589 server := NewServer(backend, "test")
590
591 _, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
592 if err == nil {
593 t.Fatal("callChangeShoppingListTool() error = nil, want action error")
594 }
595 if err.Error() != tt.wantError {
596 t.Fatalf("callChangeShoppingListTool() error = %q", err.Error())
597 }
598 if backend.clearShoppingListCalls != 0 {
599 t.Fatalf("clear calls = %d, want none", backend.clearShoppingListCalls)
600 }
601 })
602 }
603}
604
605func TestCallToolACIDShoppingListAdd1To4AddsIngredients(t *testing.T) {
606 backend := &fakeBackend{addShoppingListResult: cooked.AddShoppingListResult{
607 AddedCount: 2,
608 Ingredients: []string{"200g pasta", "1 cup tomato sauce"},
609 }}
610 server := NewServer(backend, "test")
611
612 _, output, err := server.callChangeShoppingListTool(
613 context.Background(),
614 nil,
615 ChangeShoppingListArguments{
616 Action: " add ",
617 Ingredients: "200g pasta\n1 cup tomato sauce",
618 RecipeID: " draft-1 ",
619 },
620 )
621 if err != nil {
622 t.Fatalf("callChangeShoppingListTool() error = %v", err)
623 }
624
625 if backend.addShoppingListCalls != 1 {
626 t.Fatalf("add calls = %d, want 1", backend.addShoppingListCalls)
627 }
628 if backend.addIngredients != "200g pasta\n1 cup tomato sauce" {
629 t.Fatalf("backend ingredients = %q, want raw multiline ingredients", backend.addIngredients)
630 }
631 if backend.addRecipeID != "draft-1" {
632 t.Fatalf("backend recipe ID = %q, want trimmed opaque draft ID", backend.addRecipeID)
633 }
634 if output.AddedCount != 2 {
635 t.Fatalf("output added count = %d, want 2", output.AddedCount)
636 }
637 if len(output.Ingredients) != 2 || output.Ingredients[0] != "200g pasta" {
638 t.Fatalf("output ingredients = %#v, want added ingredients", output.Ingredients)
639 }
640}
641
642func TestCallToolACIDToolsChangeShoppingListTool2RequiresIngredients(t *testing.T) {
643 backend := &fakeBackend{}
644 server := NewServer(backend, "test")
645
646 _, _, err := server.callChangeShoppingListTool(
647 context.Background(),
648 nil,
649 ChangeShoppingListArguments{Action: "add", Ingredients: " "},
650 )
651 if err == nil {
652 t.Fatal("callChangeShoppingListTool() error = nil, want missing ingredients error")
653 }
654 if backend.addShoppingListCalls != 0 {
655 t.Fatalf("add calls = %d, want none", backend.addShoppingListCalls)
656 }
657}
658
659func TestCallToolACIDShoppingListRemove1And2RemovesProductGroups(t *testing.T) {
660 backend := &fakeBackend{}
661 server := NewServer(backend, "test")
662
663 _, output, err := server.callChangeShoppingListTool(
664 context.Background(),
665 nil,
666 ChangeShoppingListArguments{
667 Action: " remove ",
668 ProductGroupIDs: []string{" " + testProductGroupID + " ", testProductGroupID2, " "},
669 },
670 )
671 if err != nil {
672 t.Fatalf("callChangeShoppingListTool() error = %v", err)
673 }
674
675 if backend.removeShoppingListCalls != 1 {
676 t.Fatalf("remove calls = %d, want 1", backend.removeShoppingListCalls)
677 }
678 if len(backend.removeProductGroupIDs) != 2 || backend.removeProductGroupIDs[0] != testProductGroupID ||
679 backend.removeProductGroupIDs[1] != testProductGroupID2 {
680 t.Fatalf("backend remove IDs = %#v, want valid test product group IDs", backend.removeProductGroupIDs)
681 }
682 if len(output.RemovedProductGroupIDs) != 2 || output.RemovedProductGroupIDs[0] != testProductGroupID ||
683 output.RemovedProductGroupIDs[1] != testProductGroupID2 {
684 t.Fatalf("output removed IDs = %#v, want valid test product group IDs", output.RemovedProductGroupIDs)
685 }
686}
687
688func TestCallToolACIDShoppingListSelection1And5And6And8ReplacesSelectedSet(t *testing.T) {
689 backend := &fakeBackend{}
690 server := NewServer(backend, "test")
691
692 _, output, err := server.callChangeShoppingListTool(
693 context.Background(),
694 nil,
695 ChangeShoppingListArguments{
696 Action: " replace_selection ",
697 ProductGroupIDs: []string{" " + testProductGroupID + " ", testProductGroupID2, " "},
698 },
699 )
700 if err != nil {
701 t.Fatalf("callChangeShoppingListTool() error = %v", err)
702 }
703
704 if backend.replaceSelectionCalls != 1 {
705 t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
706 }
707 if len(backend.replaceSelectionProductGroupIDs) != 2 ||
708 backend.replaceSelectionProductGroupIDs[0] != testProductGroupID ||
709 backend.replaceSelectionProductGroupIDs[1] != testProductGroupID2 {
710 t.Fatalf(
711 "backend selected IDs = %#v, want valid test product group IDs",
712 backend.replaceSelectionProductGroupIDs,
713 )
714 }
715 if len(output.SelectedProductGroupIDs) != 2 || output.SelectedProductGroupIDs[0] != testProductGroupID ||
716 output.SelectedProductGroupIDs[1] != testProductGroupID2 {
717 t.Fatalf("output selected IDs = %#v, want valid test product group IDs", output.SelectedProductGroupIDs)
718 }
719}
720
721func TestCallToolACIDShoppingListSelection7RequiresReplaceSelectionProductGroupIDs(t *testing.T) {
722 tests := []struct {
723 name string
724 arguments ChangeShoppingListArguments
725 }{
726 {name: "nil", arguments: ChangeShoppingListArguments{Action: "replace_selection"}},
727 {
728 name: "blank",
729 arguments: ChangeShoppingListArguments{Action: "replace_selection", ProductGroupIDs: []string{" "}},
730 },
731 }
732
733 for _, tt := range tests {
734 t.Run(tt.name, func(t *testing.T) {
735 backend := &fakeBackend{}
736 server := NewServer(backend, "test")
737
738 _, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
739 if err == nil {
740 t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
741 }
742 if backend.replaceSelectionCalls != 0 {
743 t.Fatalf("replace selection calls = %d, want none", backend.replaceSelectionCalls)
744 }
745 })
746 }
747}
748
749func TestCallToolACIDShoppingListSelection2And4And5And6And9AddsSelectedSet(t *testing.T) {
750 backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
751 ProductGroups: []cooked.ProductGroup{
752 {ID: testProductGroupID, Selected: true},
753 {ID: testProductGroupID2},
754 {ID: testProductGroupID3, Selected: true},
755 },
756 }}}}
757 server := NewServer(backend, "test")
758
759 _, output, err := server.callChangeShoppingListTool(
760 context.Background(),
761 nil,
762 ChangeShoppingListArguments{
763 Action: " add_selection ",
764 ProductGroupIDs: []string{" " + testProductGroupID2 + " ", testProductGroupID, " ", testProductGroupID4},
765 },
766 )
767 if err != nil {
768 t.Fatalf("callChangeShoppingListTool() error = %v", err)
769 }
770
771 if backend.readShoppingListCalls != 1 {
772 t.Fatalf("read shopping-list calls = %d, want 1", backend.readShoppingListCalls)
773 }
774 if backend.replaceSelectionCalls != 1 {
775 t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
776 }
777 expectedIDs := []string{testProductGroupID, testProductGroupID3, testProductGroupID2, testProductGroupID4}
778 if !slices.Equal(backend.replaceSelectionProductGroupIDs, expectedIDs) {
779 t.Fatalf("backend selected IDs = %#v, want %#v", backend.replaceSelectionProductGroupIDs, expectedIDs)
780 }
781 if !slices.Equal(output.SelectedProductGroupIDs, expectedIDs) {
782 t.Fatalf("output selected IDs = %#v, want %#v", output.SelectedProductGroupIDs, expectedIDs)
783 }
784}
785
786func TestCallToolACIDShoppingListSelection7RequiresAddSelectionProductGroupIDs(t *testing.T) {
787 tests := []struct {
788 name string
789 arguments ChangeShoppingListArguments
790 }{
791 {name: "nil", arguments: ChangeShoppingListArguments{Action: "add_selection"}},
792 {
793 name: "blank",
794 arguments: ChangeShoppingListArguments{Action: "add_selection", ProductGroupIDs: []string{" "}},
795 },
796 }
797
798 for _, tt := range tests {
799 t.Run(tt.name, func(t *testing.T) {
800 backend := &fakeBackend{}
801 server := NewServer(backend, "test")
802
803 _, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
804 if err == nil {
805 t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
806 }
807 if backend.readShoppingListCalls != 0 {
808 t.Fatalf("read shopping-list calls = %d, want none", backend.readShoppingListCalls)
809 }
810 if backend.replaceSelectionCalls != 0 {
811 t.Fatalf("replace selection calls = %d, want none", backend.replaceSelectionCalls)
812 }
813 })
814 }
815}
816
817func TestCallToolACIDShoppingListSelection3And4And5And6And10RemovesSelectedSet(t *testing.T) {
818 backend := &fakeBackend{shoppingList: cooked.ShoppingList{Aisles: []cooked.Aisle{{
819 ProductGroups: []cooked.ProductGroup{
820 {ID: testProductGroupID, Selected: true},
821 {ID: testProductGroupID2, Selected: true},
822 {ID: testProductGroupID3, Selected: true},
823 {ID: testProductGroupID4},
824 },
825 }}}}
826 server := NewServer(backend, "test")
827
828 _, output, err := server.callChangeShoppingListTool(
829 context.Background(),
830 nil,
831 ChangeShoppingListArguments{
832 Action: " remove_selection ",
833 ProductGroupIDs: []string{" " + testProductGroupID2 + " ", testProductGroupID4, " "},
834 },
835 )
836 if err != nil {
837 t.Fatalf("callChangeShoppingListTool() error = %v", err)
838 }
839
840 if backend.readShoppingListCalls != 1 {
841 t.Fatalf("read shopping-list calls = %d, want 1", backend.readShoppingListCalls)
842 }
843 if backend.replaceSelectionCalls != 1 {
844 t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
845 }
846 expectedIDs := []string{testProductGroupID, testProductGroupID3}
847 if !slices.Equal(backend.replaceSelectionProductGroupIDs, expectedIDs) {
848 t.Fatalf("backend selected IDs = %#v, want %#v", backend.replaceSelectionProductGroupIDs, expectedIDs)
849 }
850 if !slices.Equal(output.SelectedProductGroupIDs, expectedIDs) {
851 t.Fatalf("output selected IDs = %#v, want %#v", output.SelectedProductGroupIDs, expectedIDs)
852 }
853}
854
855func TestCallToolACIDShoppingListSelection7RequiresRemoveSelectionProductGroupIDs(t *testing.T) {
856 tests := []struct {
857 name string
858 arguments ChangeShoppingListArguments
859 }{
860 {name: "nil", arguments: ChangeShoppingListArguments{Action: "remove_selection"}},
861 {
862 name: "blank",
863 arguments: ChangeShoppingListArguments{Action: "remove_selection", ProductGroupIDs: []string{" "}},
864 },
865 }
866
867 for _, tt := range tests {
868 t.Run(tt.name, func(t *testing.T) {
869 backend := &fakeBackend{}
870 server := NewServer(backend, "test")
871
872 _, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
873 if err == nil {
874 t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
875 }
876 if backend.readShoppingListCalls != 0 {
877 t.Fatalf("read shopping-list calls = %d, want none", backend.readShoppingListCalls)
878 }
879 if backend.replaceSelectionCalls != 0 {
880 t.Fatalf("replace selection calls = %d, want none", backend.replaceSelectionCalls)
881 }
882 })
883 }
884}
885
886func TestCallToolACIDShoppingListSafety1RequiresRemoveProductGroupIDs(t *testing.T) {
887 tests := []struct {
888 name string
889 arguments ChangeShoppingListArguments
890 }{
891 {name: "nil", arguments: ChangeShoppingListArguments{Action: "remove"}},
892 {name: "blank", arguments: ChangeShoppingListArguments{Action: "remove", ProductGroupIDs: []string{" "}}},
893 }
894
895 for _, tt := range tests {
896 t.Run(tt.name, func(t *testing.T) {
897 backend := &fakeBackend{}
898 server := NewServer(backend, "test")
899
900 _, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
901 if err == nil {
902 t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
903 }
904 if backend.removeShoppingListCalls != 0 {
905 t.Fatalf("remove calls = %d, want none", backend.removeShoppingListCalls)
906 }
907 })
908 }
909}
910
911func TestChangeShoppingListToolACIDToolsAnnotations2And4To6MarksDestructiveOpenWorldTool(t *testing.T) {
912 tool := changeShoppingListTool()
913 if tool.Name != changeShoppingListToolName {
914 t.Fatalf("tool name = %q, want %q", tool.Name, changeShoppingListToolName)
915 }
916 if tool.Annotations == nil {
917 t.Fatal("tool annotations nil")
918 }
919 if tool.Annotations.ReadOnlyHint {
920 t.Fatal("change_shopping_list read-only hint = true, want false")
921 }
922 if tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint {
923 t.Fatalf("change_shopping_list destructive hint = %#v, want true", tool.Annotations.DestructiveHint)
924 }
925 if tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint {
926 t.Fatalf("change_shopping_list open-world hint = %#v, want true", tool.Annotations.OpenWorldHint)
927 }
928 if tool.Annotations.Title == "" {
929 t.Fatal("change_shopping_list annotation title empty")
930 }
931 if !strings.Contains(tool.Description, "clear") || !strings.Contains(tool.Description, "destructive") {
932 t.Fatalf("change_shopping_list description = %q, want clear/destructive warning", tool.Description)
933 }
934}