server_ids_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	"slices"
 10	"strings"
 11	"testing"
 12
 13	"github.com/google/jsonschema-go/jsonschema"
 14
 15	"git.secluded.site/cooked-mcp/internal/cooked"
 16)
 17
 18// recipes.VALIDATION.1 recipes.VALIDATION.2 tools.SCHEMA.7
 19func TestCallToolACIDRecipesValidation1And2RejectsMalformedRecipeReadIDBeforeCooked(t *testing.T) {
 20	backend := &fakeBackend{}
 21	server := NewServer(backend, "test")
 22
 23	_, err := server.CallReadTool(context.Background(), ReadArguments{Target: "recipe", RecipeID: "not-a-uuid"})
 24	if err == nil {
 25		t.Fatal("CallReadTool() error = nil, want malformed recipe_id error")
 26	}
 27	if backend.metadataCalls != 0 || backend.contentCalls != 0 {
 28		t.Fatalf("backend calls = metadata %d content %d, want none", backend.metadataCalls, backend.contentCalls)
 29	}
 30}
 31
 32// recipes.VALIDATION.1 recipes.VALIDATION.2
 33func TestCallToolACIDRecipesValidation1And2RejectsMalformedExistingRecipeIDBeforeCooked(t *testing.T) {
 34	backend := &fakeBackend{}
 35	server := NewServer(backend, "test")
 36
 37	_, _, err := server.callSaveRecipeTool(
 38		context.Background(),
 39		nil,
 40		SaveRecipeArguments{Source: "existing", RecipeID: "not-a-uuid", Markdown: "# Pasta", Portions: 2},
 41	)
 42	if err == nil {
 43		t.Fatal("callSaveRecipeTool() error = nil, want malformed recipe_id error")
 44	}
 45	if backend.updateCalls != 0 {
 46		t.Fatalf("update calls = %d, want none", backend.updateCalls)
 47	}
 48}
 49
 50// recipes.VALIDATION.1 recipes.VALIDATION.2
 51func TestCallToolACIDRecipesValidation1And2RejectsMalformedDeleteRecipeIDBeforeCooked(t *testing.T) {
 52	backend := &fakeBackend{}
 53	server := NewServer(backend, "test")
 54
 55	_, _, err := server.callDeleteRecipeTool(context.Background(), nil, DeleteRecipeArguments{RecipeID: "not-a-uuid"})
 56	if err == nil {
 57		t.Fatal("callDeleteRecipeTool() error = nil, want malformed recipe_id error")
 58	}
 59	if backend.deleteCalls != 0 {
 60		t.Fatalf("delete calls = %d, want none", backend.deleteCalls)
 61	}
 62}
 63
 64// tools.SURFACE.9 tools.SCHEMA.7 recipes.RESOLVE.1 recipes.RESOLVE.3
 65func TestCallToolACIDRecipesResolve1And3ResolvesRecipeIDPrefix(t *testing.T) {
 66	backend := &fakeBackend{recipes: []cooked.RecipeCard{
 67		{ID: testRecipeID, Title: "Pasta"},
 68		{ID: testOtherRecipeID, Title: "Toast"},
 69	}}
 70	server := NewServer(backend, "test")
 71
 72	result, output, err := server.callResolveRecipeIDTool(
 73		context.Background(),
 74		nil,
 75		ResolveRecipeIDArguments{Prefix: "38f161dd-a514"},
 76	)
 77	if err != nil {
 78		t.Fatalf("callResolveRecipeIDTool() error = %v", err)
 79	}
 80
 81	if backend.listRecipeCalls != 1 {
 82		t.Fatalf("list recipe calls = %d, want 1", backend.listRecipeCalls)
 83	}
 84	if backend.page != 1 || backend.limit != resolveRecipePageLimit {
 85		t.Fatalf("list page/limit = %d/%d, want 1/%d", backend.page, backend.limit, resolveRecipePageLimit)
 86	}
 87	if len(output.Recipes) != 1 || output.Recipes[0].ID != testRecipeID || output.Recipes[0].Title != "Pasta" {
 88		t.Fatalf("resolve output = %#v, want Pasta match", output)
 89	}
 90	if !strings.Contains(requireTextContent(t, result), testRecipeID) {
 91		t.Fatalf("text output = %q, want matching recipe ID", requireTextContent(t, result))
 92	}
 93}
 94
 95// recipes.RESOLVE.2
 96func TestCallToolACIDRecipesResolve2RejectsMalformedPrefixBeforeCooked(t *testing.T) {
 97	backend := &fakeBackend{}
 98	server := NewServer(backend, "test")
 99
100	_, _, err := server.callResolveRecipeIDTool(
101		context.Background(),
102		nil,
103		ResolveRecipeIDArguments{Prefix: "pasta"},
104	)
105	if err == nil {
106		t.Fatal("callResolveRecipeIDTool() error = nil, want malformed prefix error")
107	}
108	if backend.listRecipeCalls != 0 {
109		t.Fatalf("list recipe calls = %d, want none", backend.listRecipeCalls)
110	}
111}
112
113// recipes.RESOLVE.4
114func TestCallToolACIDRecipesResolve4WarnsWhenPrefixMatchesMultipleRecipes(t *testing.T) {
115	backend := &fakeBackend{recipes: []cooked.RecipeCard{
116		{ID: testRecipeID, Title: "Pasta"},
117		{ID: testRecipeID2, Title: "Tomato Pasta"},
118	}}
119	server := NewServer(backend, "test")
120
121	result, output, err := server.callResolveRecipeIDTool(
122		context.Background(),
123		nil,
124		ResolveRecipeIDArguments{Prefix: "38f161dd-a514-4d5a-a924-0227695d915"},
125	)
126	if err != nil {
127		t.Fatalf("callResolveRecipeIDTool() error = %v", err)
128	}
129	if len(output.Recipes) != 2 {
130		t.Fatalf("resolve matches = %d, want 2", len(output.Recipes))
131	}
132	if output.Warning == "" || !strings.Contains(requireTextContent(t, result), output.Warning) {
133		t.Fatalf("warning/text = %q/%q, want ambiguity warning", output.Warning, requireTextContent(t, result))
134	}
135}
136
137func TestCallToolACIDRecipesResolve4FailsWhenRecipeScanIsIncomplete(t *testing.T) {
138	fullPage := make([]cooked.RecipeCard, resolveRecipePageLimit)
139	for index := range fullPage {
140		fullPage[index] = cooked.RecipeCard{ID: testOtherRecipeID, Title: "Toast"}
141	}
142	pages := make(map[int][]cooked.RecipeCard, maxResolveRecipePages+1)
143	for page := 1; page <= maxResolveRecipePages+1; page++ {
144		pages[page] = fullPage
145	}
146	backend := &fakeBackend{recipesByPage: pages}
147	server := NewServer(backend, "test")
148
149	_, _, err := server.callResolveRecipeIDTool(
150		context.Background(),
151		nil,
152		ResolveRecipeIDArguments{Prefix: "38f161dd-a514"},
153	)
154	if err == nil {
155		t.Fatal("callResolveRecipeIDTool() error = nil, want incomplete scan error")
156	}
157	if !strings.Contains(err.Error(), "too many saved recipes") {
158		t.Fatalf("callResolveRecipeIDTool() error = %q, want scan cap error", err.Error())
159	}
160	if backend.listRecipeCalls != maxResolveRecipePages+1 {
161		t.Fatalf("list recipe calls = %d, want cap plus one", backend.listRecipeCalls)
162	}
163}
164
165func TestResolveRecipeIDToolACIDToolsSurface9ExposesReadOnlyOpenWorldTool(t *testing.T) {
166	tool := resolveRecipeIDTool()
167	if tool.Name != resolveRecipeIDToolName {
168		t.Fatalf("tool name = %q, want %q", tool.Name, resolveRecipeIDToolName)
169	}
170	if tool.Annotations == nil {
171		t.Fatal("tool annotations nil")
172	}
173	if !tool.Annotations.ReadOnlyHint {
174		t.Fatal("resolve_recipe_id read-only hint = false, want true")
175	}
176	if tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint {
177		t.Fatalf("resolve_recipe_id open-world hint = %#v, want true", tool.Annotations.OpenWorldHint)
178	}
179	if tool.Annotations.Title == "" {
180		t.Fatal("resolve_recipe_id annotation title empty")
181	}
182}
183
184// tools.SCHEMA.7 recipes.VALIDATION.1 recipes.VALIDATION.2
185func TestInputSchemasACIDToolsSchema7RejectMalformedRecipeAndProductGroupIDs(t *testing.T) {
186	tests := []struct {
187		name      string
188		schema    *jsonschema.Schema
189		valid     map[string]any
190		malformed map[string]any
191	}{
192		{
193			name:      "read_recipe_id",
194			schema:    readInputSchema(),
195			valid:     map[string]any{"target": "recipe", "recipe_id": " " + testRecipeID + " "},
196			malformed: map[string]any{"target": "recipe", "recipe_id": "not-a-uuid"},
197		},
198		{
199			name:      "save_recipe_id",
200			schema:    saveRecipeInputSchema(),
201			valid:     map[string]any{"source": "existing", "recipe_id": " " + testRecipeID + " "},
202			malformed: map[string]any{"source": "existing", "recipe_id": "not-a-uuid"},
203		},
204		{
205			name:      "delete_recipe_id",
206			schema:    deleteRecipeInputSchema(),
207			valid:     map[string]any{"recipe_id": " " + testRecipeID + " "},
208			malformed: map[string]any{"recipe_id": "not-a-uuid"},
209		},
210		{
211			name:      "change_product_group_id",
212			schema:    changeShoppingListInputSchema(),
213			valid:     map[string]any{"action": "update_item", "product_group_id": " " + testProductGroupID + " "},
214			malformed: map[string]any{"action": "update_item", "product_group_id": "not-a-uuid"},
215		},
216	}
217
218	for _, tt := range tests {
219		t.Run(tt.name, func(t *testing.T) {
220			resolved, err := tt.schema.Resolve(nil)
221			if err != nil {
222				t.Fatalf("Resolve() error = %v", err)
223			}
224			if err := resolved.Validate(tt.valid); err != nil {
225				t.Fatalf("Validate(valid) error = %v", err)
226			}
227			if err := resolved.Validate(tt.malformed); err == nil {
228				t.Fatal("Validate(malformed) error = nil, want UUID pattern rejection")
229			}
230		})
231	}
232}
233
234// tools.SCHEMA.7 recipes.VALIDATION.3
235func TestChangeShoppingListSchemaACIDToolsSchema7AllowsMalformedBulkProductGroupIDForSkipReporting(t *testing.T) {
236	resolved, err := changeShoppingListInputSchema().Resolve(nil)
237	if err != nil {
238		t.Fatalf("Resolve() error = %v", err)
239	}
240	arguments := map[string]any{
241		"action":            "remove",
242		"product_group_ids": []any{testProductGroupID, "not-a-uuid"},
243	}
244	if err := resolved.Validate(arguments); err != nil {
245		t.Fatalf("Validate() with malformed bulk product_group_ids error = %v", err)
246	}
247}
248
249// tools.SCHEMA.7
250func TestChangeShoppingListSchemaACIDToolsSchema7AllowsOpaqueAddRecipeID(t *testing.T) {
251	resolved, err := changeShoppingListInputSchema().Resolve(nil)
252	if err != nil {
253		t.Fatalf("Resolve() error = %v", err)
254	}
255	if err := resolved.Validate(
256		map[string]any{"action": "add", "ingredients": "200g pasta", "recipe_id": "draft-1"},
257	); err != nil {
258		t.Fatalf("Validate() with opaque add recipe_id error = %v", err)
259	}
260}
261
262// recipes.VALIDATION.3
263func TestCallToolACIDRecipesValidation3SkipsMalformedProductGroupIDsInArrays(t *testing.T) {
264	backend := &fakeBackend{}
265	server := NewServer(backend, "test")
266
267	result, output, err := server.callChangeShoppingListTool(
268		context.Background(),
269		nil,
270		ChangeShoppingListArguments{
271			Action:          "remove",
272			ProductGroupIDs: []string{testProductGroupID, "not-a-uuid", "  ", testProductGroupID2},
273		},
274	)
275	if err != nil {
276		t.Fatalf("callChangeShoppingListTool() error = %v", err)
277	}
278	if !slices.Equal(backend.removeProductGroupIDs, []string{testProductGroupID, testProductGroupID2}) {
279		t.Fatalf("backend remove IDs = %#v, want valid IDs only", backend.removeProductGroupIDs)
280	}
281	if !slices.Equal(output.SkippedProductGroupIDs, []string{"not-a-uuid"}) {
282		t.Fatalf("skipped IDs = %#v, want malformed ID reported", output.SkippedProductGroupIDs)
283	}
284	if !strings.Contains(requireTextContent(t, result), "not-a-uuid") {
285		t.Fatalf("text output = %q, want skipped malformed ID", requireTextContent(t, result))
286	}
287}
288
289// recipes.VALIDATION.3
290func TestCallToolACIDRecipesValidation3ReportsMalformedOnlyProductGroupIDArraysWithoutMutation(t *testing.T) {
291	tests := []string{"remove", "replace_selection", "add_selection", "remove_selection"}
292
293	for _, action := range tests {
294		t.Run(action, func(t *testing.T) {
295			backend := &fakeBackend{}
296			server := NewServer(backend, "test")
297
298			result, output, err := server.callChangeShoppingListTool(
299				context.Background(),
300				nil,
301				ChangeShoppingListArguments{Action: action, ProductGroupIDs: []string{"not-a-uuid"}},
302			)
303			if err != nil {
304				t.Fatalf("callChangeShoppingListTool() error = %v", err)
305			}
306			if !slices.Equal(output.SkippedProductGroupIDs, []string{"not-a-uuid"}) {
307				t.Fatalf("skipped IDs = %#v, want malformed ID reported", output.SkippedProductGroupIDs)
308			}
309			if !strings.Contains(requireTextContent(t, result), "not-a-uuid") {
310				t.Fatalf("text output = %q, want skipped malformed ID", requireTextContent(t, result))
311			}
312			if backend.removeShoppingListCalls != 0 || backend.readShoppingListCalls != 0 ||
313				backend.replaceSelectionCalls != 0 {
314				t.Fatalf(
315					"backend calls remove/read/replace = %d/%d/%d, want none",
316					backend.removeShoppingListCalls,
317					backend.readShoppingListCalls,
318					backend.replaceSelectionCalls,
319				)
320			}
321		})
322	}
323}