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