blob: 240d0c1094bf10f199ea76f84d47e77b092143d0 [file] [log] [blame]
Akron8db31c32025-06-17 12:22:41 +02001package tools
2
3import (
4 "encoding/json"
5 "fmt"
6 "reflect"
7 "testing"
8
9 "github.com/korap/korap-mcp/service"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12)
13
14// TestAllToolsSchemaCompliance ensures all tools have properly structured schemas
15func TestAllToolsSchemaCompliance(t *testing.T) {
16 client := &service.Client{}
17
18 tools := []Tool{
19 NewSearchTool(client),
20 NewMetadataTool(client),
21 }
22
23 for _, tool := range tools {
24 t.Run(tool.Name(), func(t *testing.T) {
25 schema := tool.InputSchema()
26
27 // Verify basic schema structure
28 validateBasicSchemaStructure(t, schema, tool.Name())
29
30 // Verify schema is valid JSON
31 validateSchemaIsValidJSON(t, schema, tool.Name())
32
33 // Verify schema documentation completeness
34 validateSchemaDocumentation(t, schema, tool.Name())
35 })
36 }
37}
38
39// validateBasicSchemaStructure checks that the schema has the required top-level properties
Akron708f3912025-06-17 12:26:02 +020040func validateBasicSchemaStructure(t *testing.T, schema map[string]any, toolName string) {
Akron8db31c32025-06-17 12:22:41 +020041 // Must be object type
42 assert.Equal(t, "object", schema["type"], "Tool %s schema must be object type", toolName)
43
44 // Must have properties
45 properties, exists := schema["properties"]
46 assert.True(t, exists, "Tool %s schema must have properties", toolName)
Akron708f3912025-06-17 12:26:02 +020047 assert.IsType(t, map[string]any{}, properties, "Tool %s properties must be an object", toolName)
Akron8db31c32025-06-17 12:22:41 +020048
49 // Must have required array
50 required, exists := schema["required"]
51 assert.True(t, exists, "Tool %s schema must have required array", toolName)
52 assert.IsType(t, []string{}, required, "Tool %s required must be string array", toolName)
53
54 // Should have title and description
55 assert.Contains(t, schema, "title", "Tool %s schema should have title", toolName)
56 assert.Contains(t, schema, "description", "Tool %s schema should have description", toolName)
57
58 // Should prevent additional properties for strict validation
59 assert.Contains(t, schema, "additionalProperties", "Tool %s schema should specify additionalProperties", toolName)
60 assert.Equal(t, false, schema["additionalProperties"], "Tool %s should not allow additional properties", toolName)
61}
62
63// validateSchemaIsValidJSON ensures the schema can be properly serialized to JSON
Akron708f3912025-06-17 12:26:02 +020064func validateSchemaIsValidJSON(t *testing.T, schema map[string]any, toolName string) {
Akron8db31c32025-06-17 12:22:41 +020065 jsonBytes, err := json.Marshal(schema)
66 require.NoError(t, err, "Tool %s schema must be serializable to JSON", toolName)
67
Akron708f3912025-06-17 12:26:02 +020068 var unmarshalled map[string]any
Akron8db31c32025-06-17 12:22:41 +020069 err = json.Unmarshal(jsonBytes, &unmarshalled)
70 require.NoError(t, err, "Tool %s schema JSON must be valid", toolName)
71
72 // Verify the unmarshalled schema has the same structure
73 assert.Equal(t, schema["type"], unmarshalled["type"], "Tool %s schema type must survive JSON round-trip", toolName)
74}
75
76// validateSchemaDocumentation checks that all properties have proper documentation
Akron708f3912025-06-17 12:26:02 +020077func validateSchemaDocumentation(t *testing.T, schema map[string]any, toolName string) {
78 properties, ok := schema["properties"].(map[string]any)
Akron8db31c32025-06-17 12:22:41 +020079 require.True(t, ok, "Tool %s properties must be accessible", toolName)
80
81 for propName, propSchema := range properties {
Akron708f3912025-06-17 12:26:02 +020082 propMap, ok := propSchema.(map[string]any)
Akron8db31c32025-06-17 12:22:41 +020083 require.True(t, ok, "Tool %s property %s must be an object", toolName, propName)
84
85 // Must have type
86 assert.Contains(t, propMap, "type", "Tool %s property %s must have type", toolName, propName)
87
88 // Must have description
89 assert.Contains(t, propMap, "description", "Tool %s property %s must have description", toolName, propName)
90
91 description, ok := propMap["description"].(string)
92 assert.True(t, ok, "Tool %s property %s description must be string", toolName, propName)
93 assert.NotEmpty(t, description, "Tool %s property %s description must not be empty", toolName, propName)
94 assert.Greater(t, len(description), 10, "Tool %s property %s description should be descriptive", toolName, propName)
95
96 // String properties should have examples
97 if propType, ok := propMap["type"].(string); ok && propType == "string" {
98 if enumValues, hasEnum := propMap["enum"]; hasEnum {
99 // Enum properties should have examples matching enum values
100 assert.Contains(t, propMap, "examples", "Tool %s property %s with enum should have examples", toolName, propName)
101 examples, ok := propMap["examples"].([]string)
102 assert.True(t, ok, "Tool %s property %s examples must be string array", toolName, propName)
103
104 enumArray, ok := enumValues.([]string)
105 assert.True(t, ok, "Tool %s property %s enum must be string array", toolName, propName)
106
107 // All examples should be valid enum values
108 for _, example := range examples {
109 assert.Contains(t, enumArray, example, "Tool %s property %s example '%s' must be valid enum value", toolName, propName, example)
110 }
111 } else {
112 // Regular string properties should have examples
113 assert.Contains(t, propMap, "examples", "Tool %s property %s should have examples", toolName, propName)
114 }
115 }
116
117 // Integer properties should have examples and constraints
118 if propType, ok := propMap["type"].(string); ok && propType == "integer" {
119 assert.Contains(t, propMap, "examples", "Tool %s property %s should have examples", toolName, propName)
120
121 // Should have minimum/maximum constraints
122 assert.Contains(t, propMap, "minimum", "Tool %s property %s should have minimum", toolName, propName)
123 assert.Contains(t, propMap, "maximum", "Tool %s property %s should have maximum", toolName, propName)
124 }
125 }
126}
127
128// TestSchemaExamples verifies that schema examples are realistic and valid
129func TestSchemaExamples(t *testing.T) {
130 client := &service.Client{}
131
132 t.Run("SearchTool", func(t *testing.T) {
133 tool := NewSearchTool(client)
134 schema := tool.InputSchema()
135
Akron708f3912025-06-17 12:26:02 +0200136 properties := schema["properties"].(map[string]any)
Akron8db31c32025-06-17 12:22:41 +0200137
138 // Test query examples
Akron708f3912025-06-17 12:26:02 +0200139 queryProp := properties["query"].(map[string]any)
Akron8db31c32025-06-17 12:22:41 +0200140 examples := queryProp["examples"].([]string)
141
142 for _, example := range examples {
143 assert.NotEmpty(t, example, "Query example should not be empty")
144 assert.LessOrEqual(t, len(example), 100, "Query example should be reasonably short")
145 }
146
147 // Test corpus examples
Akron708f3912025-06-17 12:26:02 +0200148 corpusProp := properties["corpus"].(map[string]any)
Akron8db31c32025-06-17 12:22:41 +0200149 corpusExamples := corpusProp["examples"].([]string)
150
151 for _, example := range corpusExamples {
152 assert.NotEmpty(t, example, "Corpus example should not be empty")
153 // Should match the pattern defined in the schema
154 pattern := corpusProp["pattern"].(string)
155 assert.NotEmpty(t, pattern, "Corpus should have validation pattern")
156 }
157 })
158
159 t.Run("MetadataTool", func(t *testing.T) {
160 tool := NewMetadataTool(client)
161 schema := tool.InputSchema()
162
Akron708f3912025-06-17 12:26:02 +0200163 properties := schema["properties"].(map[string]any)
Akron8db31c32025-06-17 12:22:41 +0200164
165 // Test action examples
Akron708f3912025-06-17 12:26:02 +0200166 actionProp := properties["action"].(map[string]any)
Akron8db31c32025-06-17 12:22:41 +0200167 examples := actionProp["examples"].([]string)
168 enumValues := actionProp["enum"].([]string)
169
170 for _, example := range examples {
171 assert.Contains(t, enumValues, example, "Action example should be valid enum value")
172 }
173 })
174}
175
176// TestSchemaConstraints verifies that schema constraints are reasonable
177func TestSchemaConstraints(t *testing.T) {
178 client := &service.Client{}
179
180 t.Run("SearchTool", func(t *testing.T) {
181 tool := NewSearchTool(client)
182 schema := tool.InputSchema()
Akron708f3912025-06-17 12:26:02 +0200183 properties := schema["properties"].(map[string]any)
Akron8db31c32025-06-17 12:22:41 +0200184
185 // Test query constraints
Akron708f3912025-06-17 12:26:02 +0200186 queryProp := properties["query"].(map[string]any)
Akron8db31c32025-06-17 12:22:41 +0200187 minLength := queryProp["minLength"].(int)
188 maxLength := queryProp["maxLength"].(int)
189
190 assert.Greater(t, minLength, 0, "Query minimum length should be positive")
191 assert.Greater(t, maxLength, minLength, "Query maximum length should be greater than minimum")
192 assert.LessOrEqual(t, maxLength, 10000, "Query maximum length should be reasonable")
193
194 // Test count constraints
Akron708f3912025-06-17 12:26:02 +0200195 countProp := properties["count"].(map[string]any)
Akron8db31c32025-06-17 12:22:41 +0200196 minimum := countProp["minimum"].(int)
197 maximum := countProp["maximum"].(int)
198 defaultValue := countProp["default"].(int)
199
200 assert.GreaterOrEqual(t, minimum, 0, "Count minimum should be non-negative")
201 assert.Greater(t, maximum, minimum, "Count maximum should be greater than minimum")
202 assert.GreaterOrEqual(t, defaultValue, minimum, "Count default should be >= minimum")
203 assert.LessOrEqual(t, defaultValue, maximum, "Count default should be <= maximum")
204 })
205}
206
207// TestSchemaVersioning ensures schema structure is backwards compatible
208func TestSchemaVersioning(t *testing.T) {
209 client := &service.Client{}
210
211 // This test would be extended when we add new versions
212 // For now, we ensure the basic structure is stable
213
214 tools := []Tool{
215 NewSearchTool(client),
216 NewMetadataTool(client),
217 }
218
219 for _, tool := range tools {
220 t.Run(tool.Name(), func(t *testing.T) {
221 schema := tool.InputSchema()
222
223 // Core schema structure must remain stable
224 assert.Equal(t, "object", schema["type"])
225 assert.Contains(t, schema, "properties")
226 assert.Contains(t, schema, "required")
227
228 // Tool-specific stability checks
229 switch tool.Name() {
230 case "korap_search":
Akron708f3912025-06-17 12:26:02 +0200231 properties := schema["properties"].(map[string]any)
Akron8db31c32025-06-17 12:22:41 +0200232 assert.Contains(t, properties, "query", "Search tool must always have query parameter")
233
234 required := schema["required"].([]string)
235 assert.Contains(t, required, "query", "Search tool must always require query parameter")
236
237 case "korap_metadata":
Akron708f3912025-06-17 12:26:02 +0200238 properties := schema["properties"].(map[string]any)
Akron8db31c32025-06-17 12:22:41 +0200239 assert.Contains(t, properties, "action", "Metadata tool must always have action parameter")
240
241 required := schema["required"].([]string)
242 assert.Contains(t, required, "action", "Metadata tool must always require action parameter")
243 }
244 })
245 }
246}
247
248// BenchmarkSchemaGeneration measures schema generation performance
249func BenchmarkSchemaGeneration(b *testing.B) {
250 client := &service.Client{}
251
252 tools := []Tool{
253 NewSearchTool(client),
254 NewMetadataTool(client),
255 }
256
257 for _, tool := range tools {
258 b.Run(tool.Name(), func(b *testing.B) {
259 for i := 0; i < b.N; i++ {
260 schema := tool.InputSchema()
261 _ = schema // Prevent optimization
262 }
263 })
264 }
265}
266
267// TestSchemaDeepCopy ensures schemas are independent instances
268func TestSchemaDeepCopy(t *testing.T) {
269 client := &service.Client{}
270 tool := NewSearchTool(client)
271
272 // Get two schema instances
273 schema1 := tool.InputSchema()
274 schema2 := tool.InputSchema()
275
276 // They should be equal in content but different instances
277 assert.True(t, reflect.DeepEqual(schema1, schema2), "Schemas should have equal content")
278
279 // Modifying one should not affect the other
280 schema1["modified"] = true
281 assert.NotContains(t, schema2, "modified", "Schema instances should be independent")
282}
283
284// TestSchemaHelpGeneration verifies schema can be used to generate help text
285func TestSchemaHelpGeneration(t *testing.T) {
286 client := &service.Client{}
287
288 tools := []Tool{
289 NewSearchTool(client),
290 NewMetadataTool(client),
291 }
292
293 for _, tool := range tools {
294 t.Run(tool.Name(), func(t *testing.T) {
295 schema := tool.InputSchema()
296
297 // Generate help text from schema
298 helpText := generateHelpText(schema, tool.Name())
299
300 // Verify help text contains essential information
301 assert.Contains(t, helpText, tool.Name(), "Help text should contain tool name")
302 assert.Contains(t, helpText, "Parameters:", "Help text should list parameters")
303
304 // Should contain information about required parameters
305 required := schema["required"].([]string)
306 for _, param := range required {
307 assert.Contains(t, helpText, param, "Help text should mention required parameter %s", param)
308 assert.Contains(t, helpText, "(required)", "Help text should mark required parameters")
309 }
310 })
311 }
312}
313
314// generateHelpText creates human-readable help text from a schema
Akron708f3912025-06-17 12:26:02 +0200315func generateHelpText(schema map[string]any, toolName string) string {
Akron8db31c32025-06-17 12:22:41 +0200316 help := fmt.Sprintf("Tool: %s\n", toolName)
317
318 if desc, ok := schema["description"].(string); ok {
319 help += fmt.Sprintf("Description: %s\n\n", desc)
320 }
321
Akron708f3912025-06-17 12:26:02 +0200322 properties, ok := schema["properties"].(map[string]any)
Akron8db31c32025-06-17 12:22:41 +0200323 if !ok {
324 return help
325 }
326
327 required, _ := schema["required"].([]string)
328 requiredMap := make(map[string]bool)
329 for _, req := range required {
330 requiredMap[req] = true
331 }
332
333 help += "Parameters:\n"
334 for paramName, paramSchema := range properties {
Akron708f3912025-06-17 12:26:02 +0200335 paramMap, ok := paramSchema.(map[string]any)
Akron8db31c32025-06-17 12:22:41 +0200336 if !ok {
337 continue
338 }
339
340 requiredText := ""
341 if requiredMap[paramName] {
342 requiredText = " (required)"
343 }
344
345 paramType, _ := paramMap["type"].(string)
346 description, _ := paramMap["description"].(string)
347
348 help += fmt.Sprintf(" %s (%s)%s: %s\n", paramName, paramType, requiredText, description)
349
350 // Add enum values if present
351 if enum, ok := paramMap["enum"].([]string); ok {
352 help += fmt.Sprintf(" Allowed values: %v\n", enum)
353 }
354
355 // Add examples if present
356 if examples, ok := paramMap["examples"]; ok {
357 help += fmt.Sprintf(" Examples: %v\n", examples)
358 }
359 }
360
361 return help
362}