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