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