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