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