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: "url"})
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 TestCallToolACIDShoppingListSafety1RequiresRemoveProductGroupIDs(t *testing.T) {
828 tests := []struct {
829 name string
830 arguments ChangeShoppingListArguments
831 }{
832 {name: "nil", arguments: ChangeShoppingListArguments{Action: "remove"}},
833 {name: "blank", arguments: ChangeShoppingListArguments{Action: "remove", ProductGroupIDs: []string{" "}}},
834 }
835
836 for _, tt := range tests {
837 t.Run(tt.name, func(t *testing.T) {
838 backend := &fakeBackend{}
839 server := NewServer(backend, "test")
840
841 _, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
842 if err == nil {
843 t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
844 }
845 if backend.removeShoppingListCalls != 0 {
846 t.Fatalf("remove calls = %d, want none", backend.removeShoppingListCalls)
847 }
848 })
849 }
850}
851
852func TestChangeShoppingListToolACIDToolsAnnotations2And4To6MarksDestructiveOpenWorldTool(t *testing.T) {
853 tool := changeShoppingListTool()
854 if tool.Name != changeShoppingListToolName {
855 t.Fatalf("tool name = %q, want %q", tool.Name, changeShoppingListToolName)
856 }
857 if tool.Annotations == nil {
858 t.Fatal("tool annotations nil")
859 }
860 if tool.Annotations.ReadOnlyHint {
861 t.Fatal("change_shopping_list read-only hint = true, want false")
862 }
863 if tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint {
864 t.Fatalf("change_shopping_list destructive hint = %#v, want true", tool.Annotations.DestructiveHint)
865 }
866 if tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint {
867 t.Fatalf("change_shopping_list open-world hint = %#v, want true", tool.Annotations.OpenWorldHint)
868 }
869 if tool.Annotations.Title == "" {
870 t.Fatal("change_shopping_list annotation title empty")
871 }
872 if !strings.Contains(tool.Description, "clear") || !strings.Contains(tool.Description, "destructive") {
873 t.Fatalf("change_shopping_list description = %q, want clear/destructive warning", tool.Description)
874 }
875}
876
877type fakeBackend struct {
878 shoppingList cooked.ShoppingList
879 recipes []cooked.RecipeCard
880 searchRecipes []cooked.RecipeCard
881 recipeMetadata cooked.RecipeMetadata
882 recipeContent cooked.RecipeContent
883 preview cooked.RecipeTextPreview
884 saveRecipeID string
885 page int
886 limit int
887 searchQuery string
888 searchPage int
889 metadataRecipeID string
890 contentRecipeID string
891 previewTitle string
892 previewText string
893 saveTitle string
894 saveMarkdown string
895 savePortions int
896 updateRecipeID string
897 updateMarkdown string
898 updatePortions int
899 deleteRecipeID string
900 addShoppingListResult cooked.AddShoppingListResult
901 addIngredients string
902 addRecipeID string
903 removeProductGroupIDs []string
904 replaceSelectionProductGroupIDs []string
905 metadataCalls int
906 readShoppingListCalls int
907 contentCalls int
908 previewCalls int
909 saveCalls int
910 updateCalls int
911 deleteCalls int
912 clearShoppingListCalls int
913 addShoppingListCalls int
914 removeShoppingListCalls int
915 replaceSelectionCalls int
916}
917
918func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
919 f.readShoppingListCalls++
920
921 return f.shoppingList, nil
922}
923
924func (f *fakeBackend) ListRecipes(_ context.Context, page, limit int) ([]cooked.RecipeCard, error) {
925 f.page = page
926 f.limit = limit
927
928 return f.recipes, nil
929}
930
931func (f *fakeBackend) SearchRecipes(_ context.Context, query string, page int) ([]cooked.RecipeCard, error) {
932 f.searchQuery = query
933 f.searchPage = page
934
935 return f.searchRecipes, nil
936}
937
938func (f *fakeBackend) ReadRecipeMetadata(_ context.Context, recipeID string) (cooked.RecipeMetadata, error) {
939 f.metadataRecipeID = recipeID
940 f.metadataCalls++
941
942 return f.recipeMetadata, nil
943}
944
945func (f *fakeBackend) ReadRecipeContent(_ context.Context, recipeID string) (cooked.RecipeContent, error) {
946 f.contentRecipeID = recipeID
947 f.contentCalls++
948
949 return f.recipeContent, nil
950}
951
952func (f *fakeBackend) PreviewRecipeText(_ context.Context, title, text string) (cooked.RecipeTextPreview, error) {
953 f.previewTitle = title
954 f.previewText = text
955 f.previewCalls++
956
957 return f.preview, nil
958}
959
960func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown string, portions int) (string, error) {
961 f.saveTitle = title
962 f.saveMarkdown = markdown
963 f.savePortions = portions
964 f.saveCalls++
965 if f.saveRecipeID != "" {
966 return f.saveRecipeID, nil
967 }
968
969 return "recipe-1", nil
970}
971
972func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown string, portions int) error {
973 f.updateRecipeID = recipeID
974 f.updateMarkdown = markdown
975 f.updatePortions = portions
976 f.updateCalls++
977
978 return nil
979}
980
981func (f *fakeBackend) DeleteRecipe(_ context.Context, recipeID string) error {
982 f.deleteRecipeID = recipeID
983 f.deleteCalls++
984
985 return nil
986}
987
988func (f *fakeBackend) ClearShoppingList(context.Context) error {
989 f.clearShoppingListCalls++
990
991 return nil
992}
993
994func (f *fakeBackend) AddShoppingListIngredients(
995 _ context.Context,
996 ingredients, recipeID string,
997) (cooked.AddShoppingListResult, error) {
998 f.addIngredients = ingredients
999 f.addRecipeID = recipeID
1000 f.addShoppingListCalls++
1001
1002 return f.addShoppingListResult, nil
1003}
1004
1005func (f *fakeBackend) RemoveShoppingListProductGroups(_ context.Context, ids []string) error {
1006 f.removeProductGroupIDs = ids
1007 f.removeShoppingListCalls++
1008
1009 return nil
1010}
1011
1012func (f *fakeBackend) ReplaceShoppingListSelection(_ context.Context, ids []string) error {
1013 f.replaceSelectionProductGroupIDs = ids
1014 f.replaceSelectionCalls++
1015
1016 return nil
1017}