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: "unsupported"})
 368	if err == nil {
 369		t.Fatal("callSaveRecipeTool() error = nil, want unsupported source error")
 370	}
 371	if backend.saveCalls != 0 || backend.draftSaveCalls != 0 {
 372		t.Fatalf("save calls = prepared %d draft %d, want none", backend.saveCalls, backend.draftSaveCalls)
 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	importRecipeURLResult            cooked.RecipeURLImport
 954	saveRecipeID                     string
 955	draftSaveRecipeID                string
 956	page                             int
 957	limit                            int
 958	searchQuery                      string
 959	searchPage                       int
 960	metadataRecipeID                 string
 961	contentRecipeID                  string
 962	previewTitle                     string
 963	previewText                      string
 964	importURL                        string
 965	saveTitle                        string
 966	saveMarkdown                     string
 967	savePortions                     int
 968	draftSaveID                      string
 969	draftSaveMarkdown                string
 970	draftSavePortions                int
 971	updateRecipeID                   string
 972	updateMarkdown                   string
 973	updatePortions                   int
 974	deleteRecipeID                   string
 975	addShoppingListResult            cooked.AddShoppingListResult
 976	addIngredients                   string
 977	addRecipeID                      string
 978	removeProductGroupIDs            []string
 979	replaceSelectionProductGroupIDs  []string
 980	updateShoppingListProductGroupID string
 981	updateShoppingListProductGroup   cooked.ShoppingListProductGroupUpdate
 982	metadataCalls                    int
 983	readShoppingListCalls            int
 984	contentCalls                     int
 985	previewCalls                     int
 986	importRecipeURLCalls             int
 987	saveCalls                        int
 988	draftSaveCalls                   int
 989	updateCalls                      int
 990	deleteCalls                      int
 991	clearShoppingListCalls           int
 992	addShoppingListCalls             int
 993	removeShoppingListCalls          int
 994	replaceSelectionCalls            int
 995	updateShoppingListCalls          int
 996}
 997
 998func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
 999	f.readShoppingListCalls++
1000
1001	return f.shoppingList, nil
1002}
1003
1004func (f *fakeBackend) ListRecipes(_ context.Context, page, limit int) ([]cooked.RecipeCard, error) {
1005	f.page = page
1006	f.limit = limit
1007
1008	return f.recipes, nil
1009}
1010
1011func (f *fakeBackend) SearchRecipes(_ context.Context, query string, page int) ([]cooked.RecipeCard, error) {
1012	f.searchQuery = query
1013	f.searchPage = page
1014
1015	return f.searchRecipes, nil
1016}
1017
1018func (f *fakeBackend) ReadRecipeMetadata(_ context.Context, recipeID string) (cooked.RecipeMetadata, error) {
1019	f.metadataRecipeID = recipeID
1020	f.metadataCalls++
1021
1022	return f.recipeMetadata, nil
1023}
1024
1025func (f *fakeBackend) ReadRecipeContent(_ context.Context, recipeID string) (cooked.RecipeContent, error) {
1026	f.contentRecipeID = recipeID
1027	f.contentCalls++
1028
1029	return f.recipeContent, nil
1030}
1031
1032func (f *fakeBackend) PreviewRecipeText(_ context.Context, title, text string) (cooked.RecipeTextPreview, error) {
1033	f.previewTitle = title
1034	f.previewText = text
1035	f.previewCalls++
1036
1037	return f.preview, nil
1038}
1039
1040func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown string, portions int) (string, error) {
1041	f.saveTitle = title
1042	f.saveMarkdown = markdown
1043	f.savePortions = portions
1044	f.saveCalls++
1045	if f.saveRecipeID != "" {
1046		return f.saveRecipeID, nil
1047	}
1048
1049	return "recipe-1", nil
1050}
1051
1052func (f *fakeBackend) SaveRecipeDraft(_ context.Context, draftID, markdown string, portions int) (string, error) {
1053	f.draftSaveID = draftID
1054	f.draftSaveMarkdown = markdown
1055	f.draftSavePortions = portions
1056	f.draftSaveCalls++
1057	if f.draftSaveRecipeID != "" {
1058		return f.draftSaveRecipeID, nil
1059	}
1060
1061	return "recipe-1", nil
1062}
1063
1064func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown string, portions int) error {
1065	f.updateRecipeID = recipeID
1066	f.updateMarkdown = markdown
1067	f.updatePortions = portions
1068	f.updateCalls++
1069
1070	return nil
1071}
1072
1073func (f *fakeBackend) ImportRecipeURL(_ context.Context, recipeURL string) (cooked.RecipeURLImport, error) {
1074	f.importURL = recipeURL
1075	f.importRecipeURLCalls++
1076
1077	return f.importRecipeURLResult, nil
1078}
1079
1080func (f *fakeBackend) DeleteRecipe(_ context.Context, recipeID string) error {
1081	f.deleteRecipeID = recipeID
1082	f.deleteCalls++
1083
1084	return nil
1085}
1086
1087func (f *fakeBackend) ClearShoppingList(context.Context) error {
1088	f.clearShoppingListCalls++
1089
1090	return nil
1091}
1092
1093func (f *fakeBackend) AddShoppingListIngredients(
1094	_ context.Context,
1095	ingredients, recipeID string,
1096) (cooked.AddShoppingListResult, error) {
1097	f.addIngredients = ingredients
1098	f.addRecipeID = recipeID
1099	f.addShoppingListCalls++
1100
1101	return f.addShoppingListResult, nil
1102}
1103
1104func (f *fakeBackend) RemoveShoppingListProductGroups(_ context.Context, ids []string) error {
1105	f.removeProductGroupIDs = ids
1106	f.removeShoppingListCalls++
1107
1108	return nil
1109}
1110
1111func (f *fakeBackend) ReplaceShoppingListSelection(_ context.Context, ids []string) error {
1112	f.replaceSelectionProductGroupIDs = ids
1113	f.replaceSelectionCalls++
1114
1115	return nil
1116}
1117
1118func (f *fakeBackend) UpdateShoppingListProductGroup(
1119	_ context.Context,
1120	productGroupID string,
1121	update cooked.ShoppingListProductGroupUpdate,
1122) error {
1123	f.updateShoppingListProductGroupID = productGroupID
1124	f.updateShoppingListProductGroup = update
1125	f.updateShoppingListCalls++
1126
1127	return nil
1128}