533 lines
15 KiB
Go
533 lines
15 KiB
Go
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)
|
|
}
|
|
}
|
|
} |