Files
BACKBEAT/contracts/tests/integration/validator.go
2025-10-17 08:56:25 +11:00

283 lines
8.5 KiB
Go

// Package integration provides CI validation helpers for BACKBEAT conformance testing
package integration
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/xeipuuv/gojsonschema"
)
// MessageValidator provides validation for BACKBEAT messages against JSON schemas
type MessageValidator struct {
schemaLoaders map[string]gojsonschema.JSONLoader
}
// MessageType constants for the three core BACKBEAT interfaces
const (
BeatFrameType = "backbeat.beatframe.v1"
StatusClaimType = "backbeat.statusclaim.v1"
BarReportType = "backbeat.barreport.v1"
)
// ValidationError represents a validation failure with context
type ValidationError struct {
MessageType string `json:"message_type"`
Field string `json:"field"`
Value string `json:"value"`
Message string `json:"message"`
Errors []string `json:"errors"`
}
func (ve ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", ve.MessageType, strings.Join(ve.Errors, "; "))
}
// ValidationResult contains the outcome of message validation
type ValidationResult struct {
Valid bool `json:"valid"`
MessageType string `json:"message_type"`
Errors []ValidationError `json:"errors,omitempty"`
SchemaVersion string `json:"schema_version"`
}
// NewMessageValidator creates a new validator with schema loaders
func NewMessageValidator(schemaDir string) (*MessageValidator, error) {
validator := &MessageValidator{
schemaLoaders: make(map[string]gojsonschema.JSONLoader),
}
// Load all schema files
schemas := map[string]string{
BeatFrameType: "beatframe-v1.schema.json",
StatusClaimType: "statusclaim-v1.schema.json",
BarReportType: "barreport-v1.schema.json",
}
for msgType, schemaFile := range schemas {
schemaPath := filepath.Join(schemaDir, schemaFile)
loader := gojsonschema.NewReferenceLoader("file://" + schemaPath)
validator.schemaLoaders[msgType] = loader
}
return validator, nil
}
// ValidateMessage validates a JSON message against the appropriate BACKBEAT schema
func (v *MessageValidator) ValidateMessage(messageJSON []byte) (*ValidationResult, error) {
// Parse message to determine type
var msgMap map[string]interface{}
if err := json.Unmarshal(messageJSON, &msgMap); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
msgType, ok := msgMap["type"].(string)
if !ok {
return &ValidationResult{
Valid: false,
MessageType: "unknown",
Errors: []ValidationError{
{
Field: "type",
Message: "message type field is missing or not a string",
Errors: []string{"type field is required and must be a string"},
},
},
}, nil
}
// Get appropriate schema loader
schemaLoader, exists := v.schemaLoaders[msgType]
if !exists {
return &ValidationResult{
Valid: false,
MessageType: msgType,
Errors: []ValidationError{
{
Field: "type",
Value: msgType,
Message: fmt.Sprintf("unsupported message type: %s", msgType),
Errors: []string{fmt.Sprintf("message type %s is not supported by BACKBEAT contracts", msgType)},
},
},
}, nil
}
// Validate against schema
docLoader := gojsonschema.NewBytesLoader(messageJSON)
result, err := gojsonschema.Validate(schemaLoader, docLoader)
if err != nil {
return nil, fmt.Errorf("schema validation failed: %w", err)
}
validationResult := &ValidationResult{
Valid: result.Valid(),
MessageType: msgType,
SchemaVersion: getSchemaVersion(msgType),
}
if !result.Valid() {
for _, desc := range result.Errors() {
validationResult.Errors = append(validationResult.Errors, ValidationError{
MessageType: msgType,
Field: desc.Field(),
Value: fmt.Sprintf("%v", desc.Value()),
Message: desc.Description(),
Errors: []string{desc.String()},
})
}
}
return validationResult, nil
}
// ValidateMessageString validates a JSON message string
func (v *MessageValidator) ValidateMessageString(messageJSON string) (*ValidationResult, error) {
return v.ValidateMessage([]byte(messageJSON))
}
// ValidateStruct validates a Go struct by marshaling to JSON first
func (v *MessageValidator) ValidateStruct(message interface{}) (*ValidationResult, error) {
jsonBytes, err := json.Marshal(message)
if err != nil {
return nil, fmt.Errorf("failed to marshal struct to JSON: %w", err)
}
return v.ValidateMessage(jsonBytes)
}
// BatchValidate validates multiple messages and returns aggregated results
func (v *MessageValidator) BatchValidate(messages [][]byte) ([]*ValidationResult, error) {
results := make([]*ValidationResult, len(messages))
for i, msg := range messages {
result, err := v.ValidateMessage(msg)
if err != nil {
return nil, fmt.Errorf("failed to validate message %d: %w", i, err)
}
results[i] = result
}
return results, nil
}
// GetSupportedMessageTypes returns the list of supported BACKBEAT message types
func (v *MessageValidator) GetSupportedMessageTypes() []string {
types := make([]string, 0, len(v.schemaLoaders))
for msgType := range v.schemaLoaders {
types = append(types, msgType)
}
return types
}
// IsMessageTypeSupported checks if a message type is supported
func (v *MessageValidator) IsMessageTypeSupported(msgType string) bool {
_, exists := v.schemaLoaders[msgType]
return exists
}
// getSchemaVersion returns the version for a given message type
func getSchemaVersion(msgType string) string {
versions := map[string]string{
BeatFrameType: "1.0.0",
StatusClaimType: "1.0.0",
BarReportType: "1.0.0",
}
return versions[msgType]
}
// ValidationStats provides summary statistics for batch validation
type ValidationStats struct {
TotalMessages int `json:"total_messages"`
ValidMessages int `json:"valid_messages"`
InvalidMessages int `json:"invalid_messages"`
MessageTypes map[string]int `json:"message_types"`
ErrorSummary map[string]int `json:"error_summary"`
ValidationRate float64 `json:"validation_rate"`
}
// GetValidationStats computes statistics from validation results
func GetValidationStats(results []*ValidationResult) *ValidationStats {
stats := &ValidationStats{
TotalMessages: len(results),
MessageTypes: make(map[string]int),
ErrorSummary: make(map[string]int),
}
for _, result := range results {
// Count message types
stats.MessageTypes[result.MessageType]++
if result.Valid {
stats.ValidMessages++
} else {
stats.InvalidMessages++
// Aggregate error types
for _, err := range result.Errors {
stats.ErrorSummary[err.Field]++
}
}
}
if stats.TotalMessages > 0 {
stats.ValidationRate = float64(stats.ValidMessages) / float64(stats.TotalMessages)
}
return stats
}
// ExampleMessages provides sample messages for testing and documentation
func ExampleMessages() map[string]interface{} {
return map[string]interface{}{
"beatframe_minimal": map[string]interface{}{
"type": BeatFrameType,
"cluster_id": "test-cluster",
"beat_index": 0,
"downbeat": true,
"phase": "plan",
"hlc": "0001:0000:cafe",
"deadline_at": "2025-09-05T12:00:30Z",
"tempo_bpm": 2.0,
"window_id": "a1b2c3d4e5f6789012345678901234ab",
},
"statusclaim_minimal": map[string]interface{}{
"type": StatusClaimType,
"agent_id": "test:agent",
"beat_index": 100,
"state": "idle",
"hlc": "7ffd:0001:abcd",
},
"barreport_minimal": map[string]interface{}{
"type": BarReportType,
"window_id": "7e9b0e6c4c9a4e59b7f2d9a3c1b2e4d5",
"from_beat": 0,
"to_beat": 119,
"agents_reporting": 1,
"on_time_reviews": 1,
"help_promises_fulfilled": 0,
"secret_rotations_ok": true,
"tempo_drift_ms": 0.0,
},
}
}
// PrettyPrintValidationResult formats validation results for human reading
func PrettyPrintValidationResult(result *ValidationResult) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Message Type: %s\n", result.MessageType))
sb.WriteString(fmt.Sprintf("Schema Version: %s\n", result.SchemaVersion))
sb.WriteString(fmt.Sprintf("Valid: %t\n", result.Valid))
if !result.Valid && len(result.Errors) > 0 {
sb.WriteString("\nValidation Errors:\n")
for i, err := range result.Errors {
sb.WriteString(fmt.Sprintf(" %d. Field: %s\n", i+1, err.Field))
if err.Value != "" {
sb.WriteString(fmt.Sprintf(" Value: %s\n", err.Value))
}
sb.WriteString(fmt.Sprintf(" Error: %s\n", err.Message))
}
}
return sb.String()
}