backbeat: add module sources
This commit is contained in:
533
contracts/tests/conformance_test.go
Normal file
533
contracts/tests/conformance_test.go
Normal file
@@ -0,0 +1,533 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xeipuuv/gojsonschema"
|
||||
)
|
||||
|
||||
// MessageTypes defines the three core BACKBEAT interfaces
|
||||
const (
|
||||
BeatFrameType = "backbeat.beatframe.v1"
|
||||
StatusClaimType = "backbeat.statusclaim.v1"
|
||||
BarReportType = "backbeat.barreport.v1"
|
||||
)
|
||||
|
||||
// BeatFrame represents INT-A: Pulse → All Services
|
||||
type BeatFrame struct {
|
||||
Type string `json:"type"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
BeatIndex int64 `json:"beat_index"`
|
||||
Downbeat bool `json:"downbeat"`
|
||||
Phase string `json:"phase"`
|
||||
HLC string `json:"hlc"`
|
||||
DeadlineAt time.Time `json:"deadline_at"`
|
||||
TempoBPM float64 `json:"tempo_bpm"`
|
||||
WindowID string `json:"window_id"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// StatusClaim represents INT-B: Agents → Reverb
|
||||
type StatusClaim struct {
|
||||
Type string `json:"type"`
|
||||
AgentID string `json:"agent_id"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
BeatIndex int64 `json:"beat_index"`
|
||||
State string `json:"state"`
|
||||
BeatsLeft int `json:"beats_left,omitempty"`
|
||||
Progress float64 `json:"progress,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
HLC string `json:"hlc"`
|
||||
Resources map[string]interface{} `json:"resources,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// BarReport represents INT-C: Reverb → All Services
|
||||
type BarReport struct {
|
||||
Type string `json:"type"`
|
||||
WindowID string `json:"window_id"`
|
||||
FromBeat int64 `json:"from_beat"`
|
||||
ToBeat int64 `json:"to_beat"`
|
||||
AgentsReporting int `json:"agents_reporting"`
|
||||
OnTimeReviews int `json:"on_time_reviews"`
|
||||
HelpPromisesFulfilled int `json:"help_promises_fulfilled"`
|
||||
SecretRotationsOK bool `json:"secret_rotations_ok"`
|
||||
TempoDriftMS float64 `json:"tempo_drift_ms"`
|
||||
Issues []map[string]interface{} `json:"issues,omitempty"`
|
||||
Performance map[string]interface{} `json:"performance,omitempty"`
|
||||
HealthIndicators map[string]interface{} `json:"health_indicators,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// TestSchemaValidation tests that all JSON schemas are valid and messages conform
|
||||
func TestSchemaValidation(t *testing.T) {
|
||||
schemaDir := "../schemas"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
schemaFile string
|
||||
validMsgs []interface{}
|
||||
invalidMsgs []map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "BeatFrame Schema Validation",
|
||||
schemaFile: "beatframe-v1.schema.json",
|
||||
validMsgs: []interface{}{
|
||||
BeatFrame{
|
||||
Type: BeatFrameType,
|
||||
ClusterID: "test-cluster",
|
||||
BeatIndex: 100,
|
||||
Downbeat: false,
|
||||
Phase: "execute",
|
||||
HLC: "7ffd:0001:abcd",
|
||||
DeadlineAt: time.Now().Add(30 * time.Second),
|
||||
TempoBPM: 2.0,
|
||||
WindowID: "7e9b0e6c4c9a4e59b7f2d9a3c1b2e4d5",
|
||||
},
|
||||
BeatFrame{
|
||||
Type: BeatFrameType,
|
||||
ClusterID: "prod",
|
||||
BeatIndex: 0,
|
||||
Downbeat: true,
|
||||
Phase: "plan",
|
||||
HLC: "0001:0000:cafe",
|
||||
DeadlineAt: time.Now().Add(15 * time.Second),
|
||||
TempoBPM: 4.0,
|
||||
WindowID: "a1b2c3d4e5f6789012345678901234ab",
|
||||
Metadata: map[string]interface{}{
|
||||
"pulse_version": "1.0.0",
|
||||
"cluster_health": "healthy",
|
||||
},
|
||||
},
|
||||
},
|
||||
invalidMsgs: []map[string]interface{}{
|
||||
// Missing required fields
|
||||
{
|
||||
"type": BeatFrameType,
|
||||
"cluster_id": "test",
|
||||
// missing beat_index, downbeat, phase, etc.
|
||||
},
|
||||
// Invalid phase
|
||||
{
|
||||
"type": BeatFrameType,
|
||||
"cluster_id": "test",
|
||||
"beat_index": 0,
|
||||
"downbeat": false,
|
||||
"phase": "invalid_phase",
|
||||
"hlc": "7ffd:0001:abcd",
|
||||
"deadline_at": "2025-09-05T12:00:00Z",
|
||||
"tempo_bpm": 2.0,
|
||||
"window_id": "7e9b0e6c4c9a4e59b7f2d9a3c1b2e4d5",
|
||||
},
|
||||
// Invalid HLC format
|
||||
{
|
||||
"type": BeatFrameType,
|
||||
"cluster_id": "test",
|
||||
"beat_index": 0,
|
||||
"downbeat": false,
|
||||
"phase": "plan",
|
||||
"hlc": "invalid-hlc-format",
|
||||
"deadline_at": "2025-09-05T12:00:00Z",
|
||||
"tempo_bpm": 2.0,
|
||||
"window_id": "7e9b0e6c4c9a4e59b7f2d9a3c1b2e4d5",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "StatusClaim Schema Validation",
|
||||
schemaFile: "statusclaim-v1.schema.json",
|
||||
validMsgs: []interface{}{
|
||||
StatusClaim{
|
||||
Type: StatusClaimType,
|
||||
AgentID: "worker:test-01",
|
||||
TaskID: "task:123",
|
||||
BeatIndex: 100,
|
||||
State: "executing",
|
||||
BeatsLeft: 3,
|
||||
Progress: 0.5,
|
||||
Notes: "processing batch",
|
||||
HLC: "7ffd:0001:beef",
|
||||
},
|
||||
StatusClaim{
|
||||
Type: StatusClaimType,
|
||||
AgentID: "agent:backup",
|
||||
BeatIndex: 101,
|
||||
State: "idle",
|
||||
HLC: "7ffe:0002:dead",
|
||||
Resources: map[string]interface{}{
|
||||
"cpu_percent": 25.0,
|
||||
"memory_mb": 512,
|
||||
},
|
||||
},
|
||||
},
|
||||
invalidMsgs: []map[string]interface{}{
|
||||
// Missing required fields
|
||||
{
|
||||
"type": StatusClaimType,
|
||||
"agent_id": "test",
|
||||
// missing beat_index, state, hlc
|
||||
},
|
||||
// Invalid state
|
||||
{
|
||||
"type": StatusClaimType,
|
||||
"agent_id": "test",
|
||||
"beat_index": 0,
|
||||
"state": "invalid_state",
|
||||
"hlc": "7ffd:0001:abcd",
|
||||
},
|
||||
// Negative progress
|
||||
{
|
||||
"type": StatusClaimType,
|
||||
"agent_id": "test",
|
||||
"beat_index": 0,
|
||||
"state": "executing",
|
||||
"progress": -0.1,
|
||||
"hlc": "7ffd:0001:abcd",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BarReport Schema Validation",
|
||||
schemaFile: "barreport-v1.schema.json",
|
||||
validMsgs: []interface{}{
|
||||
BarReport{
|
||||
Type: BarReportType,
|
||||
WindowID: "7e9b0e6c4c9a4e59b7f2d9a3c1b2e4d5",
|
||||
FromBeat: 0,
|
||||
ToBeat: 119,
|
||||
AgentsReporting: 150,
|
||||
OnTimeReviews: 147,
|
||||
HelpPromisesFulfilled: 12,
|
||||
SecretRotationsOK: true,
|
||||
TempoDriftMS: -2.1,
|
||||
},
|
||||
BarReport{
|
||||
Type: BarReportType,
|
||||
WindowID: "a1b2c3d4e5f6789012345678901234ab",
|
||||
FromBeat: 120,
|
||||
ToBeat: 239,
|
||||
AgentsReporting: 200,
|
||||
OnTimeReviews: 195,
|
||||
HelpPromisesFulfilled: 25,
|
||||
SecretRotationsOK: false,
|
||||
TempoDriftMS: 15.7,
|
||||
Issues: []map[string]interface{}{
|
||||
{
|
||||
"severity": "warning",
|
||||
"category": "timing",
|
||||
"count": 5,
|
||||
"description": "Some agents running late",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invalidMsgs: []map[string]interface{}{
|
||||
// Missing required fields
|
||||
{
|
||||
"type": BarReportType,
|
||||
"window_id": "7e9b0e6c4c9a4e59b7f2d9a3c1b2e4d5",
|
||||
// missing from_beat, to_beat, etc.
|
||||
},
|
||||
// Invalid window_id format
|
||||
{
|
||||
"type": BarReportType,
|
||||
"window_id": "invalid-window-id",
|
||||
"from_beat": 0,
|
||||
"to_beat": 119,
|
||||
"agents_reporting": 150,
|
||||
"on_time_reviews": 147,
|
||||
"help_promises_fulfilled": 12,
|
||||
"secret_rotations_ok": true,
|
||||
"tempo_drift_ms": 0.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Load schema
|
||||
schemaPath := filepath.Join(schemaDir, tt.schemaFile)
|
||||
schemaLoader := gojsonschema.NewReferenceLoader("file://" + schemaPath)
|
||||
|
||||
// Test valid messages
|
||||
for i, validMsg := range tt.validMsgs {
|
||||
t.Run(fmt.Sprintf("Valid_%d", i), func(t *testing.T) {
|
||||
msgBytes, err := json.Marshal(validMsg)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal valid message: %v", err)
|
||||
}
|
||||
|
||||
docLoader := gojsonschema.NewBytesLoader(msgBytes)
|
||||
result, err := gojsonschema.Validate(schemaLoader, docLoader)
|
||||
if err != nil {
|
||||
t.Fatalf("Schema validation failed: %v", err)
|
||||
}
|
||||
|
||||
if !result.Valid() {
|
||||
t.Errorf("Valid message failed validation: %v", result.Errors())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Test invalid messages
|
||||
for i, invalidMsg := range tt.invalidMsgs {
|
||||
t.Run(fmt.Sprintf("Invalid_%d", i), func(t *testing.T) {
|
||||
msgBytes, err := json.Marshal(invalidMsg)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal invalid message: %v", err)
|
||||
}
|
||||
|
||||
docLoader := gojsonschema.NewBytesLoader(msgBytes)
|
||||
result, err := gojsonschema.Validate(schemaLoader, docLoader)
|
||||
if err != nil {
|
||||
t.Fatalf("Schema validation failed: %v", err)
|
||||
}
|
||||
|
||||
if result.Valid() {
|
||||
t.Errorf("Invalid message passed validation when it should have failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessageParsing tests that messages can be correctly parsed from JSON
|
||||
func TestMessageParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jsonStr string
|
||||
expected interface{}
|
||||
}{
|
||||
{
|
||||
name: "Parse BeatFrame",
|
||||
jsonStr: `{
|
||||
"type": "backbeat.beatframe.v1",
|
||||
"cluster_id": "test",
|
||||
"beat_index": 123,
|
||||
"downbeat": true,
|
||||
"phase": "review",
|
||||
"hlc": "7ffd:0001:abcd",
|
||||
"deadline_at": "2025-09-05T12:00:00Z",
|
||||
"tempo_bpm": 2.5,
|
||||
"window_id": "7e9b0e6c4c9a4e59b7f2d9a3c1b2e4d5"
|
||||
}`,
|
||||
expected: BeatFrame{
|
||||
Type: BeatFrameType,
|
||||
ClusterID: "test",
|
||||
BeatIndex: 123,
|
||||
Downbeat: true,
|
||||
Phase: "review",
|
||||
HLC: "7ffd:0001:abcd",
|
||||
TempoBPM: 2.5,
|
||||
WindowID: "7e9b0e6c4c9a4e59b7f2d9a3c1b2e4d5",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Parse StatusClaim",
|
||||
jsonStr: `{
|
||||
"type": "backbeat.statusclaim.v1",
|
||||
"agent_id": "worker:01",
|
||||
"beat_index": 456,
|
||||
"state": "completed",
|
||||
"progress": 1.0,
|
||||
"hlc": "7ffe:0002:beef"
|
||||
}`,
|
||||
expected: StatusClaim{
|
||||
Type: StatusClaimType,
|
||||
AgentID: "worker:01",
|
||||
BeatIndex: 456,
|
||||
State: "completed",
|
||||
Progress: 1.0,
|
||||
HLC: "7ffe:0002:beef",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
switch expected := tt.expected.(type) {
|
||||
case BeatFrame:
|
||||
var parsed BeatFrame
|
||||
err := json.Unmarshal([]byte(tt.jsonStr), &parsed)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse BeatFrame: %v", err)
|
||||
}
|
||||
|
||||
if parsed.Type != expected.Type ||
|
||||
parsed.ClusterID != expected.ClusterID ||
|
||||
parsed.BeatIndex != expected.BeatIndex {
|
||||
t.Errorf("Parsed BeatFrame doesn't match expected")
|
||||
}
|
||||
|
||||
case StatusClaim:
|
||||
var parsed StatusClaim
|
||||
err := json.Unmarshal([]byte(tt.jsonStr), &parsed)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse StatusClaim: %v", err)
|
||||
}
|
||||
|
||||
if parsed.Type != expected.Type ||
|
||||
parsed.AgentID != expected.AgentID ||
|
||||
parsed.State != expected.State {
|
||||
t.Errorf("Parsed StatusClaim doesn't match expected")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHLCValidation tests Hybrid Logical Clock format validation
|
||||
func TestHLCValidation(t *testing.T) {
|
||||
validHLCs := []string{
|
||||
"0000:0000:0000",
|
||||
"7ffd:0001:abcd",
|
||||
"FFFF:FFFF:FFFF",
|
||||
"1234:5678:90ab",
|
||||
}
|
||||
|
||||
invalidHLCs := []string{
|
||||
"invalid",
|
||||
"7ffd:0001", // too short
|
||||
"7ffd:0001:abcd:ef", // too long
|
||||
"gggg:0001:abcd", // invalid hex
|
||||
"7ffd:0001:abcdz", // invalid hex
|
||||
}
|
||||
|
||||
for _, hlc := range validHLCs {
|
||||
t.Run(fmt.Sprintf("Valid_%s", hlc), func(t *testing.T) {
|
||||
if !isValidHLC(hlc) {
|
||||
t.Errorf("Valid HLC %s was rejected", hlc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, hlc := range invalidHLCs {
|
||||
t.Run(fmt.Sprintf("Invalid_%s", hlc), func(t *testing.T) {
|
||||
if isValidHLC(hlc) {
|
||||
t.Errorf("Invalid HLC %s was accepted", hlc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowIDValidation tests window ID format validation
|
||||
func TestWindowIDValidation(t *testing.T) {
|
||||
validWindowIDs := []string{
|
||||
"7e9b0e6c4c9a4e59b7f2d9a3c1b2e4d5",
|
||||
"a1b2c3d4e5f6789012345678901234ab",
|
||||
"00000000000000000000000000000000",
|
||||
"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
|
||||
}
|
||||
|
||||
invalidWindowIDs := []string{
|
||||
"invalid",
|
||||
"7e9b0e6c4c9a4e59b7f2d9a3c1b2e4d", // too short
|
||||
"7e9b0e6c4c9a4e59b7f2d9a3c1b2e4d55", // too long
|
||||
"7e9b0e6c4c9a4e59b7f2d9a3c1b2e4g5", // invalid hex
|
||||
}
|
||||
|
||||
for _, windowID := range validWindowIDs {
|
||||
t.Run(fmt.Sprintf("Valid_%s", windowID), func(t *testing.T) {
|
||||
if !isValidWindowID(windowID) {
|
||||
t.Errorf("Valid window ID %s was rejected", windowID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, windowID := range invalidWindowIDs {
|
||||
t.Run(fmt.Sprintf("Invalid_%s", windowID), func(t *testing.T) {
|
||||
if isValidWindowID(windowID) {
|
||||
t.Errorf("Invalid window ID %s was accepted", windowID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for validation
|
||||
func isValidHLC(hlc string) bool {
|
||||
parts := strings.Split(hlc, ":")
|
||||
if len(parts) != 3 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, part := range parts {
|
||||
if len(part) != 4 {
|
||||
return false
|
||||
}
|
||||
for _, char := range part {
|
||||
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isValidWindowID(windowID string) bool {
|
||||
if len(windowID) != 32 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, char := range windowID {
|
||||
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// BenchmarkSchemaValidation benchmarks schema validation performance
|
||||
func BenchmarkSchemaValidation(b *testing.B) {
|
||||
schemaDir := "../schemas"
|
||||
schemaPath := filepath.Join(schemaDir, "beatframe-v1.schema.json")
|
||||
schemaLoader := gojsonschema.NewReferenceLoader("file://" + schemaPath)
|
||||
|
||||
beatFrame := BeatFrame{
|
||||
Type: BeatFrameType,
|
||||
ClusterID: "benchmark",
|
||||
BeatIndex: 1000,
|
||||
Downbeat: false,
|
||||
Phase: "execute",
|
||||
HLC: "7ffd:0001:abcd",
|
||||
DeadlineAt: time.Now().Add(30 * time.Second),
|
||||
TempoBPM: 2.0,
|
||||
WindowID: "7e9b0e6c4c9a4e59b7f2d9a3c1b2e4d5",
|
||||
}
|
||||
|
||||
msgBytes, _ := json.Marshal(beatFrame)
|
||||
docLoader := gojsonschema.NewBytesLoader(msgBytes)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
result, err := gojsonschema.Validate(schemaLoader, docLoader)
|
||||
if err != nil || !result.Valid() {
|
||||
b.Fatal("Validation failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if schema files exist
|
||||
func TestSchemaFilesExist(t *testing.T) {
|
||||
schemaDir := "../schemas"
|
||||
requiredSchemas := []string{
|
||||
"beatframe-v1.schema.json",
|
||||
"statusclaim-v1.schema.json",
|
||||
"barreport-v1.schema.json",
|
||||
}
|
||||
|
||||
for _, schema := range requiredSchemas {
|
||||
schemaPath := filepath.Join(schemaDir, schema)
|
||||
if _, err := os.Stat(schemaPath); os.IsNotExist(err) {
|
||||
t.Errorf("Required schema file %s does not exist", schemaPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user