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