server_test.go

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