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}