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: "unsupported"})
368 if err == nil {
369 t.Fatal("callSaveRecipeTool() error = nil, want unsupported source error")
370 }
371 if backend.saveCalls != 0 || backend.draftSaveCalls != 0 {
372 t.Fatalf("save calls = prepared %d draft %d, want none", backend.saveCalls, backend.draftSaveCalls)
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 draftSaveRecipeID string
956 page int
957 limit int
958 searchQuery string
959 searchPage int
960 metadataRecipeID string
961 contentRecipeID string
962 previewTitle string
963 previewText string
964 importURL string
965 saveTitle string
966 saveMarkdown string
967 savePortions int
968 draftSaveID string
969 draftSaveMarkdown string
970 draftSavePortions int
971 updateRecipeID string
972 updateMarkdown string
973 updatePortions int
974 deleteRecipeID string
975 addShoppingListResult cooked.AddShoppingListResult
976 addIngredients string
977 addRecipeID string
978 removeProductGroupIDs []string
979 replaceSelectionProductGroupIDs []string
980 updateShoppingListProductGroupID string
981 updateShoppingListProductGroup cooked.ShoppingListProductGroupUpdate
982 metadataCalls int
983 readShoppingListCalls int
984 contentCalls int
985 previewCalls int
986 importRecipeURLCalls int
987 saveCalls int
988 draftSaveCalls int
989 updateCalls int
990 deleteCalls int
991 clearShoppingListCalls int
992 addShoppingListCalls int
993 removeShoppingListCalls int
994 replaceSelectionCalls int
995 updateShoppingListCalls int
996}
997
998func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
999 f.readShoppingListCalls++
1000
1001 return f.shoppingList, nil
1002}
1003
1004func (f *fakeBackend) ListRecipes(_ context.Context, page, limit int) ([]cooked.RecipeCard, error) {
1005 f.page = page
1006 f.limit = limit
1007
1008 return f.recipes, nil
1009}
1010
1011func (f *fakeBackend) SearchRecipes(_ context.Context, query string, page int) ([]cooked.RecipeCard, error) {
1012 f.searchQuery = query
1013 f.searchPage = page
1014
1015 return f.searchRecipes, nil
1016}
1017
1018func (f *fakeBackend) ReadRecipeMetadata(_ context.Context, recipeID string) (cooked.RecipeMetadata, error) {
1019 f.metadataRecipeID = recipeID
1020 f.metadataCalls++
1021
1022 return f.recipeMetadata, nil
1023}
1024
1025func (f *fakeBackend) ReadRecipeContent(_ context.Context, recipeID string) (cooked.RecipeContent, error) {
1026 f.contentRecipeID = recipeID
1027 f.contentCalls++
1028
1029 return f.recipeContent, nil
1030}
1031
1032func (f *fakeBackend) PreviewRecipeText(_ context.Context, title, text string) (cooked.RecipeTextPreview, error) {
1033 f.previewTitle = title
1034 f.previewText = text
1035 f.previewCalls++
1036
1037 return f.preview, nil
1038}
1039
1040func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown string, portions int) (string, error) {
1041 f.saveTitle = title
1042 f.saveMarkdown = markdown
1043 f.savePortions = portions
1044 f.saveCalls++
1045 if f.saveRecipeID != "" {
1046 return f.saveRecipeID, nil
1047 }
1048
1049 return "recipe-1", nil
1050}
1051
1052func (f *fakeBackend) SaveRecipeDraft(_ context.Context, draftID, markdown string, portions int) (string, error) {
1053 f.draftSaveID = draftID
1054 f.draftSaveMarkdown = markdown
1055 f.draftSavePortions = portions
1056 f.draftSaveCalls++
1057 if f.draftSaveRecipeID != "" {
1058 return f.draftSaveRecipeID, nil
1059 }
1060
1061 return "recipe-1", nil
1062}
1063
1064func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown string, portions int) error {
1065 f.updateRecipeID = recipeID
1066 f.updateMarkdown = markdown
1067 f.updatePortions = portions
1068 f.updateCalls++
1069
1070 return nil
1071}
1072
1073func (f *fakeBackend) ImportRecipeURL(_ context.Context, recipeURL string) (cooked.RecipeURLImport, error) {
1074 f.importURL = recipeURL
1075 f.importRecipeURLCalls++
1076
1077 return f.importRecipeURLResult, nil
1078}
1079
1080func (f *fakeBackend) DeleteRecipe(_ context.Context, recipeID string) error {
1081 f.deleteRecipeID = recipeID
1082 f.deleteCalls++
1083
1084 return nil
1085}
1086
1087func (f *fakeBackend) ClearShoppingList(context.Context) error {
1088 f.clearShoppingListCalls++
1089
1090 return nil
1091}
1092
1093func (f *fakeBackend) AddShoppingListIngredients(
1094 _ context.Context,
1095 ingredients, recipeID string,
1096) (cooked.AddShoppingListResult, error) {
1097 f.addIngredients = ingredients
1098 f.addRecipeID = recipeID
1099 f.addShoppingListCalls++
1100
1101 return f.addShoppingListResult, nil
1102}
1103
1104func (f *fakeBackend) RemoveShoppingListProductGroups(_ context.Context, ids []string) error {
1105 f.removeProductGroupIDs = ids
1106 f.removeShoppingListCalls++
1107
1108 return nil
1109}
1110
1111func (f *fakeBackend) ReplaceShoppingListSelection(_ context.Context, ids []string) error {
1112 f.replaceSelectionProductGroupIDs = ids
1113 f.replaceSelectionCalls++
1114
1115 return nil
1116}
1117
1118func (f *fakeBackend) UpdateShoppingListProductGroup(
1119 _ context.Context,
1120 productGroupID string,
1121 update cooked.ShoppingListProductGroupUpdate,
1122) error {
1123 f.updateShoppingListProductGroupID = productGroupID
1124 f.updateShoppingListProductGroup = update
1125 f.updateShoppingListCalls++
1126
1127 return nil
1128}