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	"strings"
 11	"testing"
 12
 13	sdk "github.com/modelcontextprotocol/go-sdk/mcp"
 14
 15	"git.secluded.site/cooked-mcp/internal/cooked"
 16)
 17
 18func TestCallToolACIDToolsSurface1ReadsShoppingList(t *testing.T) {
 19	backend := &fakeBackend{shoppingList: cooked.ShoppingList{
 20		Aisles: []cooked.Aisle{{
 21			ID:   "pantry",
 22			Name: "Pantry",
 23			ProductGroups: []cooked.ProductGroup{{
 24				ID:       "pasta",
 25				Name:     "Pasta",
 26				Quantity: "200g",
 27			}},
 28		}},
 29	}}
 30	server := NewServer(backend, "test")
 31
 32	output, err := server.CallReadTool(context.Background(), ReadArguments{Target: "shopping_list"})
 33	if err != nil {
 34		t.Fatalf("CallReadTool() error = %v", err)
 35	}
 36
 37	if len(output.Aisles) != 1 {
 38		t.Fatalf("structured aisles = %d, want 1", len(output.Aisles))
 39	}
 40	if output.Aisles[0].ProductGroups[0].Name != "Pasta" {
 41		t.Fatalf("first item = %q, want Pasta", output.Aisles[0].ProductGroups[0].Name)
 42	}
 43}
 44
 45func TestCallToolACIDRecipesRead1ListsRecipes(t *testing.T) {
 46	backend := &fakeBackend{
 47		recipes: []cooked.RecipeCard{
 48			{ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
 49		},
 50	}
 51	server := NewServer(backend, "test")
 52
 53	output, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Page: 2, Limit: 5})
 54	if err != nil {
 55		t.Fatalf("CallReadTool() error = %v", err)
 56	}
 57
 58	if backend.page != 2 {
 59		t.Fatalf("backend page = %d, want 2", backend.page)
 60	}
 61	if backend.limit != 5 {
 62		t.Fatalf("backend limit = %d, want 5", backend.limit)
 63	}
 64	if len(output.Recipes) != 1 {
 65		t.Fatalf("structured recipes = %d, want 1", len(output.Recipes))
 66	}
 67	if output.Recipes[0].ID != "recipe-1" || output.Recipes[0].Title != "Pasta" {
 68		t.Fatalf("first recipe = %#v, want recipe-1 Pasta", output.Recipes[0])
 69	}
 70}
 71
 72func TestCallToolACIDRecipesPagination4DefaultsAndCapsRecipeLimit(t *testing.T) {
 73	backend := &fakeBackend{}
 74	server := NewServer(backend, "test")
 75
 76	_, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipes", Limit: 99})
 77	if err != nil {
 78		t.Fatalf("CallReadTool() error = %v", err)
 79	}
 80
 81	if backend.page != 1 {
 82		t.Fatalf("backend page = %d, want default page 1", backend.page)
 83	}
 84	if backend.limit != 30 {
 85		t.Fatalf("backend limit = %d, want capped limit 30", backend.limit)
 86	}
 87}
 88
 89func TestCallToolACIDRecipesRead2SearchesRecipes(t *testing.T) {
 90	backend := &fakeBackend{searchRecipes: []cooked.RecipeCard{
 91		{ID: "recipe-1", Title: "Pasta", ThumbnailURL: "https://example.invalid/thumb.jpg"},
 92		{ID: "recipe-2", Title: "Tomato Pasta", ThumbnailURL: "https://example.invalid/thumb2.jpg"},
 93	}}
 94	server := NewServer(backend, "test")
 95
 96	output, err := server.CallReadTool(context.Background(), ReadArguments{
 97		Target: "recipes",
 98		Query:  " pasta ",
 99		Page:   2,
100		Limit:  1,
101	})
102	if err != nil {
103		t.Fatalf("CallReadTool() error = %v", err)
104	}
105
106	if backend.searchQuery != "pasta" {
107		t.Fatalf("backend search query = %q, want pasta", backend.searchQuery)
108	}
109	if backend.searchPage != 2 {
110		t.Fatalf("backend search page = %d, want 2", backend.searchPage)
111	}
112	if len(output.Recipes) != 1 {
113		t.Fatalf("structured recipes = %d, want truncated result length 1", len(output.Recipes))
114	}
115	if output.Recipes[0].ID != "recipe-1" || output.Recipes[0].Title != "Pasta" {
116		t.Fatalf("first recipe = %#v, want recipe-1 Pasta", output.Recipes[0])
117	}
118}
119
120func TestCallToolACIDRecipesRead5ReadsSingleRecipe(t *testing.T) {
121	backend := &fakeBackend{
122		recipeMetadata: cooked.RecipeMetadata{
123			Title:          "Pasta",
124			ImageURLs:      []string{"https://example.invalid/pasta.jpg"},
125			Owner:          "returned-user",
126			EditPermission: true,
127		},
128		recipeContent: cooked.RecipeContent{
129			Content:  "# Pasta\n\n- 200g pasta\n\n1. Boil pasta.",
130			Portions: 2,
131		},
132	}
133	server := NewServer(backend, "test")
134
135	result, output, err := server.callReadTool(
136		context.Background(),
137		nil,
138		ReadArguments{Target: "recipe", RecipeID: "recipe-1"},
139	)
140	if err != nil {
141		t.Fatalf("callReadTool() error = %v", err)
142	}
143
144	requireSingleRecipeBackendCalls(t, backend)
145	requireSingleRecipeOutput(t, output)
146	requireNoImageLeak(t, result, output)
147}
148
149func requireSingleRecipeBackendCalls(t *testing.T, backend *fakeBackend) {
150	t.Helper()
151
152	if backend.metadataRecipeID != "recipe-1" || backend.contentRecipeID != "recipe-1" {
153		t.Fatalf(
154			"backend recipe IDs = metadata %q content %q, want recipe-1",
155			backend.metadataRecipeID,
156			backend.contentRecipeID,
157		)
158	}
159	if backend.metadataCalls != 1 || backend.contentCalls != 1 {
160		t.Fatalf("backend calls = metadata %d content %d, want 1/1", backend.metadataCalls, backend.contentCalls)
161	}
162}
163
164func requireSingleRecipeOutput(t *testing.T, output ReadOutput) {
165	t.Helper()
166
167	if output.Recipe == nil {
168		t.Fatal("structured recipe is nil")
169	}
170	if output.Recipe.ID != "recipe-1" || output.Recipe.Title != "Pasta" {
171		t.Fatalf("structured recipe identity = %#v, want recipe-1 Pasta", output.Recipe)
172	}
173	if output.Recipe.Owner != "returned-user" || !output.Recipe.EditPermission {
174		t.Fatalf(
175			"structured recipe owner/edit = %q/%v, want returned-user/true",
176			output.Recipe.Owner,
177			output.Recipe.EditPermission,
178		)
179	}
180	if output.Recipe.Content != "# Pasta\n\n- 200g pasta\n\n1. Boil pasta." || output.Recipe.Portions != 2 {
181		t.Fatalf(
182			"structured recipe content/portions = %q/%d, want markdown/2",
183			output.Recipe.Content,
184			output.Recipe.Portions,
185		)
186	}
187}
188
189func requireNoImageLeak(t *testing.T, result *sdk.CallToolResult, output ReadOutput) {
190	t.Helper()
191
192	encodedOutput, err := json.Marshal(output)
193	if err != nil {
194		t.Fatalf("marshal output: %v", err)
195	}
196	if strings.Contains(string(encodedOutput), "image") ||
197		strings.Contains(string(encodedOutput), "https://example.invalid/pasta.jpg") {
198		t.Fatalf("structured output leaked image data: %s", encodedOutput)
199	}
200	if len(result.Content) != 1 {
201		t.Fatalf("text content length = %d, want 1", len(result.Content))
202	}
203	textContent, ok := result.Content[0].(*sdk.TextContent)
204	if !ok {
205		t.Fatalf("content type = %T, want *sdk.TextContent", result.Content[0])
206	}
207	if strings.Contains(textContent.Text, "https://example.invalid/pasta.jpg") {
208		t.Fatalf("text output leaked image URL: %s", textContent.Text)
209	}
210}
211
212func TestCallToolACIDToolsReadTool3RequiresRecipeIDForRecipeTarget(t *testing.T) {
213	backend := &fakeBackend{}
214	server := NewServer(backend, "test")
215
216	_, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipe", RecipeID: "   "})
217	if err == nil {
218		t.Fatal("CallReadTool() error = nil, want missing recipe_id error")
219	}
220
221	if backend.metadataCalls != 0 || backend.contentCalls != 0 {
222		t.Fatalf("backend calls = metadata %d content %d, want none", backend.metadataCalls, backend.contentCalls)
223	}
224}
225
226func TestCallToolACIDRecipesPreviewText1PreviewsRawText(t *testing.T) {
227	backend := &fakeBackend{preview: cooked.RecipeTextPreview{
228		Title:    "Pasta with Tomato Sauce",
229		Markdown: "# Pasta\n\n1. Boil pasta.",
230		Portions: 2,
231	}}
232	server := NewServer(backend, "test")
233
234	_, output, err := server.callPreviewRecipeTextTool(
235		context.Background(),
236		nil,
237		PreviewRecipeTextArguments{Title: " Pasta ", Text: "  Boil pasta.\n"},
238	)
239	if err != nil {
240		t.Fatalf("callPreviewRecipeTextTool() error = %v", err)
241	}
242
243	if backend.previewCalls != 1 {
244		t.Fatalf("preview calls = %d, want 1", backend.previewCalls)
245	}
246	if backend.previewTitle != "Pasta" {
247		t.Fatalf("backend preview title = %q, want trimmed Pasta", backend.previewTitle)
248	}
249	if backend.previewText != "  Boil pasta.\n" {
250		t.Fatalf("backend preview text = %q, want raw text preserved", backend.previewText)
251	}
252	if output.Title != "Pasta with Tomato Sauce" || output.Markdown != "# Pasta\n\n1. Boil pasta." ||
253		output.Portions != 2 {
254		t.Fatalf("preview output = %#v, want title/markdown/portions", output)
255	}
256}
257
258func TestCallToolACIDToolsPreviewRecipeTextTool1RequiresTitle(t *testing.T) {
259	backend := &fakeBackend{}
260	server := NewServer(backend, "test")
261
262	_, _, err := server.callPreviewRecipeTextTool(
263		context.Background(),
264		nil,
265		PreviewRecipeTextArguments{Title: "   ", Text: "Boil pasta."},
266	)
267	if err == nil {
268		t.Fatal("callPreviewRecipeTextTool() error = nil, want missing title error")
269	}
270	if backend.previewCalls != 0 {
271		t.Fatalf("preview calls = %d, want none", backend.previewCalls)
272	}
273}
274
275func TestCallToolACIDToolsPreviewRecipeTextTool2RequiresText(t *testing.T) {
276	backend := &fakeBackend{}
277	server := NewServer(backend, "test")
278
279	_, _, err := server.callPreviewRecipeTextTool(
280		context.Background(),
281		nil,
282		PreviewRecipeTextArguments{Title: "Pasta", Text: "   "},
283	)
284	if err == nil {
285		t.Fatal("callPreviewRecipeTextTool() error = nil, want missing text error")
286	}
287	if backend.previewCalls != 0 {
288		t.Fatalf("preview calls = %d, want none", backend.previewCalls)
289	}
290}
291
292func TestCallToolACIDRecipesSave2And9SavesPreparedRecipe(t *testing.T) {
293	backend := &fakeBackend{saveRecipeID: "recipe-1"}
294	server := NewServer(backend, "test")
295
296	_, 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,
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 {
320		t.Fatalf("backend save portions = %d, want 2", backend.savePortions)
321	}
322	if output.RecipeID != "recipe-1" {
323		t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
324	}
325}
326
327func TestCallToolACIDRecipesSave2_1To2_3RequiresPreparedFields(t *testing.T) {
328	tests := []struct {
329		name      string
330		arguments SaveRecipeArguments
331	}{
332		{
333			name:      "title",
334			arguments: SaveRecipeArguments{Source: "prepared", Title: "   ", Markdown: "# Pasta", Portions: 2},
335		},
336		{
337			name:      "markdown",
338			arguments: SaveRecipeArguments{Source: "prepared", Title: "Pasta", Markdown: "   ", Portions: 2},
339		},
340		{
341			name:      "portions",
342			arguments: SaveRecipeArguments{Source: "prepared", Title: "Pasta", Markdown: "# Pasta", Portions: 0},
343		},
344	}
345
346	for _, tt := range tests {
347		t.Run(tt.name, func(t *testing.T) {
348			backend := &fakeBackend{}
349			server := NewServer(backend, "test")
350
351			_, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
352			if err == nil {
353				t.Fatal("callSaveRecipeTool() error = nil, want missing prepared field error")
354			}
355			if backend.saveCalls != 0 {
356				t.Fatalf("save calls = %d, want none", backend.saveCalls)
357			}
358		})
359	}
360}
361
362func TestCallToolACIDRecipesSave10RejectsUnsupportedSourceBeforeCallingCooked(t *testing.T) {
363	backend := &fakeBackend{}
364	server := NewServer(backend, "test")
365
366	_, _, err := server.callSaveRecipeTool(context.Background(), nil, SaveRecipeArguments{Source: "url"})
367	if err == nil {
368		t.Fatal("callSaveRecipeTool() error = nil, want unsupported source error")
369	}
370	if backend.saveCalls != 0 {
371		t.Fatalf("save calls = %d, want none", backend.saveCalls)
372	}
373}
374
375func TestCallToolACIDRecipesSave1And11SavesRawTextRecipe(t *testing.T) {
376	backend := &fakeBackend{
377		preview: cooked.RecipeTextPreview{
378			Title:    "Pasta with Tomato Sauce",
379			Markdown: "# Pasta\n\n1. Boil pasta.",
380			Portions: 2,
381		},
382		saveRecipeID: "recipe-1",
383	}
384	server := NewServer(backend, "test")
385
386	_, output, err := server.callSaveRecipeTool(
387		context.Background(),
388		nil,
389		SaveRecipeArguments{Source: " raw_text ", Title: " Pasta ", Text: "  Boil pasta.\n"},
390	)
391	if err != nil {
392		t.Fatalf("callSaveRecipeTool() error = %v", err)
393	}
394
395	if backend.previewCalls != 1 || backend.saveCalls != 1 {
396		t.Fatalf("backend calls = preview %d save %d, want 1/1", backend.previewCalls, backend.saveCalls)
397	}
398	if backend.previewTitle != "Pasta" {
399		t.Fatalf("preview title = %q, want trimmed Pasta", backend.previewTitle)
400	}
401	if backend.previewText != "  Boil pasta.\n" {
402		t.Fatalf("preview text = %q, want raw text preserved", backend.previewText)
403	}
404	if backend.saveTitle != "Pasta with Tomato Sauce" {
405		t.Fatalf("save title = %q, want preview title", backend.saveTitle)
406	}
407	if backend.saveMarkdown != "# Pasta\n\n1. Boil pasta." {
408		t.Fatalf("save markdown = %q, want preview markdown", backend.saveMarkdown)
409	}
410	if backend.savePortions != 2 {
411		t.Fatalf("save portions = %d, want preview portions 2", backend.savePortions)
412	}
413	if output.RecipeID != "recipe-1" {
414		t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
415	}
416}
417
418func TestCallToolACIDRecipesSave1_1To1_2RequiresRawTextFields(t *testing.T) {
419	tests := []struct {
420		name      string
421		arguments SaveRecipeArguments
422	}{
423		{name: "title", arguments: SaveRecipeArguments{Source: "raw_text", Title: "   ", Text: "Boil pasta."}},
424		{name: "text", arguments: SaveRecipeArguments{Source: "raw_text", Title: "Pasta", Text: "   "}},
425	}
426
427	for _, tt := range tests {
428		t.Run(tt.name, func(t *testing.T) {
429			backend := &fakeBackend{}
430			server := NewServer(backend, "test")
431
432			_, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
433			if err == nil {
434				t.Fatal("callSaveRecipeTool() error = nil, want missing raw text field error")
435			}
436			if backend.previewCalls != 0 || backend.saveCalls != 0 {
437				t.Fatalf("backend calls = preview %d save %d, want none", backend.previewCalls, backend.saveCalls)
438			}
439		})
440	}
441}
442
443func TestCallToolACIDRecipesSave8And9UpdatesExistingRecipe(t *testing.T) {
444	backend := &fakeBackend{}
445	server := NewServer(backend, "test")
446
447	_, output, err := server.callSaveRecipeTool(
448		context.Background(),
449		nil,
450		SaveRecipeArguments{
451			Source:   " existing ",
452			RecipeID: " recipe-1 ",
453			Markdown: "  # Pasta\n\n1. Boil pasta.\n",
454			Portions: 4,
455		},
456	)
457	if err != nil {
458		t.Fatalf("callSaveRecipeTool() error = %v", err)
459	}
460
461	if backend.updateCalls != 1 {
462		t.Fatalf("update calls = %d, want 1", backend.updateCalls)
463	}
464	if backend.updateRecipeID != "recipe-1" {
465		t.Fatalf("backend update recipe ID = %q, want recipe-1", backend.updateRecipeID)
466	}
467	if backend.updateMarkdown != "  # Pasta\n\n1. Boil pasta.\n" {
468		t.Fatalf("backend update markdown = %q, want raw markdown preserved", backend.updateMarkdown)
469	}
470	if backend.updatePortions != 4 {
471		t.Fatalf("backend update portions = %d, want 4", backend.updatePortions)
472	}
473	if output.RecipeID != "recipe-1" {
474		t.Fatalf("save output recipe ID = %q, want recipe-1", output.RecipeID)
475	}
476}
477
478func TestCallToolACIDRecipesSave8_1To8_3RequiresExistingFields(t *testing.T) {
479	tests := []struct {
480		name      string
481		arguments SaveRecipeArguments
482	}{
483		{
484			name:      "recipe ID",
485			arguments: SaveRecipeArguments{Source: "existing", RecipeID: "   ", Markdown: "# Pasta", Portions: 4},
486		},
487		{
488			name:      "markdown",
489			arguments: SaveRecipeArguments{Source: "existing", RecipeID: "recipe-1", Markdown: "   ", Portions: 4},
490		},
491		{
492			name:      "portions",
493			arguments: SaveRecipeArguments{Source: "existing", RecipeID: "recipe-1", Markdown: "# Pasta", Portions: 0},
494		},
495	}
496
497	for _, tt := range tests {
498		t.Run(tt.name, func(t *testing.T) {
499			backend := &fakeBackend{}
500			server := NewServer(backend, "test")
501
502			_, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
503			if err == nil {
504				t.Fatal("callSaveRecipeTool() error = nil, want missing existing field error")
505			}
506			if backend.updateCalls != 0 {
507				t.Fatalf("update calls = %d, want none", backend.updateCalls)
508			}
509		})
510	}
511}
512
513func TestCallToolACIDRecipesDelete1And2DeletesRecipe(t *testing.T) {
514	backend := &fakeBackend{}
515	server := NewServer(backend, "test")
516
517	_, output, err := server.callDeleteRecipeTool(
518		context.Background(),
519		nil,
520		DeleteRecipeArguments{RecipeID: " recipe-1 "},
521	)
522	if err != nil {
523		t.Fatalf("callDeleteRecipeTool() error = %v", err)
524	}
525
526	if backend.deleteCalls != 1 {
527		t.Fatalf("delete calls = %d, want 1", backend.deleteCalls)
528	}
529	if backend.deleteRecipeID != "recipe-1" {
530		t.Fatalf("backend delete recipe ID = %q, want recipe-1", backend.deleteRecipeID)
531	}
532	if output.RecipeID != "recipe-1" {
533		t.Fatalf("delete output recipe ID = %q, want recipe-1", output.RecipeID)
534	}
535}
536
537func TestCallToolACIDToolsDeleteRecipeTool1RequiresRecipeID(t *testing.T) {
538	backend := &fakeBackend{}
539	server := NewServer(backend, "test")
540
541	_, _, err := server.callDeleteRecipeTool(context.Background(), nil, DeleteRecipeArguments{RecipeID: "   "})
542	if err == nil {
543		t.Fatal("callDeleteRecipeTool() error = nil, want missing recipe_id error")
544	}
545	if backend.deleteCalls != 0 {
546		t.Fatalf("delete calls = %d, want none", backend.deleteCalls)
547	}
548}
549
550func TestDeleteRecipeToolACIDToolsAnnotations2To5MarksDestructiveOpenWorldTool(t *testing.T) {
551	tool := deleteRecipeTool()
552	if tool.Name != deleteRecipeToolName {
553		t.Fatalf("tool name = %q, want %q", tool.Name, deleteRecipeToolName)
554	}
555	if tool.Annotations == nil {
556		t.Fatal("tool annotations nil")
557	}
558	if tool.Annotations.ReadOnlyHint {
559		t.Fatal("delete_recipe read-only hint = true, want false")
560	}
561	if tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint {
562		t.Fatalf("delete_recipe destructive hint = %#v, want true", tool.Annotations.DestructiveHint)
563	}
564	if tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint {
565		t.Fatalf("delete_recipe open-world hint = %#v, want true", tool.Annotations.OpenWorldHint)
566	}
567	if tool.Annotations.Title == "" {
568		t.Fatal("delete_recipe annotation title empty")
569	}
570}
571
572func TestCallToolACIDShoppingListClear1And2ClearsShoppingList(t *testing.T) {
573	backend := &fakeBackend{}
574	server := NewServer(backend, "test")
575
576	_, output, err := server.callChangeShoppingListTool(
577		context.Background(),
578		nil,
579		ChangeShoppingListArguments{Action: " clear "},
580	)
581	if err != nil {
582		t.Fatalf("callChangeShoppingListTool() error = %v", err)
583	}
584
585	if backend.clearShoppingListCalls != 1 {
586		t.Fatalf("clear calls = %d, want 1", backend.clearShoppingListCalls)
587	}
588	if !output.Cleared {
589		t.Fatalf("clear output cleared = %v, want true", output.Cleared)
590	}
591}
592
593func TestCallToolACIDShoppingListActions2RejectsUnknownActionBeforeCallingCooked(t *testing.T) {
594	tests := []struct {
595		name      string
596		arguments ChangeShoppingListArguments
597	}{
598		{name: "empty", arguments: ChangeShoppingListArguments{Action: "   "}},
599		{name: "unknown", arguments: ChangeShoppingListArguments{Action: "dance"}},
600	}
601
602	for _, tt := range tests {
603		t.Run(tt.name, func(t *testing.T) {
604			backend := &fakeBackend{}
605			server := NewServer(backend, "test")
606
607			_, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
608			if err == nil {
609				t.Fatal("callChangeShoppingListTool() error = nil, want action error")
610			}
611			if backend.clearShoppingListCalls != 0 {
612				t.Fatalf("clear calls = %d, want none", backend.clearShoppingListCalls)
613			}
614		})
615	}
616}
617
618func TestCallToolACIDShoppingListAdd1To4AddsIngredients(t *testing.T) {
619	backend := &fakeBackend{addShoppingListResult: cooked.AddShoppingListResult{
620		AddedCount:  2,
621		Ingredients: []string{"200g pasta", "1 cup tomato sauce"},
622	}}
623	server := NewServer(backend, "test")
624
625	_, output, err := server.callChangeShoppingListTool(
626		context.Background(),
627		nil,
628		ChangeShoppingListArguments{
629			Action:      " add ",
630			Ingredients: "200g pasta\n1 cup tomato sauce",
631			RecipeID:    " recipe-1 ",
632		},
633	)
634	if err != nil {
635		t.Fatalf("callChangeShoppingListTool() error = %v", err)
636	}
637
638	if backend.addShoppingListCalls != 1 {
639		t.Fatalf("add calls = %d, want 1", backend.addShoppingListCalls)
640	}
641	if backend.addIngredients != "200g pasta\n1 cup tomato sauce" {
642		t.Fatalf("backend ingredients = %q, want raw multiline ingredients", backend.addIngredients)
643	}
644	if backend.addRecipeID != "recipe-1" {
645		t.Fatalf("backend recipe ID = %q, want recipe-1", backend.addRecipeID)
646	}
647	if output.AddedCount != 2 {
648		t.Fatalf("output added count = %d, want 2", output.AddedCount)
649	}
650	if len(output.Ingredients) != 2 || output.Ingredients[0] != "200g pasta" {
651		t.Fatalf("output ingredients = %#v, want added ingredients", output.Ingredients)
652	}
653}
654
655func TestCallToolACIDToolsChangeShoppingListTool2RequiresIngredients(t *testing.T) {
656	backend := &fakeBackend{}
657	server := NewServer(backend, "test")
658
659	_, _, err := server.callChangeShoppingListTool(
660		context.Background(),
661		nil,
662		ChangeShoppingListArguments{Action: "add", Ingredients: "   "},
663	)
664	if err == nil {
665		t.Fatal("callChangeShoppingListTool() error = nil, want missing ingredients error")
666	}
667	if backend.addShoppingListCalls != 0 {
668		t.Fatalf("add calls = %d, want none", backend.addShoppingListCalls)
669	}
670}
671
672func TestCallToolACIDShoppingListRemove1And2RemovesProductGroups(t *testing.T) {
673	backend := &fakeBackend{}
674	server := NewServer(backend, "test")
675
676	_, output, err := server.callChangeShoppingListTool(
677		context.Background(),
678		nil,
679		ChangeShoppingListArguments{
680			Action:          " remove ",
681			ProductGroupIDs: []string{" pasta ", "tomato", "   "},
682		},
683	)
684	if err != nil {
685		t.Fatalf("callChangeShoppingListTool() error = %v", err)
686	}
687
688	if backend.removeShoppingListCalls != 1 {
689		t.Fatalf("remove calls = %d, want 1", backend.removeShoppingListCalls)
690	}
691	if len(backend.removeProductGroupIDs) != 2 || backend.removeProductGroupIDs[0] != "pasta" ||
692		backend.removeProductGroupIDs[1] != "tomato" {
693		t.Fatalf("backend remove IDs = %#v, want pasta and tomato", backend.removeProductGroupIDs)
694	}
695	if len(output.RemovedProductGroupIDs) != 2 || output.RemovedProductGroupIDs[0] != "pasta" ||
696		output.RemovedProductGroupIDs[1] != "tomato" {
697		t.Fatalf("output removed IDs = %#v, want pasta and tomato", output.RemovedProductGroupIDs)
698	}
699}
700
701func TestCallToolACIDShoppingListSelection1And5And6And8ReplacesSelectedSet(t *testing.T) {
702	backend := &fakeBackend{}
703	server := NewServer(backend, "test")
704
705	_, output, err := server.callChangeShoppingListTool(
706		context.Background(),
707		nil,
708		ChangeShoppingListArguments{
709			Action:          " replace_selection ",
710			ProductGroupIDs: []string{" pasta ", "tomato", "   "},
711		},
712	)
713	if err != nil {
714		t.Fatalf("callChangeShoppingListTool() error = %v", err)
715	}
716
717	if backend.replaceSelectionCalls != 1 {
718		t.Fatalf("replace selection calls = %d, want 1", backend.replaceSelectionCalls)
719	}
720	if len(backend.replaceSelectionProductGroupIDs) != 2 || backend.replaceSelectionProductGroupIDs[0] != "pasta" ||
721		backend.replaceSelectionProductGroupIDs[1] != "tomato" {
722		t.Fatalf("backend selected IDs = %#v, want pasta and tomato", backend.replaceSelectionProductGroupIDs)
723	}
724	if len(output.SelectedProductGroupIDs) != 2 || output.SelectedProductGroupIDs[0] != "pasta" ||
725		output.SelectedProductGroupIDs[1] != "tomato" {
726		t.Fatalf("output selected IDs = %#v, want pasta and tomato", output.SelectedProductGroupIDs)
727	}
728}
729
730func TestCallToolACIDShoppingListSelection7RequiresReplaceSelectionProductGroupIDs(t *testing.T) {
731	tests := []struct {
732		name      string
733		arguments ChangeShoppingListArguments
734	}{
735		{name: "nil", arguments: ChangeShoppingListArguments{Action: "replace_selection"}},
736		{
737			name:      "blank",
738			arguments: ChangeShoppingListArguments{Action: "replace_selection", ProductGroupIDs: []string{"  "}},
739		},
740	}
741
742	for _, tt := range tests {
743		t.Run(tt.name, func(t *testing.T) {
744			backend := &fakeBackend{}
745			server := NewServer(backend, "test")
746
747			_, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
748			if err == nil {
749				t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
750			}
751			if backend.replaceSelectionCalls != 0 {
752				t.Fatalf("replace selection calls = %d, want none", backend.replaceSelectionCalls)
753			}
754		})
755	}
756}
757
758func TestCallToolACIDShoppingListSafety1RequiresRemoveProductGroupIDs(t *testing.T) {
759	tests := []struct {
760		name      string
761		arguments ChangeShoppingListArguments
762	}{
763		{name: "nil", arguments: ChangeShoppingListArguments{Action: "remove"}},
764		{name: "blank", arguments: ChangeShoppingListArguments{Action: "remove", ProductGroupIDs: []string{"  "}}},
765	}
766
767	for _, tt := range tests {
768		t.Run(tt.name, func(t *testing.T) {
769			backend := &fakeBackend{}
770			server := NewServer(backend, "test")
771
772			_, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments)
773			if err == nil {
774				t.Fatal("callChangeShoppingListTool() error = nil, want missing product_group_ids error")
775			}
776			if backend.removeShoppingListCalls != 0 {
777				t.Fatalf("remove calls = %d, want none", backend.removeShoppingListCalls)
778			}
779		})
780	}
781}
782
783func TestChangeShoppingListToolACIDToolsAnnotations2And4To6MarksDestructiveOpenWorldTool(t *testing.T) {
784	tool := changeShoppingListTool()
785	if tool.Name != changeShoppingListToolName {
786		t.Fatalf("tool name = %q, want %q", tool.Name, changeShoppingListToolName)
787	}
788	if tool.Annotations == nil {
789		t.Fatal("tool annotations nil")
790	}
791	if tool.Annotations.ReadOnlyHint {
792		t.Fatal("change_shopping_list read-only hint = true, want false")
793	}
794	if tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint {
795		t.Fatalf("change_shopping_list destructive hint = %#v, want true", tool.Annotations.DestructiveHint)
796	}
797	if tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint {
798		t.Fatalf("change_shopping_list open-world hint = %#v, want true", tool.Annotations.OpenWorldHint)
799	}
800	if tool.Annotations.Title == "" {
801		t.Fatal("change_shopping_list annotation title empty")
802	}
803	if !strings.Contains(tool.Description, "clear") || !strings.Contains(tool.Description, "destructive") {
804		t.Fatalf("change_shopping_list description = %q, want clear/destructive warning", tool.Description)
805	}
806}
807
808type fakeBackend struct {
809	shoppingList                    cooked.ShoppingList
810	recipes                         []cooked.RecipeCard
811	searchRecipes                   []cooked.RecipeCard
812	recipeMetadata                  cooked.RecipeMetadata
813	recipeContent                   cooked.RecipeContent
814	preview                         cooked.RecipeTextPreview
815	saveRecipeID                    string
816	page                            int
817	limit                           int
818	searchQuery                     string
819	searchPage                      int
820	metadataRecipeID                string
821	contentRecipeID                 string
822	previewTitle                    string
823	previewText                     string
824	saveTitle                       string
825	saveMarkdown                    string
826	savePortions                    int
827	updateRecipeID                  string
828	updateMarkdown                  string
829	updatePortions                  int
830	deleteRecipeID                  string
831	addShoppingListResult           cooked.AddShoppingListResult
832	addIngredients                  string
833	addRecipeID                     string
834	removeProductGroupIDs           []string
835	replaceSelectionProductGroupIDs []string
836	metadataCalls                   int
837	contentCalls                    int
838	previewCalls                    int
839	saveCalls                       int
840	updateCalls                     int
841	deleteCalls                     int
842	clearShoppingListCalls          int
843	addShoppingListCalls            int
844	removeShoppingListCalls         int
845	replaceSelectionCalls           int
846}
847
848func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) {
849	return f.shoppingList, nil
850}
851
852func (f *fakeBackend) ListRecipes(_ context.Context, page, limit int) ([]cooked.RecipeCard, error) {
853	f.page = page
854	f.limit = limit
855
856	return f.recipes, nil
857}
858
859func (f *fakeBackend) SearchRecipes(_ context.Context, query string, page int) ([]cooked.RecipeCard, error) {
860	f.searchQuery = query
861	f.searchPage = page
862
863	return f.searchRecipes, nil
864}
865
866func (f *fakeBackend) ReadRecipeMetadata(_ context.Context, recipeID string) (cooked.RecipeMetadata, error) {
867	f.metadataRecipeID = recipeID
868	f.metadataCalls++
869
870	return f.recipeMetadata, nil
871}
872
873func (f *fakeBackend) ReadRecipeContent(_ context.Context, recipeID string) (cooked.RecipeContent, error) {
874	f.contentRecipeID = recipeID
875	f.contentCalls++
876
877	return f.recipeContent, nil
878}
879
880func (f *fakeBackend) PreviewRecipeText(_ context.Context, title, text string) (cooked.RecipeTextPreview, error) {
881	f.previewTitle = title
882	f.previewText = text
883	f.previewCalls++
884
885	return f.preview, nil
886}
887
888func (f *fakeBackend) SavePreparedRecipe(_ context.Context, title, markdown string, portions int) (string, error) {
889	f.saveTitle = title
890	f.saveMarkdown = markdown
891	f.savePortions = portions
892	f.saveCalls++
893	if f.saveRecipeID != "" {
894		return f.saveRecipeID, nil
895	}
896
897	return "recipe-1", nil
898}
899
900func (f *fakeBackend) UpdateRecipeContent(_ context.Context, recipeID, markdown string, portions int) error {
901	f.updateRecipeID = recipeID
902	f.updateMarkdown = markdown
903	f.updatePortions = portions
904	f.updateCalls++
905
906	return nil
907}
908
909func (f *fakeBackend) DeleteRecipe(_ context.Context, recipeID string) error {
910	f.deleteRecipeID = recipeID
911	f.deleteCalls++
912
913	return nil
914}
915
916func (f *fakeBackend) ClearShoppingList(context.Context) error {
917	f.clearShoppingListCalls++
918
919	return nil
920}
921
922func (f *fakeBackend) AddShoppingListIngredients(
923	_ context.Context,
924	ingredients, recipeID string,
925) (cooked.AddShoppingListResult, error) {
926	f.addIngredients = ingredients
927	f.addRecipeID = recipeID
928	f.addShoppingListCalls++
929
930	return f.addShoppingListResult, nil
931}
932
933func (f *fakeBackend) RemoveShoppingListProductGroups(_ context.Context, ids []string) error {
934	f.removeProductGroupIDs = ids
935	f.removeShoppingListCalls++
936
937	return nil
938}
939
940func (f *fakeBackend) ReplaceShoppingListSelection(_ context.Context, ids []string) error {
941	f.replaceSelectionProductGroupIDs = ids
942	f.replaceSelectionCalls++
943
944	return nil
945}