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