Skip to content

Commit e616cdc

Browse files
authored
feat: Add mock handler generator for dry-run saga validation (#754)
* feat: Add mock handler generator for dry-run saga validation Implement automatic mock generation that reads handlers.yaml and creates mock handlers returning schema-compliant test data. Features: - Parse handlers.yaml using existing schema.Registry - Generate deterministic mock data based on field types: * string → 'mock_<field_name>' or extract from description for status * Decimal → 100.00 * int64/int32/uint32 → 1000 * enum → first valid value from schema * array → empty array [] * map → empty map {} * bool → true * UUID → deterministic UUID - Echo input parameters when return field matches param name - Integrate with saga.HandlerRegistry for runtime injection - Provide NewMockHandlerRegistry helper for isolated testing All mocks are deterministic (same input → same output) for reproducible validation. Status fields intelligently extract values from descriptions (e.g., 'Status of the lien (ACTIVE)' → 'ACTIVE'). Enables dry-run validation of Starlark saga scripts without real service dependencies (Task 25). * refactor: Address CodeRabbit feedback - improve test robustness - Use comma-ok type assertions in all tests for clearer failure messages - Rename TestGenerateMockForInt64Field → TestGenerateMockForHandlerWithInt64Params to accurately reflect what the test validates (handler accepts int64 params, not that it returns int64 fields) - All tests still pass --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent deb088c commit e616cdc

2 files changed

Lines changed: 478 additions & 0 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Package validation provides mock handler generation from handlers.yaml for dry-run validation.
2+
package validation
3+
4+
import (
5+
"fmt"
6+
7+
"github.com/google/uuid"
8+
"github.com/meridianhub/meridian/shared/pkg/saga"
9+
"github.com/meridianhub/meridian/shared/pkg/saga/schema"
10+
"github.com/shopspring/decimal"
11+
)
12+
13+
// GenerateMockHandler creates a deterministic mock handler from a handler definition.
14+
// The mock returns schema-compliant test data based on the handler's return types.
15+
//
16+
// Mock generation rules:
17+
// - string fields: "mock_<field_name>"
18+
// - Decimal fields: "100.00"
19+
// - int64 fields: 1000
20+
// - enum fields: first valid value from schema
21+
// - array fields: empty array []
22+
// - map fields: empty map {} or echo input if field matches param
23+
// - Fields matching input params: echo the input value
24+
//
25+
// Mocks are deterministic - same input parameters produce same output.
26+
func GenerateMockHandler(handlerDef *schema.HandlerDef) saga.Handler {
27+
return func(_ *saga.StarlarkContext, params map[string]any) (any, error) {
28+
result := make(map[string]any)
29+
30+
// Generate mock values for each return field
31+
for fieldName, fieldDef := range handlerDef.Returns {
32+
result[fieldName] = generateMockValue(fieldName, fieldDef, params)
33+
}
34+
35+
return result, nil
36+
}
37+
}
38+
39+
// generateMockValue generates a deterministic mock value for a single field.
40+
// If the field name matches an input parameter, the parameter value is echoed.
41+
// Otherwise, a type-appropriate mock value is generated.
42+
func generateMockValue(fieldName string, fieldDef *schema.FieldDef, params map[string]any) any {
43+
// Check if this field should echo an input parameter
44+
if echoValue, found := params[fieldName]; found {
45+
return echoValue
46+
}
47+
48+
// Handle account_id alias (special case for position_keeping handlers)
49+
if fieldName == "position_id" {
50+
if accountID, found := params["account_id"]; found {
51+
return accountID
52+
}
53+
}
54+
55+
// Generate type-specific mock value
56+
return generateByType(fieldName, fieldDef)
57+
}
58+
59+
// generateByType generates a mock value based on the field type.
60+
func generateByType(fieldName string, fieldDef *schema.FieldDef) any {
61+
switch fieldDef.Type {
62+
case schema.TypeString:
63+
return generateStringValue(fieldName, fieldDef)
64+
case schema.TypeDecimal:
65+
return decimal.NewFromFloat(100.00)
66+
case schema.TypeInt32:
67+
return int32(1000)
68+
case schema.TypeInt64:
69+
return int64(1000)
70+
case schema.TypeUint32:
71+
return uint32(1000)
72+
case schema.TypeBool:
73+
return true
74+
case schema.TypeEnum:
75+
return generateEnumValue(fieldDef)
76+
case schema.TypeArray:
77+
return []any{}
78+
case schema.TypeMap:
79+
return map[string]any{}
80+
case schema.TypeUUID:
81+
return uuid.MustParse("00000000-0000-0000-0000-000000000001")
82+
default:
83+
return nil
84+
}
85+
}
86+
87+
// generateStringValue generates a mock string value, with special handling for status fields.
88+
func generateStringValue(fieldName string, fieldDef *schema.FieldDef) string {
89+
// Special handling for status fields - extract from description
90+
if fieldName == "status" && fieldDef.Description != "" {
91+
if statusValue := extractStatusFromDescription(fieldDef.Description); statusValue != "" {
92+
return statusValue
93+
}
94+
}
95+
// String fields use "mock_<field_name>" pattern
96+
return fmt.Sprintf("mock_%s", fieldName)
97+
}
98+
99+
// generateEnumValue generates a mock enum value (first valid value or "UNKNOWN").
100+
func generateEnumValue(fieldDef *schema.FieldDef) string {
101+
if len(fieldDef.Values) > 0 {
102+
return fieldDef.Values[0]
103+
}
104+
return "UNKNOWN"
105+
}
106+
107+
// RegisterMockHandlers registers mock handlers for all schemas in the registry.
108+
// This populates the HandlerRegistry with mocks that can be used for dry-run validation.
109+
//
110+
// Each handler from the schema is converted to a mock and registered using
111+
// the handler's full name (e.g., "position_keeping.initiate_log").
112+
//
113+
// Returns an error if registration fails for any handler.
114+
func RegisterMockHandlers(handlerRegistry *saga.HandlerRegistry, schemaRegistry *schema.Registry) error {
115+
// Get all registered handler names from schema registry
116+
handlerNames := schemaRegistry.ListHandlers()
117+
118+
for _, handlerName := range handlerNames {
119+
// Get handler definition from schema
120+
handlerDef, err := schemaRegistry.GetHandler(handlerName)
121+
if err != nil {
122+
return fmt.Errorf("failed to get handler %s: %w", handlerName, err)
123+
}
124+
125+
// Generate mock handler
126+
mockHandler := GenerateMockHandler(handlerDef)
127+
128+
// Register mock in handler registry
129+
if err := handlerRegistry.Register(handlerName, mockHandler); err != nil {
130+
return fmt.Errorf("failed to register mock handler %s: %w", handlerName, err)
131+
}
132+
}
133+
134+
return nil
135+
}
136+
137+
// extractStatusFromDescription attempts to extract a status value from field description.
138+
// Handlers.yaml descriptions often contain the status value in parentheses, e.g.:
139+
// "Status of the log entry (INITIATED)"
140+
// "Status of the lien (ACTIVE)"
141+
func extractStatusFromDescription(description string) string {
142+
// Look for pattern: (<STATUS_VALUE>)
143+
start := -1
144+
end := -1
145+
146+
for i, ch := range description {
147+
if ch == '(' {
148+
start = i + 1
149+
} else if ch == ')' && start != -1 {
150+
end = i
151+
break
152+
}
153+
}
154+
155+
if start != -1 && end != -1 && end > start {
156+
statusValue := description[start:end]
157+
// Verify it looks like a status (uppercase, underscores allowed)
158+
if isUppercaseWithUnderscores(statusValue) {
159+
return statusValue
160+
}
161+
}
162+
163+
return ""
164+
}
165+
166+
// isUppercaseWithUnderscores checks if a string contains only uppercase letters and underscores.
167+
func isUppercaseWithUnderscores(s string) bool {
168+
if s == "" {
169+
return false
170+
}
171+
for _, ch := range s {
172+
if ch < 'A' || ch > 'Z' {
173+
if ch != '_' {
174+
return false
175+
}
176+
}
177+
}
178+
return true
179+
}
180+
181+
// NewMockHandlerRegistry creates a new isolated HandlerRegistry populated with mocks.
182+
// This is a convenience function for creating a registry dedicated to dry-run validation.
183+
//
184+
// The returned registry is independent from production handlers and safe to use
185+
// for script validation without affecting real services.
186+
//
187+
// Example usage:
188+
//
189+
// schemaRegistry := schema.NewRegistry()
190+
// schemaRegistry.LoadFromFile("handlers.yaml")
191+
// mockRegistry, err := NewMockHandlerRegistry(schemaRegistry)
192+
// // Use mockRegistry for dry-run saga validation
193+
func NewMockHandlerRegistry(schemaRegistry *schema.Registry) (*saga.HandlerRegistry, error) {
194+
registry := saga.NewHandlerRegistry()
195+
196+
if err := RegisterMockHandlers(registry, schemaRegistry); err != nil {
197+
return nil, fmt.Errorf("failed to populate mock handler registry: %w", err)
198+
}
199+
200+
return registry, nil
201+
}

0 commit comments

Comments
 (0)