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