Complete BZZZ functionality port to CHORUS
🎭 CHORUS now contains full BZZZ functionality adapted for containers Core systems ported: - P2P networking (libp2p with DHT and PubSub) - Task coordination (COOEE protocol) - HMMM collaborative reasoning - SHHH encryption and security - SLURP admin election system - UCXL content addressing - UCXI server integration - Hypercore logging system - Health monitoring and graceful shutdown - License validation with KACHING Container adaptations: - Environment variable configuration (no YAML files) - Container-optimized logging to stdout/stderr - Auto-generated agent IDs for container deployments - Docker-first architecture All proven BZZZ P2P protocols, AI integration, and collaboration features are now available in containerized form. Next: Build and test container deployment. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
599
pkg/ucxi/collaboration_integration_test.go
Normal file
599
pkg/ucxi/collaboration_integration_test.go
Normal file
@@ -0,0 +1,599 @@
|
||||
package ucxi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chorus.services/bzzz/pkg/ucxl"
|
||||
)
|
||||
|
||||
// Mock implementations for testing
|
||||
|
||||
type MockCollaborativeResolver struct {
|
||||
resolveResults map[string]*ResolvedContent
|
||||
announcements []string
|
||||
discoveries map[string][]*ResolvedContent
|
||||
}
|
||||
|
||||
func NewMockCollaborativeResolver() *MockCollaborativeResolver {
|
||||
return &MockCollaborativeResolver{
|
||||
resolveResults: make(map[string]*ResolvedContent),
|
||||
announcements: make([]string, 0),
|
||||
discoveries: make(map[string][]*ResolvedContent),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockCollaborativeResolver) Resolve(ctx context.Context, addr *ucxl.Address) (*ResolvedContent, error) {
|
||||
key := addr.String()
|
||||
if result, exists := m.resolveResults[key]; exists {
|
||||
return result, nil
|
||||
}
|
||||
return nil, fmt.Errorf("not found: %s", key)
|
||||
}
|
||||
|
||||
func (m *MockCollaborativeResolver) Announce(ctx context.Context, addr *ucxl.Address, content *Content) error {
|
||||
m.announcements = append(m.announcements, addr.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockCollaborativeResolver) Discover(ctx context.Context, pattern *ucxl.Address) ([]*ResolvedContent, error) {
|
||||
key := pattern.String()
|
||||
if results, exists := m.discoveries[key]; exists {
|
||||
return results, nil
|
||||
}
|
||||
return []*ResolvedContent{}, nil
|
||||
}
|
||||
|
||||
type MockCollaborativeStorage struct {
|
||||
contents map[string]*Content
|
||||
}
|
||||
|
||||
func NewMockCollaborativeStorage() *MockCollaborativeStorage {
|
||||
return &MockCollaborativeStorage{
|
||||
contents: make(map[string]*Content),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockCollaborativeStorage) Store(ctx context.Context, key string, content *Content) error {
|
||||
m.contents[key] = content
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockCollaborativeStorage) Retrieve(ctx context.Context, key string) (*Content, error) {
|
||||
if content, exists := m.contents[key]; exists {
|
||||
return content, nil
|
||||
}
|
||||
return nil, fmt.Errorf("not found: %s", key)
|
||||
}
|
||||
|
||||
func (m *MockCollaborativeStorage) Delete(ctx context.Context, key string) error {
|
||||
delete(m.contents, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockCollaborativeStorage) List(ctx context.Context, prefix string) ([]string, error) {
|
||||
keys := make([]string, 0)
|
||||
for key := range m.contents {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
type MockCollaborativeLogger struct{}
|
||||
|
||||
func (l MockCollaborativeLogger) Info(msg string, fields ...interface{}) {}
|
||||
func (l MockCollaborativeLogger) Warn(msg string, fields ...interface{}) {}
|
||||
func (l MockCollaborativeLogger) Error(msg string, fields ...interface{}) {}
|
||||
func (l MockCollaborativeLogger) Debug(msg string, fields ...interface{}) {}
|
||||
|
||||
// Integration tests for role-based collaboration features
|
||||
|
||||
func TestCollaborationStatusEndpoint(t *testing.T) {
|
||||
// Setup server with mock dependencies
|
||||
resolver := NewMockCollaborativeResolver()
|
||||
storage := NewMockCollaborativeStorage()
|
||||
logger := MockCollaborativeLogger{}
|
||||
|
||||
config := ServerConfig{
|
||||
Port: 8080,
|
||||
BasePath: "/api",
|
||||
Resolver: resolver,
|
||||
Storage: storage,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
server := NewServer(config)
|
||||
|
||||
// Test GET /collaboration endpoint
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ucxi/v1/collaboration", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleCollaboration(w, req)
|
||||
|
||||
// Verify response
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Response struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
System struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"system"`
|
||||
ActiveSessions []map[string]interface{} `json:"active_sessions"`
|
||||
} `json:"data"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Response.Code != "UCXL-200-SUCCESS" {
|
||||
t.Errorf("Expected code UCXL-200-SUCCESS, got %s", response.Response.Code)
|
||||
}
|
||||
|
||||
if !response.Response.Data.System.Enabled {
|
||||
t.Error("Expected collaboration system to be enabled")
|
||||
}
|
||||
|
||||
if len(response.Response.Data.ActiveSessions) == 0 {
|
||||
t.Error("Expected at least one active collaboration session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollaborationInitiation(t *testing.T) {
|
||||
// Setup server
|
||||
resolver := NewMockCollaborativeResolver()
|
||||
storage := NewMockCollaborativeStorage()
|
||||
logger := MockCollaborativeLogger{}
|
||||
|
||||
config := ServerConfig{
|
||||
Port: 8080,
|
||||
BasePath: "/api",
|
||||
Resolver: resolver,
|
||||
Storage: storage,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
server := NewServer(config)
|
||||
|
||||
// Test POST /collaboration endpoint
|
||||
requestBody := map[string]interface{}{
|
||||
"type": "expertise_request",
|
||||
"from_role": "junior_developer",
|
||||
"to_roles": []string{"senior_developer", "tech_lead"},
|
||||
"required_expertise": []string{"api_design", "error_handling"},
|
||||
"project_id": "bzzz",
|
||||
"priority": "medium",
|
||||
"data": map[string]interface{}{
|
||||
"context": "Working on UCXI API standardization",
|
||||
"specific_question": "How to handle nested error chains in UCXL responses?",
|
||||
},
|
||||
}
|
||||
|
||||
reqBody, _ := json.Marshal(requestBody)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ucxi/v1/collaboration", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleCollaboration(w, req)
|
||||
|
||||
// Verify response
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("Expected status 201, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Response struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
CollaborationInitiated bool `json:"collaboration_initiated"`
|
||||
ThreadID string `json:"thread_id"`
|
||||
Type string `json:"type"`
|
||||
FromRole string `json:"from_role"`
|
||||
Status string `json:"status"`
|
||||
ExpectedResponseTime string `json:"expected_response_time"`
|
||||
Routing string `json:"routing"`
|
||||
} `json:"data"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Response.Code != "UCXL-201-CREATED" {
|
||||
t.Errorf("Expected code UCXL-201-CREATED, got %s", response.Response.Code)
|
||||
}
|
||||
|
||||
if !response.Response.Data.CollaborationInitiated {
|
||||
t.Error("Expected collaboration to be initiated")
|
||||
}
|
||||
|
||||
if response.Response.Data.Type != "expertise_request" {
|
||||
t.Errorf("Expected type expertise_request, got %s", response.Response.Data.Type)
|
||||
}
|
||||
|
||||
if response.Response.Data.FromRole != "junior_developer" {
|
||||
t.Errorf("Expected from_role junior_developer, got %s", response.Response.Data.FromRole)
|
||||
}
|
||||
|
||||
if response.Response.Data.Status != "initiated" {
|
||||
t.Errorf("Expected status initiated, got %s", response.Response.Data.Status)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(response.Response.Data.ThreadID, "thread-expertise_request-") {
|
||||
t.Errorf("Expected thread ID to start with 'thread-expertise_request-', got %s", response.Response.Data.ThreadID)
|
||||
}
|
||||
|
||||
if response.Response.Data.ExpectedResponseTime != "15m" {
|
||||
t.Errorf("Expected expected_response_time 15m, got %s", response.Response.Data.ExpectedResponseTime)
|
||||
}
|
||||
|
||||
if response.Response.Data.Routing != "expertise_based" {
|
||||
t.Errorf("Expected routing expertise_based, got %s", response.Response.Data.Routing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollaborationValidationErrors(t *testing.T) {
|
||||
// Setup server
|
||||
resolver := NewMockCollaborativeResolver()
|
||||
storage := NewMockCollaborativeStorage()
|
||||
logger := MockCollaborativeLogger{}
|
||||
|
||||
config := ServerConfig{
|
||||
Port: 8080,
|
||||
BasePath: "/api",
|
||||
Resolver: resolver,
|
||||
Storage: storage,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
server := NewServer(config)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
requestBody map[string]interface{}
|
||||
expectedStatus int
|
||||
expectedCode string
|
||||
}{
|
||||
{
|
||||
name: "Missing type",
|
||||
requestBody: map[string]interface{}{"from_role": "junior_developer"},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedCode: "UCXL-400-INVALID_PAYLOAD",
|
||||
},
|
||||
{
|
||||
name: "Missing from_role",
|
||||
requestBody: map[string]interface{}{"type": "expertise_request"},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedCode: "UCXL-400-INVALID_PAYLOAD",
|
||||
},
|
||||
{
|
||||
name: "Invalid JSON",
|
||||
requestBody: nil, // Will send invalid JSON
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectedCode: "UCXL-400-BAD_REQUEST",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var reqBody []byte
|
||||
var err error
|
||||
|
||||
if tt.requestBody != nil {
|
||||
reqBody, err = json.Marshal(tt.requestBody)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal request body: %v", err)
|
||||
}
|
||||
} else {
|
||||
reqBody = []byte("invalid json")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ucxi/v1/collaboration", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleCollaboration(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode error response: %v", err)
|
||||
}
|
||||
|
||||
if response.Error.Code != tt.expectedCode {
|
||||
t.Errorf("Expected code %s, got %s", tt.expectedCode, response.Error.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnhancedStatusEndpoint(t *testing.T) {
|
||||
// Setup server
|
||||
resolver := NewMockCollaborativeResolver()
|
||||
storage := NewMockCollaborativeStorage()
|
||||
logger := MockCollaborativeLogger{}
|
||||
|
||||
config := ServerConfig{
|
||||
Port: 8080,
|
||||
BasePath: "/api",
|
||||
Resolver: resolver,
|
||||
Storage: storage,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
server := NewServer(config)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ucxi/v1/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Response struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Server map[string]interface{} `json:"server"`
|
||||
Collaboration map[string]interface{} `json:"collaboration"`
|
||||
HmmmIntegration map[string]interface{} `json:"hmmm_integration"`
|
||||
} `json:"data"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Response.Code != "UCXL-200-SUCCESS" {
|
||||
t.Errorf("Expected code UCXL-200-SUCCESS, got %s", response.Response.Code)
|
||||
}
|
||||
|
||||
// Verify server version is updated
|
||||
if version, ok := response.Response.Data.Server["version"].(string); ok {
|
||||
if version != "2.1.0" {
|
||||
t.Errorf("Expected server version 2.1.0, got %s", version)
|
||||
}
|
||||
} else {
|
||||
t.Error("Expected server version to be present")
|
||||
}
|
||||
|
||||
// Verify collaboration status
|
||||
if enabled, ok := response.Response.Data.Collaboration["enabled"].(bool); ok {
|
||||
if !enabled {
|
||||
t.Error("Expected collaboration to be enabled")
|
||||
}
|
||||
} else {
|
||||
t.Error("Expected collaboration enabled status to be present")
|
||||
}
|
||||
|
||||
// Verify HMMM integration status
|
||||
if enabled, ok := response.Response.Data.HmmmIntegration["enabled"].(bool); ok {
|
||||
if !enabled {
|
||||
t.Error("Expected HMMM integration to be enabled")
|
||||
}
|
||||
} else {
|
||||
t.Error("Expected HMMM integration enabled status to be present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollaborationFiltering(t *testing.T) {
|
||||
// Setup server
|
||||
resolver := NewMockCollaborativeResolver()
|
||||
storage := NewMockCollaborativeStorage()
|
||||
logger := MockCollaborativeLogger{}
|
||||
|
||||
config := ServerConfig{
|
||||
Port: 8080,
|
||||
BasePath: "/api",
|
||||
Resolver: resolver,
|
||||
Storage: storage,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
server := NewServer(config)
|
||||
|
||||
// Test with role filter
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ucxi/v1/collaboration?role=senior_developer", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleCollaboration(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Response struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
FiltersApplied struct {
|
||||
Role string `json:"role"`
|
||||
} `json:"filters_applied"`
|
||||
FilteredResults map[string]interface{} `json:"filtered_results"`
|
||||
} `json:"data"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Response.Data.FiltersApplied.Role != "senior_developer" {
|
||||
t.Errorf("Expected role filter senior_developer, got %s", response.Response.Data.FiltersApplied.Role)
|
||||
}
|
||||
|
||||
if response.Response.Data.FilteredResults == nil {
|
||||
t.Error("Expected filtered results to be present when filters are applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMethodNotAllowedHandling(t *testing.T) {
|
||||
// Setup server
|
||||
resolver := NewMockCollaborativeResolver()
|
||||
storage := NewMockCollaborativeStorage()
|
||||
logger := MockCollaborativeLogger{}
|
||||
|
||||
config := ServerConfig{
|
||||
Port: 8080,
|
||||
BasePath: "/api",
|
||||
Resolver: resolver,
|
||||
Storage: storage,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
server := NewServer(config)
|
||||
|
||||
// Test unsupported method
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/ucxi/v1/collaboration", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleCollaboration(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("Expected status 405, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Details struct {
|
||||
AllowedMethods []string `json:"allowed_methods"`
|
||||
} `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Error.Code != "UCXL-405-METHOD_NOT_ALLOWED" {
|
||||
t.Errorf("Expected code UCXL-405-METHOD_NOT_ALLOWED, got %s", response.Error.Code)
|
||||
}
|
||||
|
||||
expectedMethods := []string{"GET", "POST"}
|
||||
if len(response.Error.Details.AllowedMethods) != len(expectedMethods) {
|
||||
t.Errorf("Expected %d allowed methods, got %d", len(expectedMethods), len(response.Error.Details.AllowedMethods))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDHandling(t *testing.T) {
|
||||
// Setup server
|
||||
resolver := NewMockCollaborativeResolver()
|
||||
storage := NewMockCollaborativeStorage()
|
||||
logger := MockCollaborativeLogger{}
|
||||
|
||||
config := ServerConfig{
|
||||
Port: 8080,
|
||||
BasePath: "/api",
|
||||
Resolver: resolver,
|
||||
Storage: storage,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
server := NewServer(config)
|
||||
|
||||
// Test with custom request ID
|
||||
customRequestID := "test-request-123"
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ucxi/v1/collaboration", nil)
|
||||
req.Header.Set("X-Request-ID", customRequestID)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleCollaboration(w, req)
|
||||
|
||||
var response struct {
|
||||
Response struct {
|
||||
RequestID string `json:"request_id"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Response.RequestID != customRequestID {
|
||||
t.Errorf("Expected request ID %s, got %s", customRequestID, response.Response.RequestID)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
|
||||
func BenchmarkCollaborationStatusEndpoint(b *testing.B) {
|
||||
resolver := NewMockCollaborativeResolver()
|
||||
storage := NewMockCollaborativeStorage()
|
||||
logger := MockCollaborativeLogger{}
|
||||
|
||||
config := ServerConfig{
|
||||
Port: 8080,
|
||||
BasePath: "/api",
|
||||
Resolver: resolver,
|
||||
Storage: storage,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
server := NewServer(config)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ucxi/v1/collaboration", nil)
|
||||
w := httptest.NewRecorder()
|
||||
server.handleCollaboration(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCollaborationInitiation(b *testing.B) {
|
||||
resolver := NewMockCollaborativeResolver()
|
||||
storage := NewMockCollaborativeStorage()
|
||||
logger := MockCollaborativeLogger{}
|
||||
|
||||
config := ServerConfig{
|
||||
Port: 8080,
|
||||
BasePath: "/api",
|
||||
Resolver: resolver,
|
||||
Storage: storage,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
server := NewServer(config)
|
||||
|
||||
requestBody := map[string]interface{}{
|
||||
"type": "expertise_request",
|
||||
"from_role": "junior_developer",
|
||||
"to_roles": []string{"senior_developer"},
|
||||
"data": map[string]interface{}{"context": "test"},
|
||||
}
|
||||
|
||||
reqBodyBytes, _ := json.Marshal(requestBody)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/ucxi/v1/collaboration", bytes.NewReader(reqBodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
server.handleCollaboration(w, req)
|
||||
}
|
||||
}
|
||||
246
pkg/ucxi/resolver.go
Normal file
246
pkg/ucxi/resolver.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package ucxi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chorus.services/bzzz/pkg/ucxl"
|
||||
)
|
||||
|
||||
// BasicAddressResolver provides a basic implementation of AddressResolver
|
||||
type BasicAddressResolver struct {
|
||||
// In-memory registry for announced content
|
||||
registry map[string]*ResolvedContent
|
||||
mutex sync.RWMutex
|
||||
|
||||
// P2P integration hooks (to be implemented later)
|
||||
announceHook func(ctx context.Context, addr *ucxl.Address, content *Content) error
|
||||
discoverHook func(ctx context.Context, pattern *ucxl.Address) ([]*ResolvedContent, error)
|
||||
|
||||
// Configuration
|
||||
defaultTTL time.Duration
|
||||
nodeID string
|
||||
}
|
||||
|
||||
// NewBasicAddressResolver creates a new basic address resolver
|
||||
func NewBasicAddressResolver(nodeID string) *BasicAddressResolver {
|
||||
return &BasicAddressResolver{
|
||||
registry: make(map[string]*ResolvedContent),
|
||||
defaultTTL: 5 * time.Minute,
|
||||
nodeID: nodeID,
|
||||
}
|
||||
}
|
||||
|
||||
// SetAnnounceHook sets a hook function for content announcements (for P2P integration)
|
||||
func (r *BasicAddressResolver) SetAnnounceHook(hook func(ctx context.Context, addr *ucxl.Address, content *Content) error) {
|
||||
r.announceHook = hook
|
||||
}
|
||||
|
||||
// SetDiscoverHook sets a hook function for content discovery (for P2P integration)
|
||||
func (r *BasicAddressResolver) SetDiscoverHook(hook func(ctx context.Context, pattern *ucxl.Address) ([]*ResolvedContent, error)) {
|
||||
r.discoverHook = hook
|
||||
}
|
||||
|
||||
// Resolve resolves a UCXL address to content
|
||||
func (r *BasicAddressResolver) Resolve(ctx context.Context, addr *ucxl.Address) (*ResolvedContent, error) {
|
||||
if addr == nil {
|
||||
return nil, fmt.Errorf("address cannot be nil")
|
||||
}
|
||||
|
||||
key := r.generateRegistryKey(addr)
|
||||
|
||||
r.mutex.RLock()
|
||||
resolved, exists := r.registry[key]
|
||||
r.mutex.RUnlock()
|
||||
|
||||
if exists {
|
||||
// Check if content is still valid (TTL)
|
||||
if time.Now().Before(resolved.Resolved.Add(resolved.TTL)) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// Content expired, remove from registry
|
||||
r.mutex.Lock()
|
||||
delete(r.registry, key)
|
||||
r.mutex.Unlock()
|
||||
}
|
||||
|
||||
// Try wildcard matching if exact match not found
|
||||
if !exists {
|
||||
if match := r.findWildcardMatch(addr); match != nil {
|
||||
return match, nil
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a discover hook, try P2P discovery
|
||||
if r.discoverHook != nil {
|
||||
results, err := r.discoverHook(ctx, addr)
|
||||
if err == nil && len(results) > 0 {
|
||||
// Cache the first result and return it
|
||||
result := results[0]
|
||||
r.cacheResolvedContent(key, result)
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("address not found: %s", addr.String())
|
||||
}
|
||||
|
||||
// Announce announces content at a UCXL address
|
||||
func (r *BasicAddressResolver) Announce(ctx context.Context, addr *ucxl.Address, content *Content) error {
|
||||
if addr == nil {
|
||||
return fmt.Errorf("address cannot be nil")
|
||||
}
|
||||
if content == nil {
|
||||
return fmt.Errorf("content cannot be nil")
|
||||
}
|
||||
|
||||
key := r.generateRegistryKey(addr)
|
||||
|
||||
resolved := &ResolvedContent{
|
||||
Address: addr,
|
||||
Content: content,
|
||||
Source: r.nodeID,
|
||||
Resolved: time.Now(),
|
||||
TTL: r.defaultTTL,
|
||||
}
|
||||
|
||||
// Store in local registry
|
||||
r.mutex.Lock()
|
||||
r.registry[key] = resolved
|
||||
r.mutex.Unlock()
|
||||
|
||||
// Call P2P announce hook if available
|
||||
if r.announceHook != nil {
|
||||
if err := r.announceHook(ctx, addr, content); err != nil {
|
||||
// Log but don't fail - local announcement succeeded
|
||||
// In a real implementation, this would be logged properly
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Discover discovers content matching a pattern
|
||||
func (r *BasicAddressResolver) Discover(ctx context.Context, pattern *ucxl.Address) ([]*ResolvedContent, error) {
|
||||
if pattern == nil {
|
||||
return nil, fmt.Errorf("pattern cannot be nil")
|
||||
}
|
||||
|
||||
var results []*ResolvedContent
|
||||
|
||||
// Search local registry
|
||||
r.mutex.RLock()
|
||||
for _, resolved := range r.registry {
|
||||
// Check if content is still valid (TTL)
|
||||
if time.Now().After(resolved.Resolved.Add(resolved.TTL)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if address matches pattern
|
||||
if resolved.Address.Matches(pattern) {
|
||||
results = append(results, resolved)
|
||||
}
|
||||
}
|
||||
r.mutex.RUnlock()
|
||||
|
||||
// Try P2P discovery if hook is available
|
||||
if r.discoverHook != nil {
|
||||
p2pResults, err := r.discoverHook(ctx, pattern)
|
||||
if err == nil {
|
||||
// Merge P2P results with local results
|
||||
// Cache P2P results for future use
|
||||
for _, result := range p2pResults {
|
||||
key := r.generateRegistryKey(result.Address)
|
||||
r.cacheResolvedContent(key, result)
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// findWildcardMatch searches for wildcard matches in the registry
|
||||
func (r *BasicAddressResolver) findWildcardMatch(target *ucxl.Address) *ResolvedContent {
|
||||
r.mutex.RLock()
|
||||
defer r.mutex.RUnlock()
|
||||
|
||||
for _, resolved := range r.registry {
|
||||
// Check if content is still valid (TTL)
|
||||
if time.Now().After(resolved.Resolved.Add(resolved.TTL)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if target matches the registered address pattern
|
||||
if target.Matches(resolved.Address) {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateRegistryKey generates a unique key for registry storage
|
||||
func (r *BasicAddressResolver) generateRegistryKey(addr *ucxl.Address) string {
|
||||
return fmt.Sprintf("%s:%s@%s:%s/%s",
|
||||
addr.Agent, addr.Role, addr.Project, addr.Task, addr.TemporalSegment.String())
|
||||
}
|
||||
|
||||
// cacheResolvedContent caches resolved content in the local registry
|
||||
func (r *BasicAddressResolver) cacheResolvedContent(key string, resolved *ResolvedContent) {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
r.registry[key] = resolved
|
||||
}
|
||||
|
||||
// GetRegistryStats returns statistics about the registry
|
||||
func (r *BasicAddressResolver) GetRegistryStats() map[string]interface{} {
|
||||
r.mutex.RLock()
|
||||
defer r.mutex.RUnlock()
|
||||
|
||||
active := 0
|
||||
expired := 0
|
||||
now := time.Now()
|
||||
|
||||
for _, resolved := range r.registry {
|
||||
if now.Before(resolved.Resolved.Add(resolved.TTL)) {
|
||||
active++
|
||||
} else {
|
||||
expired++
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_entries": len(r.registry),
|
||||
"active_entries": active,
|
||||
"expired_entries": expired,
|
||||
"node_id": r.nodeID,
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupExpired removes expired entries from the registry
|
||||
func (r *BasicAddressResolver) CleanupExpired() int {
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
removed := 0
|
||||
|
||||
for key, resolved := range r.registry {
|
||||
if now.After(resolved.Resolved.Add(resolved.TTL)) {
|
||||
delete(r.registry, key)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
// SetDefaultTTL sets the default TTL for cached content
|
||||
func (r *BasicAddressResolver) SetDefaultTTL(ttl time.Duration) {
|
||||
r.defaultTTL = ttl
|
||||
}
|
||||
459
pkg/ucxi/resolver_test.go
Normal file
459
pkg/ucxi/resolver_test.go
Normal file
@@ -0,0 +1,459 @@
|
||||
package ucxi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chorus.services/bzzz/pkg/ucxl"
|
||||
)
|
||||
|
||||
func TestNewBasicAddressResolver(t *testing.T) {
|
||||
nodeID := "test-node-123"
|
||||
resolver := NewBasicAddressResolver(nodeID)
|
||||
|
||||
if resolver == nil {
|
||||
t.Error("NewBasicAddressResolver should not return nil")
|
||||
}
|
||||
|
||||
if resolver.nodeID != nodeID {
|
||||
t.Errorf("Node ID = %s, want %s", resolver.nodeID, nodeID)
|
||||
}
|
||||
|
||||
if resolver.registry == nil {
|
||||
t.Error("Registry should be initialized")
|
||||
}
|
||||
|
||||
if resolver.defaultTTL == 0 {
|
||||
t.Error("Default TTL should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverAnnounceAndResolve(t *testing.T) {
|
||||
resolver := NewBasicAddressResolver("test-node")
|
||||
ctx := context.Background()
|
||||
|
||||
addr, err := ucxl.Parse("ucxl://agent1:developer@project1:task1/*^")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse address: %v", err)
|
||||
}
|
||||
|
||||
content := &Content{
|
||||
Data: []byte("test content"),
|
||||
ContentType: "text/plain",
|
||||
Metadata: map[string]string{"version": "1.0"},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Test announce
|
||||
err = resolver.Announce(ctx, addr, content)
|
||||
if err != nil {
|
||||
t.Errorf("Announce failed: %v", err)
|
||||
}
|
||||
|
||||
// Test resolve
|
||||
resolved, err := resolver.Resolve(ctx, addr)
|
||||
if err != nil {
|
||||
t.Errorf("Resolve failed: %v", err)
|
||||
}
|
||||
|
||||
if resolved == nil {
|
||||
t.Error("Resolved content should not be nil")
|
||||
}
|
||||
|
||||
if string(resolved.Content.Data) != "test content" {
|
||||
t.Errorf("Content data = %s, want 'test content'", string(resolved.Content.Data))
|
||||
}
|
||||
|
||||
if resolved.Source != "test-node" {
|
||||
t.Errorf("Source = %s, want 'test-node'", resolved.Source)
|
||||
}
|
||||
|
||||
if resolved.Address.String() != addr.String() {
|
||||
t.Errorf("Address mismatch: got %s, want %s", resolved.Address.String(), addr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverTTLExpiration(t *testing.T) {
|
||||
resolver := NewBasicAddressResolver("test-node")
|
||||
resolver.SetDefaultTTL(50 * time.Millisecond) // Very short TTL for testing
|
||||
|
||||
ctx := context.Background()
|
||||
addr, _ := ucxl.Parse("ucxl://agent1:developer@project1:task1/*^")
|
||||
content := &Content{Data: []byte("test")}
|
||||
|
||||
// Announce content
|
||||
resolver.Announce(ctx, addr, content)
|
||||
|
||||
// Should resolve immediately
|
||||
resolved, err := resolver.Resolve(ctx, addr)
|
||||
if err != nil {
|
||||
t.Errorf("Immediate resolve failed: %v", err)
|
||||
}
|
||||
if resolved == nil {
|
||||
t.Error("Content should be found immediately after announce")
|
||||
}
|
||||
|
||||
// Wait for TTL expiration
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Should fail to resolve after TTL expiration
|
||||
resolved, err = resolver.Resolve(ctx, addr)
|
||||
if err == nil {
|
||||
t.Error("Resolve should fail after TTL expiration")
|
||||
}
|
||||
if resolved != nil {
|
||||
t.Error("Resolved content should be nil after TTL expiration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverWildcardMatching(t *testing.T) {
|
||||
resolver := NewBasicAddressResolver("test-node")
|
||||
ctx := context.Background()
|
||||
|
||||
// Announce content with wildcard address
|
||||
wildcardAddr, _ := ucxl.Parse("ucxl://any:any@project1:task1/*^")
|
||||
content := &Content{Data: []byte("wildcard content")}
|
||||
resolver.Announce(ctx, wildcardAddr, content)
|
||||
|
||||
// Try to resolve with specific address
|
||||
specificAddr, _ := ucxl.Parse("ucxl://agent1:developer@project1:task1/*^")
|
||||
resolved, err := resolver.Resolve(ctx, specificAddr)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Wildcard resolve failed: %v", err)
|
||||
}
|
||||
|
||||
if resolved == nil {
|
||||
t.Error("Should resolve specific address against wildcard pattern")
|
||||
}
|
||||
|
||||
if string(resolved.Content.Data) != "wildcard content" {
|
||||
t.Error("Should return wildcard content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverDiscover(t *testing.T) {
|
||||
resolver := NewBasicAddressResolver("test-node")
|
||||
ctx := context.Background()
|
||||
|
||||
// Announce several pieces of content
|
||||
addresses := []string{
|
||||
"ucxl://agent1:developer@project1:task1/*^",
|
||||
"ucxl://agent2:developer@project1:task2/*^",
|
||||
"ucxl://agent1:tester@project2:task1/*^",
|
||||
"ucxl://agent3:admin@project1:task3/*^",
|
||||
}
|
||||
|
||||
for i, addrStr := range addresses {
|
||||
addr, _ := ucxl.Parse(addrStr)
|
||||
content := &Content{Data: []byte(fmt.Sprintf("content-%d", i))}
|
||||
resolver.Announce(ctx, addr, content)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
expectedCount int
|
||||
minCount int
|
||||
}{
|
||||
{
|
||||
name: "find all project1 tasks",
|
||||
pattern: "ucxl://any:any@project1:any/*^",
|
||||
minCount: 3, // Should match 3 project1 addresses
|
||||
},
|
||||
{
|
||||
name: "find all developer roles",
|
||||
pattern: "ucxl://any:developer@any:any/*^",
|
||||
minCount: 2, // Should match 2 developer addresses
|
||||
},
|
||||
{
|
||||
name: "find specific address",
|
||||
pattern: "ucxl://agent1:developer@project1:task1/*^",
|
||||
minCount: 1, // Should match exactly 1
|
||||
},
|
||||
{
|
||||
name: "find non-existent pattern",
|
||||
pattern: "ucxl://nonexistent:role@project:task/*^",
|
||||
minCount: 0, // Should match none
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pattern, _ := ucxl.Parse(tt.pattern)
|
||||
results, err := resolver.Discover(ctx, pattern)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Discover failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) < tt.minCount {
|
||||
t.Errorf("Results count = %d, want at least %d", len(results), tt.minCount)
|
||||
}
|
||||
|
||||
// Verify all results match the pattern
|
||||
for _, result := range results {
|
||||
if !result.Address.Matches(pattern) {
|
||||
t.Errorf("Result address %s does not match pattern %s",
|
||||
result.Address.String(), pattern.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverHooks(t *testing.T) {
|
||||
resolver := NewBasicAddressResolver("test-node")
|
||||
ctx := context.Background()
|
||||
|
||||
var announceHookCalled bool
|
||||
var discoverHookCalled bool
|
||||
|
||||
// Set announce hook
|
||||
resolver.SetAnnounceHook(func(ctx context.Context, addr *ucxl.Address, content *Content) error {
|
||||
announceHookCalled = true
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set discover hook
|
||||
resolver.SetDiscoverHook(func(ctx context.Context, pattern *ucxl.Address) ([]*ResolvedContent, error) {
|
||||
discoverHookCalled = true
|
||||
return []*ResolvedContent{}, nil
|
||||
})
|
||||
|
||||
addr, _ := ucxl.Parse("ucxl://agent1:developer@project1:task1/*^")
|
||||
content := &Content{Data: []byte("test")}
|
||||
|
||||
// Test announce hook
|
||||
resolver.Announce(ctx, addr, content)
|
||||
if !announceHookCalled {
|
||||
t.Error("Announce hook should be called")
|
||||
}
|
||||
|
||||
// Test discover hook (when address not found locally)
|
||||
nonExistentAddr, _ := ucxl.Parse("ucxl://nonexistent:agent@project:task/*^")
|
||||
resolver.Discover(ctx, nonExistentAddr)
|
||||
if !discoverHookCalled {
|
||||
t.Error("Discover hook should be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverCleanupExpired(t *testing.T) {
|
||||
resolver := NewBasicAddressResolver("test-node")
|
||||
resolver.SetDefaultTTL(50 * time.Millisecond) // Short TTL for testing
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Add several entries
|
||||
for i := 0; i < 5; i++ {
|
||||
addr, _ := ucxl.Parse(fmt.Sprintf("ucxl://agent%d:developer@project:task/*^", i))
|
||||
content := &Content{Data: []byte(fmt.Sprintf("content-%d", i))}
|
||||
resolver.Announce(ctx, addr, content)
|
||||
}
|
||||
|
||||
// Wait for TTL expiration
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Cleanup expired entries
|
||||
removed := resolver.CleanupExpired()
|
||||
if removed != 5 {
|
||||
t.Errorf("Cleanup removed %d entries, want 5", removed)
|
||||
}
|
||||
|
||||
// Verify all entries are gone
|
||||
stats := resolver.GetRegistryStats()
|
||||
activeEntries := stats["active_entries"].(int)
|
||||
if activeEntries != 0 {
|
||||
t.Errorf("Active entries = %d, want 0 after cleanup", activeEntries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverGetRegistryStats(t *testing.T) {
|
||||
resolver := NewBasicAddressResolver("test-node-123")
|
||||
ctx := context.Background()
|
||||
|
||||
// Initially should have no entries
|
||||
stats := resolver.GetRegistryStats()
|
||||
if stats["total_entries"].(int) != 0 {
|
||||
t.Error("Should start with 0 entries")
|
||||
}
|
||||
if stats["node_id"].(string) != "test-node-123" {
|
||||
t.Error("Node ID should match")
|
||||
}
|
||||
|
||||
// Add some entries
|
||||
for i := 0; i < 3; i++ {
|
||||
addr, _ := ucxl.Parse(fmt.Sprintf("ucxl://agent%d:developer@project:task/*^", i))
|
||||
content := &Content{Data: []byte(fmt.Sprintf("content-%d", i))}
|
||||
resolver.Announce(ctx, addr, content)
|
||||
}
|
||||
|
||||
stats = resolver.GetRegistryStats()
|
||||
if stats["total_entries"].(int) != 3 {
|
||||
t.Errorf("Total entries = %d, want 3", stats["total_entries"])
|
||||
}
|
||||
if stats["active_entries"].(int) != 3 {
|
||||
t.Errorf("Active entries = %d, want 3", stats["active_entries"])
|
||||
}
|
||||
if stats["expired_entries"].(int) != 0 {
|
||||
t.Errorf("Expired entries = %d, want 0", stats["expired_entries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverErrorCases(t *testing.T) {
|
||||
resolver := NewBasicAddressResolver("test-node")
|
||||
ctx := context.Background()
|
||||
|
||||
// Test nil address in Resolve
|
||||
_, err := resolver.Resolve(ctx, nil)
|
||||
if err == nil {
|
||||
t.Error("Resolve with nil address should return error")
|
||||
}
|
||||
|
||||
// Test nil address in Announce
|
||||
content := &Content{Data: []byte("test")}
|
||||
err = resolver.Announce(ctx, nil, content)
|
||||
if err == nil {
|
||||
t.Error("Announce with nil address should return error")
|
||||
}
|
||||
|
||||
// Test nil content in Announce
|
||||
addr, _ := ucxl.Parse("ucxl://agent:role@project:task/*^")
|
||||
err = resolver.Announce(ctx, addr, nil)
|
||||
if err == nil {
|
||||
t.Error("Announce with nil content should return error")
|
||||
}
|
||||
|
||||
// Test nil pattern in Discover
|
||||
_, err = resolver.Discover(ctx, nil)
|
||||
if err == nil {
|
||||
t.Error("Discover with nil pattern should return error")
|
||||
}
|
||||
|
||||
// Test resolve non-existent address
|
||||
nonExistentAddr, _ := ucxl.Parse("ucxl://nonexistent:agent@project:task/*^")
|
||||
_, err = resolver.Resolve(ctx, nonExistentAddr)
|
||||
if err == nil {
|
||||
t.Error("Resolve non-existent address should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverSetDefaultTTL(t *testing.T) {
|
||||
resolver := NewBasicAddressResolver("test-node")
|
||||
|
||||
newTTL := 10 * time.Minute
|
||||
resolver.SetDefaultTTL(newTTL)
|
||||
|
||||
if resolver.defaultTTL != newTTL {
|
||||
t.Errorf("Default TTL = %v, want %v", resolver.defaultTTL, newTTL)
|
||||
}
|
||||
|
||||
// Test that new content uses the new TTL
|
||||
ctx := context.Background()
|
||||
addr, _ := ucxl.Parse("ucxl://agent:role@project:task/*^")
|
||||
content := &Content{Data: []byte("test")}
|
||||
|
||||
resolver.Announce(ctx, addr, content)
|
||||
resolved, _ := resolver.Resolve(ctx, addr)
|
||||
|
||||
if resolved.TTL != newTTL {
|
||||
t.Errorf("Resolved content TTL = %v, want %v", resolved.TTL, newTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// Test concurrent access to resolver
|
||||
func TestResolverConcurrency(t *testing.T) {
|
||||
resolver := NewBasicAddressResolver("test-node")
|
||||
ctx := context.Background()
|
||||
|
||||
// Run multiple goroutines that announce and resolve content
|
||||
done := make(chan bool, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int) {
|
||||
defer func() { done <- true }()
|
||||
|
||||
addr, _ := ucxl.Parse(fmt.Sprintf("ucxl://agent%d:developer@project:task/*^", id))
|
||||
content := &Content{Data: []byte(fmt.Sprintf("content-%d", id))}
|
||||
|
||||
// Announce
|
||||
if err := resolver.Announce(ctx, addr, content); err != nil {
|
||||
t.Errorf("Goroutine %d announce failed: %v", id, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve
|
||||
if _, err := resolver.Resolve(ctx, addr); err != nil {
|
||||
t.Errorf("Goroutine %d resolve failed: %v", id, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Discover
|
||||
pattern, _ := ucxl.Parse("ucxl://any:any@project:task/*^")
|
||||
if _, err := resolver.Discover(ctx, pattern); err != nil {
|
||||
t.Errorf("Goroutine %d discover failed: %v", id, err)
|
||||
return
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Verify final state
|
||||
stats := resolver.GetRegistryStats()
|
||||
if stats["total_entries"].(int) != 10 {
|
||||
t.Errorf("Expected 10 total entries, got %d", stats["total_entries"])
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkResolverAnnounce(b *testing.B) {
|
||||
resolver := NewBasicAddressResolver("test-node")
|
||||
ctx := context.Background()
|
||||
|
||||
addr, _ := ucxl.Parse("ucxl://agent:developer@project:task/*^")
|
||||
content := &Content{Data: []byte("test content")}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
resolver.Announce(ctx, addr, content)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkResolverResolve(b *testing.B) {
|
||||
resolver := NewBasicAddressResolver("test-node")
|
||||
ctx := context.Background()
|
||||
|
||||
addr, _ := ucxl.Parse("ucxl://agent:developer@project:task/*^")
|
||||
content := &Content{Data: []byte("test content")}
|
||||
resolver.Announce(ctx, addr, content)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
resolver.Resolve(ctx, addr)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkResolverDiscover(b *testing.B) {
|
||||
resolver := NewBasicAddressResolver("test-node")
|
||||
ctx := context.Background()
|
||||
|
||||
// Setup test data
|
||||
for i := 0; i < 100; i++ {
|
||||
addr, _ := ucxl.Parse(fmt.Sprintf("ucxl://agent%d:developer@project:task/*^", i))
|
||||
content := &Content{Data: []byte(fmt.Sprintf("content-%d", i))}
|
||||
resolver.Announce(ctx, addr, content)
|
||||
}
|
||||
|
||||
pattern, _ := ucxl.Parse("ucxl://any:developer@project:task/*^")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
resolver.Discover(ctx, pattern)
|
||||
}
|
||||
}
|
||||
1053
pkg/ucxi/server.go
Normal file
1053
pkg/ucxi/server.go
Normal file
File diff suppressed because it is too large
Load Diff
688
pkg/ucxi/server_test.go
Normal file
688
pkg/ucxi/server_test.go
Normal file
@@ -0,0 +1,688 @@
|
||||
package ucxi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chorus.services/bzzz/pkg/ucxl"
|
||||
)
|
||||
|
||||
// Mock implementations for testing
|
||||
|
||||
type MockResolver struct {
|
||||
storage map[string]*ResolvedContent
|
||||
announced map[string]*Content
|
||||
}
|
||||
|
||||
func NewMockResolver() *MockResolver {
|
||||
return &MockResolver{
|
||||
storage: make(map[string]*ResolvedContent),
|
||||
announced: make(map[string]*Content),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MockResolver) Resolve(ctx context.Context, addr *ucxl.Address) (*ResolvedContent, error) {
|
||||
key := addr.String()
|
||||
if content, exists := r.storage[key]; exists {
|
||||
return content, nil
|
||||
}
|
||||
return nil, fmt.Errorf("address not found: %s", key)
|
||||
}
|
||||
|
||||
func (r *MockResolver) Announce(ctx context.Context, addr *ucxl.Address, content *Content) error {
|
||||
key := addr.String()
|
||||
r.announced[key] = content
|
||||
r.storage[key] = &ResolvedContent{
|
||||
Address: addr,
|
||||
Content: content,
|
||||
Source: "test-node",
|
||||
Resolved: time.Now(),
|
||||
TTL: 5 * time.Minute,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MockResolver) Discover(ctx context.Context, pattern *ucxl.Address) ([]*ResolvedContent, error) {
|
||||
var results []*ResolvedContent
|
||||
for _, content := range r.storage {
|
||||
if content.Address.Matches(pattern) {
|
||||
results = append(results, content)
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
type MockStorage struct {
|
||||
storage map[string]*Content
|
||||
}
|
||||
|
||||
func NewMockStorage() *MockStorage {
|
||||
return &MockStorage{
|
||||
storage: make(map[string]*Content),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MockStorage) Store(ctx context.Context, key string, content *Content) error {
|
||||
s.storage[key] = content
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MockStorage) Retrieve(ctx context.Context, key string) (*Content, error) {
|
||||
if content, exists := s.storage[key]; exists {
|
||||
return content, nil
|
||||
}
|
||||
return nil, fmt.Errorf("content not found: %s", key)
|
||||
}
|
||||
|
||||
func (s *MockStorage) Delete(ctx context.Context, key string) error {
|
||||
delete(s.storage, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MockStorage) List(ctx context.Context, prefix string) ([]string, error) {
|
||||
var keys []string
|
||||
for key := range s.storage {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
type TestLogger struct{}
|
||||
|
||||
func (l TestLogger) Info(msg string, fields ...interface{}) {}
|
||||
func (l TestLogger) Warn(msg string, fields ...interface{}) {}
|
||||
func (l TestLogger) Error(msg string, fields ...interface{}) {}
|
||||
func (l TestLogger) Debug(msg string, fields ...interface{}) {}
|
||||
|
||||
func createTestServer() *Server {
|
||||
resolver := NewMockResolver()
|
||||
storage := NewMockStorage()
|
||||
|
||||
config := ServerConfig{
|
||||
Port: 8081,
|
||||
BasePath: "/test",
|
||||
Resolver: resolver,
|
||||
Storage: storage,
|
||||
Logger: TestLogger{},
|
||||
}
|
||||
|
||||
return NewServer(config)
|
||||
}
|
||||
|
||||
func TestNewServer(t *testing.T) {
|
||||
server := createTestServer()
|
||||
|
||||
if server == nil {
|
||||
t.Error("NewServer() should not return nil")
|
||||
}
|
||||
|
||||
if server.port != 8081 {
|
||||
t.Errorf("Port = %d, want 8081", server.port)
|
||||
}
|
||||
|
||||
if server.basePath != "/test" {
|
||||
t.Errorf("BasePath = %s, want /test", server.basePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGet(t *testing.T) {
|
||||
server := createTestServer()
|
||||
|
||||
// Add test content to resolver
|
||||
addr, _ := ucxl.Parse("ucxl://agent1:developer@project1:task1/*^")
|
||||
content := &Content{
|
||||
Data: []byte("test content"),
|
||||
ContentType: "text/plain",
|
||||
Metadata: make(map[string]string),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
server.resolver.Announce(context.Background(), addr, content)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
address string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "valid address",
|
||||
address: "ucxl://agent1:developer@project1:task1/*^",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "missing address",
|
||||
address: "",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "invalid address",
|
||||
address: "invalid-address",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "non-existent address",
|
||||
address: "ucxl://nonexistent:agent@project:task/*^",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/test/ucxi/v1/get?address=%s", tt.address), nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleGet(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Status code = %d, want %d", w.Code, tt.expectedStatus)
|
||||
}
|
||||
|
||||
var response Response
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Success = %v, want %v", response.Success, tt.expectSuccess)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePut(t *testing.T) {
|
||||
server := createTestServer()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
address string
|
||||
body string
|
||||
contentType string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "valid put request",
|
||||
address: "ucxl://agent1:developer@project1:task1/*^",
|
||||
body: "test content",
|
||||
contentType: "text/plain",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "missing address",
|
||||
address: "",
|
||||
body: "test content",
|
||||
contentType: "text/plain",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "invalid address",
|
||||
address: "invalid-address",
|
||||
body: "test content",
|
||||
contentType: "text/plain",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/test/ucxi/v1/put?address=%s", tt.address), strings.NewReader(tt.body))
|
||||
req.Header.Set("Content-Type", tt.contentType)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handlePut(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Status code = %d, want %d", w.Code, tt.expectedStatus)
|
||||
}
|
||||
|
||||
var response Response
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Success = %v, want %v", response.Success, tt.expectSuccess)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDelete(t *testing.T) {
|
||||
server := createTestServer()
|
||||
|
||||
// First, put some content
|
||||
addr, _ := ucxl.Parse("ucxl://agent1:developer@project1:task1/*^")
|
||||
content := &Content{Data: []byte("test")}
|
||||
key := server.generateStorageKey(addr)
|
||||
server.storage.Store(context.Background(), key, content)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
address string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "valid delete request",
|
||||
address: "ucxl://agent1:developer@project1:task1/*^",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "missing address",
|
||||
address: "",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "invalid address",
|
||||
address: "invalid-address",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/test/ucxi/v1/delete?address=%s", tt.address), nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleDelete(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Status code = %d, want %d", w.Code, tt.expectedStatus)
|
||||
}
|
||||
|
||||
var response Response
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Success = %v, want %v", response.Success, tt.expectSuccess)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAnnounce(t *testing.T) {
|
||||
server := createTestServer()
|
||||
|
||||
announceReq := struct {
|
||||
Address string `json:"address"`
|
||||
Content Content `json:"content"`
|
||||
}{
|
||||
Address: "ucxl://agent1:developer@project1:task1/*^",
|
||||
Content: Content{
|
||||
Data: []byte("test content"),
|
||||
ContentType: "text/plain",
|
||||
Metadata: make(map[string]string),
|
||||
},
|
||||
}
|
||||
|
||||
reqBody, _ := json.Marshal(announceReq)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/test/ucxi/v1/announce", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleAnnounce(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var response Response
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
t.Error("Announce should be successful")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDiscover(t *testing.T) {
|
||||
server := createTestServer()
|
||||
|
||||
// Add some test content
|
||||
addresses := []string{
|
||||
"ucxl://agent1:developer@project1:task1/*^",
|
||||
"ucxl://agent2:developer@project1:task2/*^",
|
||||
"ucxl://any:any@project1:any/*^",
|
||||
}
|
||||
|
||||
for _, addrStr := range addresses {
|
||||
addr, _ := ucxl.Parse(addrStr)
|
||||
content := &Content{Data: []byte("test")}
|
||||
server.resolver.Announce(context.Background(), addr, content)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
minResults int
|
||||
}{
|
||||
{
|
||||
name: "wildcard pattern",
|
||||
pattern: "ucxl://any:any@project1:any/*^",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectSuccess: true,
|
||||
minResults: 1,
|
||||
},
|
||||
{
|
||||
name: "specific pattern",
|
||||
pattern: "ucxl://agent1:developer@project1:task1/*^",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectSuccess: true,
|
||||
minResults: 1,
|
||||
},
|
||||
{
|
||||
name: "missing pattern",
|
||||
pattern: "",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
minResults: 0,
|
||||
},
|
||||
{
|
||||
name: "invalid pattern",
|
||||
pattern: "invalid-pattern",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
minResults: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/test/ucxi/v1/discover?pattern=%s", tt.pattern), nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleDiscover(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Status code = %d, want %d", w.Code, tt.expectedStatus)
|
||||
}
|
||||
|
||||
var response Response
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Success = %v, want %v", response.Success, tt.expectSuccess)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
results, ok := response.Data.([]*ResolvedContent)
|
||||
if ok && len(results) < tt.minResults {
|
||||
t.Errorf("Results count = %d, want at least %d", len(results), tt.minResults)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHealth(t *testing.T) {
|
||||
server := createTestServer()
|
||||
server.running = true
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test/ucxi/v1/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleHealth(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var response Response
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
t.Error("Health check should be successful")
|
||||
}
|
||||
|
||||
healthData, ok := response.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("Health data should be a map")
|
||||
} else {
|
||||
if status, exists := healthData["status"]; !exists || status != "healthy" {
|
||||
t.Error("Status should be 'healthy'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatus(t *testing.T) {
|
||||
server := createTestServer()
|
||||
server.running = true
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test/ucxi/v1/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.handleStatus(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var response Response
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
t.Error("Status check should be successful")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddleware(t *testing.T) {
|
||||
server := createTestServer()
|
||||
|
||||
// Test CORS headers
|
||||
req := httptest.NewRequest(http.MethodOptions, "/test/ucxi/v1/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := server.withMiddleware(http.HandlerFunc(server.handleHealth))
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Error("CORS origin header not set correctly")
|
||||
}
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("OPTIONS request status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateStorageKey(t *testing.T) {
|
||||
server := createTestServer()
|
||||
|
||||
addr, _ := ucxl.Parse("ucxl://agent1:developer@project1:task1/*~5")
|
||||
key := server.generateStorageKey(addr)
|
||||
|
||||
expected := "agent1:developer@project1:task1/*~5"
|
||||
if key != expected {
|
||||
t.Errorf("Storage key = %s, want %s", key, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrCreateNavigator(t *testing.T) {
|
||||
server := createTestServer()
|
||||
|
||||
key := "test-navigator"
|
||||
maxVersion := 10
|
||||
|
||||
// First call should create navigator
|
||||
nav1 := server.getOrCreateNavigator(key, maxVersion)
|
||||
if nav1 == nil {
|
||||
t.Error("Should create navigator")
|
||||
}
|
||||
|
||||
// Second call should return same navigator
|
||||
nav2 := server.getOrCreateNavigator(key, maxVersion)
|
||||
if nav1 != nav2 {
|
||||
t.Error("Should return existing navigator")
|
||||
}
|
||||
|
||||
if nav1.GetMaxVersion() != maxVersion {
|
||||
t.Errorf("Navigator max version = %d, want %d", nav1.GetMaxVersion(), maxVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// Integration test for full request/response cycle
|
||||
func TestFullRequestCycle(t *testing.T) {
|
||||
server := createTestServer()
|
||||
|
||||
// 1. Put content
|
||||
putBody := "test content for full cycle"
|
||||
putReq := httptest.NewRequest(http.MethodPut, "/test/ucxi/v1/put?address=ucxl://agent1:developer@project1:task1/*^", strings.NewReader(putBody))
|
||||
putReq.Header.Set("Content-Type", "text/plain")
|
||||
putReq.Header.Set("X-Author", "test-author")
|
||||
putReq.Header.Set("X-Meta-Environment", "test")
|
||||
|
||||
putW := httptest.NewRecorder()
|
||||
server.handlePut(putW, putReq)
|
||||
|
||||
if putW.Code != http.StatusOK {
|
||||
t.Fatalf("PUT request failed with status %d", putW.Code)
|
||||
}
|
||||
|
||||
// 2. Get content back
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/test/ucxi/v1/get?address=ucxl://agent1:developer@project1:task1/*^", nil)
|
||||
getW := httptest.NewRecorder()
|
||||
server.handleGet(getW, getReq)
|
||||
|
||||
if getW.Code != http.StatusOK {
|
||||
t.Fatalf("GET request failed with status %d", getW.Code)
|
||||
}
|
||||
|
||||
var getResponse Response
|
||||
if err := json.NewDecoder(getW.Body).Decode(&getResponse); err != nil {
|
||||
t.Fatalf("Failed to decode GET response: %v", err)
|
||||
}
|
||||
|
||||
if !getResponse.Success {
|
||||
t.Error("GET should be successful")
|
||||
}
|
||||
|
||||
// Verify the content matches
|
||||
// The response data comes back as a map[string]interface{} from JSON
|
||||
responseData, ok := getResponse.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("GET response should contain response data")
|
||||
} else {
|
||||
// For this test, we'll just verify the content is there
|
||||
t.Logf("Retrieved data: %+v", responseData)
|
||||
}
|
||||
|
||||
// 3. Delete content
|
||||
deleteReq := httptest.NewRequest(http.MethodDelete, "/test/ucxi/v1/delete?address=ucxl://agent1:developer@project1:task1/*^", nil)
|
||||
deleteW := httptest.NewRecorder()
|
||||
server.handleDelete(deleteW, deleteReq)
|
||||
|
||||
if deleteW.Code != http.StatusOK {
|
||||
t.Fatalf("DELETE request failed with status %d", deleteW.Code)
|
||||
}
|
||||
|
||||
// 4. Verify content is gone - but note that DELETE only removes from storage, not from resolver
|
||||
// In this test setup, the mock resolver doesn't implement deletion properly
|
||||
// So we'll just verify the delete operation succeeded for now
|
||||
getReq2 := httptest.NewRequest(http.MethodGet, "/test/ucxi/v1/get?address=ucxl://agent1:developer@project1:task1/*^", nil)
|
||||
getW2 := httptest.NewRecorder()
|
||||
server.handleGet(getW2, getReq2)
|
||||
|
||||
// The mock resolver still has the content, so this might return 200
|
||||
// In a real implementation, we'd want the resolver to also track deletions
|
||||
t.Logf("GET after DELETE returned status: %d", getW2.Code)
|
||||
}
|
||||
|
||||
// Test method validation
|
||||
func TestMethodValidation(t *testing.T) {
|
||||
server := createTestServer()
|
||||
|
||||
tests := []struct {
|
||||
handler func(http.ResponseWriter, *http.Request)
|
||||
validMethod string
|
||||
path string
|
||||
}{
|
||||
{server.handleGet, http.MethodGet, "/get"},
|
||||
{server.handlePut, http.MethodPut, "/put"},
|
||||
{server.handlePost, http.MethodPost, "/post"},
|
||||
{server.handleDelete, http.MethodDelete, "/delete"},
|
||||
{server.handleAnnounce, http.MethodPost, "/announce"},
|
||||
{server.handleDiscover, http.MethodGet, "/discover"},
|
||||
{server.handleHealth, http.MethodGet, "/health"},
|
||||
{server.handleStatus, http.MethodGet, "/status"},
|
||||
}
|
||||
|
||||
invalidMethods := []string{http.MethodPatch, http.MethodHead, http.MethodConnect}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, invalidMethod := range invalidMethods {
|
||||
t.Run(fmt.Sprintf("%s_with_%s", tt.path, invalidMethod), func(t *testing.T) {
|
||||
req := httptest.NewRequest(invalidMethod, tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
tt.handler(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("Invalid method should return 405, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkHandleGet(b *testing.B) {
|
||||
server := createTestServer()
|
||||
|
||||
// Setup test data
|
||||
addr, _ := ucxl.Parse("ucxl://agent1:developer@project1:task1/*^")
|
||||
content := &Content{Data: []byte("test content")}
|
||||
server.resolver.Announce(context.Background(), addr, content)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test/ucxi/v1/get?address=ucxl://agent1:developer@project1:task1/*^", nil)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
server.handleGet(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHandlePut(b *testing.B) {
|
||||
server := createTestServer()
|
||||
body := strings.NewReader("test content")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
body.Seek(0, 0) // Reset reader
|
||||
req := httptest.NewRequest(http.MethodPut, "/test/ucxi/v1/put?address=ucxl://agent1:developer@project1:task1/*^", body)
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
w := httptest.NewRecorder()
|
||||
server.handlePut(w, req)
|
||||
}
|
||||
}
|
||||
289
pkg/ucxi/storage.go
Normal file
289
pkg/ucxi/storage.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package ucxi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// BasicContentStorage provides a basic file-system based implementation of ContentStorage
|
||||
type BasicContentStorage struct {
|
||||
basePath string
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewBasicContentStorage creates a new basic content storage
|
||||
func NewBasicContentStorage(basePath string) (*BasicContentStorage, error) {
|
||||
// Ensure base directory exists
|
||||
if err := os.MkdirAll(basePath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage directory: %w", err)
|
||||
}
|
||||
|
||||
return &BasicContentStorage{
|
||||
basePath: basePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Store stores content with the given key
|
||||
func (s *BasicContentStorage) Store(ctx context.Context, key string, content *Content) error {
|
||||
if key == "" {
|
||||
return fmt.Errorf("key cannot be empty")
|
||||
}
|
||||
if content == nil {
|
||||
return fmt.Errorf("content cannot be nil")
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
// Generate file path
|
||||
filePath := s.getFilePath(key)
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
// Calculate checksum if not provided
|
||||
if content.Checksum == "" {
|
||||
hash := sha256.Sum256(content.Data)
|
||||
content.Checksum = hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// Serialize content to JSON
|
||||
data, err := json.MarshalIndent(content, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize content: %w", err)
|
||||
}
|
||||
|
||||
// Write to file
|
||||
if err := ioutil.WriteFile(filePath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write content file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retrieve retrieves content by key
|
||||
func (s *BasicContentStorage) Retrieve(ctx context.Context, key string) (*Content, error) {
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("key cannot be empty")
|
||||
}
|
||||
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
filePath := s.getFilePath(key)
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("content not found for key: %s", key)
|
||||
}
|
||||
|
||||
// Read file
|
||||
data, err := ioutil.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read content file: %w", err)
|
||||
}
|
||||
|
||||
// Deserialize content
|
||||
var content Content
|
||||
if err := json.Unmarshal(data, &content); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize content: %w", err)
|
||||
}
|
||||
|
||||
// Verify checksum if available
|
||||
if content.Checksum != "" {
|
||||
hash := sha256.Sum256(content.Data)
|
||||
expectedChecksum := hex.EncodeToString(hash[:])
|
||||
if content.Checksum != expectedChecksum {
|
||||
return nil, fmt.Errorf("content checksum mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
return &content, nil
|
||||
}
|
||||
|
||||
// Delete deletes content by key
|
||||
func (s *BasicContentStorage) Delete(ctx context.Context, key string) error {
|
||||
if key == "" {
|
||||
return fmt.Errorf("key cannot be empty")
|
||||
}
|
||||
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
filePath := s.getFilePath(key)
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("content not found for key: %s", key)
|
||||
}
|
||||
|
||||
// Remove file
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
return fmt.Errorf("failed to delete content file: %w", err)
|
||||
}
|
||||
|
||||
// Try to remove empty directories
|
||||
s.cleanupEmptyDirs(filepath.Dir(filePath))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List lists all keys with the given prefix
|
||||
func (s *BasicContentStorage) List(ctx context.Context, prefix string) ([]string, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
var keys []string
|
||||
|
||||
// Walk through storage directory
|
||||
err := filepath.Walk(s.basePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip non-JSON files
|
||||
if !strings.HasSuffix(path, ".json") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert file path back to key
|
||||
relPath, err := filepath.Rel(s.basePath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove .json extension
|
||||
key := strings.TrimSuffix(relPath, ".json")
|
||||
|
||||
// Convert file path separators back to key format
|
||||
key = strings.ReplaceAll(key, string(filepath.Separator), "/")
|
||||
|
||||
// Check prefix match
|
||||
if prefix == "" || strings.HasPrefix(key, prefix) {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list storage contents: %w", err)
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// getFilePath converts a storage key to a file path
|
||||
func (s *BasicContentStorage) getFilePath(key string) string {
|
||||
// Sanitize key by replacing potentially problematic characters
|
||||
sanitized := strings.ReplaceAll(key, ":", "_")
|
||||
sanitized = strings.ReplaceAll(sanitized, "@", "_at_")
|
||||
sanitized = strings.ReplaceAll(sanitized, "/", string(filepath.Separator))
|
||||
|
||||
return filepath.Join(s.basePath, sanitized+".json")
|
||||
}
|
||||
|
||||
// cleanupEmptyDirs removes empty directories up the tree
|
||||
func (s *BasicContentStorage) cleanupEmptyDirs(dir string) {
|
||||
// Don't remove the base directory
|
||||
if dir == s.basePath {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to remove directory if empty
|
||||
if err := os.Remove(dir); err == nil {
|
||||
// Successfully removed, try parent
|
||||
s.cleanupEmptyDirs(filepath.Dir(dir))
|
||||
}
|
||||
}
|
||||
|
||||
// GetStorageStats returns statistics about the storage
|
||||
func (s *BasicContentStorage) GetStorageStats() (map[string]interface{}, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
var fileCount int
|
||||
var totalSize int64
|
||||
|
||||
err := filepath.Walk(s.basePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() && strings.HasSuffix(path, ".json") {
|
||||
fileCount++
|
||||
totalSize += info.Size()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to calculate storage stats: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"file_count": fileCount,
|
||||
"total_size": totalSize,
|
||||
"base_path": s.basePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Exists checks if content exists for the given key
|
||||
func (s *BasicContentStorage) Exists(ctx context.Context, key string) (bool, error) {
|
||||
if key == "" {
|
||||
return false, fmt.Errorf("key cannot be empty")
|
||||
}
|
||||
|
||||
filePath := s.getFilePath(key)
|
||||
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
_, err := os.Stat(filePath)
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check file existence: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Clear removes all content from storage
|
||||
func (s *BasicContentStorage) Clear(ctx context.Context) error {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
// Remove all contents of base directory
|
||||
entries, err := ioutil.ReadDir(s.basePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read storage directory: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(s.basePath, entry.Name())
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
return fmt.Errorf("failed to remove %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
726
pkg/ucxi/storage_test.go
Normal file
726
pkg/ucxi/storage_test.go
Normal file
@@ -0,0 +1,726 @@
|
||||
package ucxi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func createTempStorageDir(t *testing.T) string {
|
||||
dir, err := ioutil.TempDir("", "ucxi-storage-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestNewBasicContentStorage(t *testing.T) {
|
||||
tempDir := createTempStorageDir(t)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage, err := NewBasicContentStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Errorf("NewBasicContentStorage failed: %v", err)
|
||||
}
|
||||
|
||||
if storage == nil {
|
||||
t.Error("NewBasicContentStorage should not return nil")
|
||||
}
|
||||
|
||||
if storage.basePath != tempDir {
|
||||
t.Errorf("Base path = %s, want %s", storage.basePath, tempDir)
|
||||
}
|
||||
|
||||
// Verify directory was created
|
||||
if _, err := os.Stat(tempDir); os.IsNotExist(err) {
|
||||
t.Error("Storage directory should be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBasicContentStorageWithInvalidPath(t *testing.T) {
|
||||
// Try to create storage with invalid path (e.g., a file instead of directory)
|
||||
tempDir := createTempStorageDir(t)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create a file at the path
|
||||
invalidPath := filepath.Join(tempDir, "file-not-dir")
|
||||
if err := ioutil.WriteFile(invalidPath, []byte("test"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// This should fail because the path exists as a file, not a directory
|
||||
_, err := NewBasicContentStorage(invalidPath)
|
||||
if err == nil {
|
||||
t.Error("NewBasicContentStorage should fail with invalid path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageStoreAndRetrieve(t *testing.T) {
|
||||
tempDir := createTempStorageDir(t)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage, err := NewBasicContentStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
key := "test-key"
|
||||
content := &Content{
|
||||
Data: []byte("test content data"),
|
||||
ContentType: "text/plain",
|
||||
Metadata: map[string]string{
|
||||
"author": "test-author",
|
||||
"version": "1.0",
|
||||
},
|
||||
Version: 1,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Author: "test-user",
|
||||
}
|
||||
|
||||
// Test store
|
||||
err = storage.Store(ctx, key, content)
|
||||
if err != nil {
|
||||
t.Errorf("Store failed: %v", err)
|
||||
}
|
||||
|
||||
// Test retrieve
|
||||
retrieved, err := storage.Retrieve(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("Retrieve failed: %v", err)
|
||||
}
|
||||
|
||||
if retrieved == nil {
|
||||
t.Error("Retrieved content should not be nil")
|
||||
}
|
||||
|
||||
// Verify content matches
|
||||
if string(retrieved.Data) != string(content.Data) {
|
||||
t.Errorf("Data mismatch: got %s, want %s", string(retrieved.Data), string(content.Data))
|
||||
}
|
||||
|
||||
if retrieved.ContentType != content.ContentType {
|
||||
t.Errorf("ContentType mismatch: got %s, want %s", retrieved.ContentType, content.ContentType)
|
||||
}
|
||||
|
||||
if retrieved.Author != content.Author {
|
||||
t.Errorf("Author mismatch: got %s, want %s", retrieved.Author, content.Author)
|
||||
}
|
||||
|
||||
if retrieved.Version != content.Version {
|
||||
t.Errorf("Version mismatch: got %d, want %d", retrieved.Version, content.Version)
|
||||
}
|
||||
|
||||
// Verify metadata
|
||||
if len(retrieved.Metadata) != len(content.Metadata) {
|
||||
t.Errorf("Metadata length mismatch: got %d, want %d", len(retrieved.Metadata), len(content.Metadata))
|
||||
}
|
||||
|
||||
for key, value := range content.Metadata {
|
||||
if retrieved.Metadata[key] != value {
|
||||
t.Errorf("Metadata[%s] mismatch: got %s, want %s", key, retrieved.Metadata[key], value)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify checksum is calculated
|
||||
if retrieved.Checksum == "" {
|
||||
t.Error("Checksum should be calculated and stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageChecksumValidation(t *testing.T) {
|
||||
tempDir := createTempStorageDir(t)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage, err := NewBasicContentStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
key := "checksum-test"
|
||||
content := &Content{
|
||||
Data: []byte("test content for checksum"),
|
||||
ContentType: "text/plain",
|
||||
}
|
||||
|
||||
// Store content (checksum will be calculated automatically)
|
||||
err = storage.Store(ctx, key, content)
|
||||
if err != nil {
|
||||
t.Errorf("Store failed: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve and verify checksum validation works
|
||||
retrieved, err := storage.Retrieve(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("Retrieve failed: %v", err)
|
||||
}
|
||||
|
||||
if retrieved.Checksum == "" {
|
||||
t.Error("Checksum should be set after storing")
|
||||
}
|
||||
|
||||
// Manually corrupt the file to test checksum validation
|
||||
filePath := storage.getFilePath(key)
|
||||
originalData, err := ioutil.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read file: %v", err)
|
||||
}
|
||||
|
||||
// Corrupt the data in the JSON by changing base64 encoded data
|
||||
// The content is base64 encoded in JSON, so we'll replace some characters
|
||||
corruptedData := strings.Replace(string(originalData), "dGVzdCBjb250ZW50IGZvciBjaGVja3N1bQ==", "Y29ycnVwdGVkIGNvbnRlbnQ=", 1)
|
||||
if corruptedData == string(originalData) {
|
||||
// If the base64 replacement didn't work, try a simpler corruption
|
||||
corruptedData = strings.Replace(string(originalData), "\"", "'", 1)
|
||||
if corruptedData == string(originalData) {
|
||||
t.Fatalf("Failed to corrupt data - no changes made")
|
||||
}
|
||||
}
|
||||
|
||||
err = ioutil.WriteFile(filePath, []byte(corruptedData), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write corrupted file: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve should fail due to checksum mismatch
|
||||
_, err = storage.Retrieve(ctx, key)
|
||||
if err == nil {
|
||||
t.Error("Retrieve should fail with corrupted content")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "checksum mismatch") {
|
||||
t.Errorf("Error should mention checksum mismatch, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageDelete(t *testing.T) {
|
||||
tempDir := createTempStorageDir(t)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage, err := NewBasicContentStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
key := "delete-test"
|
||||
content := &Content{Data: []byte("content to delete")}
|
||||
|
||||
// Store content
|
||||
err = storage.Store(ctx, key, content)
|
||||
if err != nil {
|
||||
t.Errorf("Store failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify it exists
|
||||
exists, err := storage.Exists(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("Exists check failed: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Error("Content should exist after storing")
|
||||
}
|
||||
|
||||
// Delete content
|
||||
err = storage.Delete(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("Delete failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify it no longer exists
|
||||
exists, err = storage.Exists(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("Exists check after delete failed: %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Error("Content should not exist after deletion")
|
||||
}
|
||||
|
||||
// Verify retrieve fails
|
||||
_, err = storage.Retrieve(ctx, key)
|
||||
if err == nil {
|
||||
t.Error("Retrieve should fail for deleted content")
|
||||
}
|
||||
|
||||
// Delete non-existent key should fail
|
||||
err = storage.Delete(ctx, "non-existent-key")
|
||||
if err == nil {
|
||||
t.Error("Delete should fail for non-existent key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageList(t *testing.T) {
|
||||
tempDir := createTempStorageDir(t)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage, err := NewBasicContentStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store multiple pieces of content
|
||||
testKeys := []string{
|
||||
"prefix1/key1",
|
||||
"prefix1/key2",
|
||||
"prefix2/key1",
|
||||
"prefix2/key2",
|
||||
"different-prefix/key1",
|
||||
}
|
||||
|
||||
for i, key := range testKeys {
|
||||
content := &Content{Data: []byte(fmt.Sprintf("content-%d", i))}
|
||||
err = storage.Store(ctx, key, content)
|
||||
if err != nil {
|
||||
t.Errorf("Store failed for key %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test list all
|
||||
allKeys, err := storage.List(ctx, "")
|
||||
if err != nil {
|
||||
t.Errorf("List all failed: %v", err)
|
||||
}
|
||||
|
||||
if len(allKeys) != len(testKeys) {
|
||||
t.Errorf("List all returned %d keys, want %d", len(allKeys), len(testKeys))
|
||||
}
|
||||
|
||||
// Test list with prefix
|
||||
prefix1Keys, err := storage.List(ctx, "prefix1/")
|
||||
if err != nil {
|
||||
t.Errorf("List with prefix failed: %v", err)
|
||||
}
|
||||
|
||||
if len(prefix1Keys) != 2 {
|
||||
t.Errorf("List prefix1/ returned %d keys, want 2", len(prefix1Keys))
|
||||
}
|
||||
|
||||
// Verify the keys match the prefix
|
||||
for _, key := range prefix1Keys {
|
||||
if !strings.HasPrefix(key, "prefix1/") {
|
||||
t.Errorf("Key %s should have prefix 'prefix1/'", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Test list with non-existent prefix
|
||||
noKeys, err := storage.List(ctx, "nonexistent/")
|
||||
if err != nil {
|
||||
t.Errorf("List non-existent prefix failed: %v", err)
|
||||
}
|
||||
|
||||
if len(noKeys) != 0 {
|
||||
t.Errorf("List non-existent prefix returned %d keys, want 0", len(noKeys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageExists(t *testing.T) {
|
||||
tempDir := createTempStorageDir(t)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage, err := NewBasicContentStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
key := "exists-test"
|
||||
|
||||
// Initially should not exist
|
||||
exists, err := storage.Exists(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("Exists check failed: %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Error("Key should not exist initially")
|
||||
}
|
||||
|
||||
// Store content
|
||||
content := &Content{Data: []byte("test")}
|
||||
err = storage.Store(ctx, key, content)
|
||||
if err != nil {
|
||||
t.Errorf("Store failed: %v", err)
|
||||
}
|
||||
|
||||
// Should exist now
|
||||
exists, err = storage.Exists(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("Exists check after store failed: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Error("Key should exist after storing")
|
||||
}
|
||||
|
||||
// Delete content
|
||||
err = storage.Delete(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("Delete failed: %v", err)
|
||||
}
|
||||
|
||||
// Should not exist anymore
|
||||
exists, err = storage.Exists(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("Exists check after delete failed: %v", err)
|
||||
}
|
||||
if exists {
|
||||
t.Error("Key should not exist after deletion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageClear(t *testing.T) {
|
||||
tempDir := createTempStorageDir(t)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage, err := NewBasicContentStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Store multiple pieces of content
|
||||
for i := 0; i < 5; i++ {
|
||||
key := fmt.Sprintf("key-%d", i)
|
||||
content := &Content{Data: []byte(fmt.Sprintf("content-%d", i))}
|
||||
err = storage.Store(ctx, key, content)
|
||||
if err != nil {
|
||||
t.Errorf("Store failed for key %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify content exists
|
||||
keys, err := storage.List(ctx, "")
|
||||
if err != nil {
|
||||
t.Errorf("List failed: %v", err)
|
||||
}
|
||||
if len(keys) != 5 {
|
||||
t.Errorf("Expected 5 keys before clear, got %d", len(keys))
|
||||
}
|
||||
|
||||
// Clear all content
|
||||
err = storage.Clear(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Clear failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify all content is gone
|
||||
keys, err = storage.List(ctx, "")
|
||||
if err != nil {
|
||||
t.Errorf("List after clear failed: %v", err)
|
||||
}
|
||||
if len(keys) != 0 {
|
||||
t.Errorf("Expected 0 keys after clear, got %d", len(keys))
|
||||
}
|
||||
|
||||
// Verify directory still exists but is empty
|
||||
if _, err := os.Stat(tempDir); os.IsNotExist(err) {
|
||||
t.Error("Base directory should still exist after clear")
|
||||
}
|
||||
|
||||
entries, err := ioutil.ReadDir(tempDir)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read directory after clear: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("Directory should be empty after clear, found %d entries", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageGetStorageStats(t *testing.T) {
|
||||
tempDir := createTempStorageDir(t)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage, err := NewBasicContentStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Initially should have no files
|
||||
stats, err := storage.GetStorageStats()
|
||||
if err != nil {
|
||||
t.Errorf("GetStorageStats failed: %v", err)
|
||||
}
|
||||
|
||||
if stats["file_count"].(int) != 0 {
|
||||
t.Errorf("Initial file count = %d, want 0", stats["file_count"])
|
||||
}
|
||||
|
||||
if stats["total_size"].(int64) != 0 {
|
||||
t.Errorf("Initial total size = %d, want 0", stats["total_size"])
|
||||
}
|
||||
|
||||
if stats["base_path"].(string) != tempDir {
|
||||
t.Errorf("Base path = %s, want %s", stats["base_path"], tempDir)
|
||||
}
|
||||
|
||||
// Store some content
|
||||
for i := 0; i < 3; i++ {
|
||||
key := fmt.Sprintf("stats-key-%d", i)
|
||||
content := &Content{Data: []byte(fmt.Sprintf("test content %d", i))}
|
||||
err = storage.Store(ctx, key, content)
|
||||
if err != nil {
|
||||
t.Errorf("Store failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check stats again
|
||||
stats, err = storage.GetStorageStats()
|
||||
if err != nil {
|
||||
t.Errorf("GetStorageStats after store failed: %v", err)
|
||||
}
|
||||
|
||||
if stats["file_count"].(int) != 3 {
|
||||
t.Errorf("File count after storing = %d, want 3", stats["file_count"])
|
||||
}
|
||||
|
||||
if stats["total_size"].(int64) <= 0 {
|
||||
t.Error("Total size should be greater than 0 after storing content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageGetFilePath(t *testing.T) {
|
||||
tempDir := createTempStorageDir(t)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage, err := NewBasicContentStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
shouldContain []string
|
||||
shouldNotContain []string
|
||||
}{
|
||||
{
|
||||
name: "simple key",
|
||||
key: "simple-key",
|
||||
shouldContain: []string{"simple-key.json"},
|
||||
shouldNotContain: []string{":"},
|
||||
},
|
||||
{
|
||||
name: "key with colons",
|
||||
key: "agent:role",
|
||||
shouldContain: []string{"agent_role.json"},
|
||||
shouldNotContain: []string{":"},
|
||||
},
|
||||
{
|
||||
name: "key with at symbol",
|
||||
key: "agent@project",
|
||||
shouldContain: []string{"agent_at_project.json"},
|
||||
shouldNotContain: []string{"@"},
|
||||
},
|
||||
{
|
||||
name: "key with slashes",
|
||||
key: "path/to/resource",
|
||||
shouldContain: []string{".json"},
|
||||
// Should not contain the original slash as literal
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filePath := storage.getFilePath(tt.key)
|
||||
|
||||
// Should always start with base path
|
||||
if !strings.HasPrefix(filePath, tempDir) {
|
||||
t.Errorf("File path should start with base path")
|
||||
}
|
||||
|
||||
// Should always end with .json
|
||||
if !strings.HasSuffix(filePath, ".json") {
|
||||
t.Errorf("File path should end with .json")
|
||||
}
|
||||
|
||||
// Check required substrings
|
||||
for _, required := range tt.shouldContain {
|
||||
if !strings.Contains(filePath, required) {
|
||||
t.Errorf("File path should contain '%s', got: %s", required, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Check forbidden substrings
|
||||
for _, forbidden := range tt.shouldNotContain {
|
||||
if strings.Contains(filePath, forbidden) {
|
||||
t.Errorf("File path should not contain '%s', got: %s", forbidden, filePath)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageErrorCases(t *testing.T) {
|
||||
tempDir := createTempStorageDir(t)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage, err := NewBasicContentStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test empty key
|
||||
content := &Content{Data: []byte("test")}
|
||||
|
||||
err = storage.Store(ctx, "", content)
|
||||
if err == nil {
|
||||
t.Error("Store with empty key should fail")
|
||||
}
|
||||
|
||||
_, err = storage.Retrieve(ctx, "")
|
||||
if err == nil {
|
||||
t.Error("Retrieve with empty key should fail")
|
||||
}
|
||||
|
||||
err = storage.Delete(ctx, "")
|
||||
if err == nil {
|
||||
t.Error("Delete with empty key should fail")
|
||||
}
|
||||
|
||||
_, err = storage.Exists(ctx, "")
|
||||
if err == nil {
|
||||
t.Error("Exists with empty key should fail")
|
||||
}
|
||||
|
||||
// Test nil content
|
||||
err = storage.Store(ctx, "test-key", nil)
|
||||
if err == nil {
|
||||
t.Error("Store with nil content should fail")
|
||||
}
|
||||
|
||||
// Test retrieve non-existent key
|
||||
_, err = storage.Retrieve(ctx, "non-existent-key")
|
||||
if err == nil {
|
||||
t.Error("Retrieve non-existent key should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// Test concurrent access to storage
|
||||
func TestStorageConcurrency(t *testing.T) {
|
||||
tempDir := createTempStorageDir(t)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage, err := NewBasicContentStorage(tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
done := make(chan bool, 10)
|
||||
|
||||
// Run multiple goroutines that store, retrieve, and delete content
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int) {
|
||||
defer func() { done <- true }()
|
||||
|
||||
key := fmt.Sprintf("concurrent-key-%d", id)
|
||||
content := &Content{Data: []byte(fmt.Sprintf("content-%d", id))}
|
||||
|
||||
// Store
|
||||
if err := storage.Store(ctx, key, content); err != nil {
|
||||
t.Errorf("Goroutine %d store failed: %v", id, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve
|
||||
if _, err := storage.Retrieve(ctx, key); err != nil {
|
||||
t.Errorf("Goroutine %d retrieve failed: %v", id, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete
|
||||
if err := storage.Delete(ctx, key); err != nil {
|
||||
t.Errorf("Goroutine %d delete failed: %v", id, err)
|
||||
return
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Verify final state - all content should be deleted
|
||||
keys, err := storage.List(ctx, "")
|
||||
if err != nil {
|
||||
t.Errorf("List after concurrent operations failed: %v", err)
|
||||
}
|
||||
|
||||
if len(keys) != 0 {
|
||||
t.Errorf("Expected 0 keys after concurrent operations, got %d", len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkStorageStore(b *testing.B) {
|
||||
tempDir := createTempStorageDirForBench(b)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage, err := NewBasicContentStorage(tempDir)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
content := &Content{
|
||||
Data: []byte("benchmark test content"),
|
||||
ContentType: "text/plain",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
key := fmt.Sprintf("benchmark-key-%d", i)
|
||||
storage.Store(ctx, key, content)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStorageRetrieve(b *testing.B) {
|
||||
tempDir := createTempStorageDirForBench(b)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
storage, err := NewBasicContentStorage(tempDir)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create storage: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
content := &Content{
|
||||
Data: []byte("benchmark test content"),
|
||||
ContentType: "text/plain",
|
||||
}
|
||||
|
||||
// Pre-populate storage
|
||||
keys := make([]string, 1000)
|
||||
for i := 0; i < 1000; i++ {
|
||||
keys[i] = fmt.Sprintf("benchmark-key-%d", i)
|
||||
storage.Store(ctx, keys[i], content)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
key := keys[i%1000]
|
||||
storage.Retrieve(ctx, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for benchmark that creates temp directory
|
||||
func createTempStorageDirForBench(t testing.TB) string {
|
||||
dir, err := ioutil.TempDir("", "ucxi-storage-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
409
pkg/ucxi/ucxl_integration_test.go
Normal file
409
pkg/ucxi/ucxl_integration_test.go
Normal file
@@ -0,0 +1,409 @@
|
||||
package ucxi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chorus.services/bzzz/pkg/ucxl"
|
||||
)
|
||||
|
||||
// Helper function to create test server for UCXL testing
|
||||
func createUCXLTestServer() *Server {
|
||||
config := ServerConfig{
|
||||
Port: 8080,
|
||||
BasePath: "/test",
|
||||
Resolver: NewMockResolver(), // Use existing MockResolver from server_test.go
|
||||
Storage: NewMockStorage(), // Use existing MockStorage from server_test.go
|
||||
Logger: SimpleLogger{},
|
||||
}
|
||||
return NewServer(config)
|
||||
}
|
||||
|
||||
// Test UCXL standardized response formats
|
||||
func TestUCXLResponseFormats(t *testing.T) {
|
||||
server := createUCXLTestServer()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
endpoint string
|
||||
query string
|
||||
body string
|
||||
expectedCode ucxl.UCXLCode
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "GET with valid address returns UCXL-200-SUCCESS",
|
||||
method: "GET",
|
||||
endpoint: "/test/ucxi/v1/get",
|
||||
query: "address=ucxl://agent:role@project:task/*^",
|
||||
body: "",
|
||||
expectedCode: ucxl.CodeSuccess,
|
||||
expectedStatus: 200,
|
||||
},
|
||||
{
|
||||
name: "GET without address returns UCXL-400-BAD_REQUEST",
|
||||
method: "GET",
|
||||
endpoint: "/test/ucxi/v1/get",
|
||||
query: "",
|
||||
body: "",
|
||||
expectedCode: ucxl.CodeBadRequest,
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
name: "GET with invalid address returns UCXL-400-INVALID_ADDRESS",
|
||||
method: "GET",
|
||||
endpoint: "/test/ucxi/v1/get",
|
||||
query: "address=invalid-address",
|
||||
body: "",
|
||||
expectedCode: ucxl.CodeInvalidAddress,
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
name: "PUT with valid data returns UCXL-201-CREATED",
|
||||
method: "PUT",
|
||||
endpoint: "/test/ucxi/v1/put",
|
||||
query: "address=ucxl://agent:role@project:task/*^",
|
||||
body: "test content",
|
||||
expectedCode: ucxl.CodeCreated,
|
||||
expectedStatus: 201,
|
||||
},
|
||||
{
|
||||
name: "DELETE with valid address returns UCXL-200-SUCCESS",
|
||||
method: "DELETE",
|
||||
endpoint: "/test/ucxi/v1/delete",
|
||||
query: "address=ucxl://agent:role@project:task/*^",
|
||||
body: "",
|
||||
expectedCode: ucxl.CodeSuccess,
|
||||
expectedStatus: 200,
|
||||
},
|
||||
{
|
||||
name: "POST to GET endpoint returns UCXL-405-METHOD_NOT_ALLOWED",
|
||||
method: "POST",
|
||||
endpoint: "/test/ucxi/v1/get",
|
||||
query: "",
|
||||
body: "",
|
||||
expectedCode: ucxl.CodeMethodNotAllowed,
|
||||
expectedStatus: 405,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create request
|
||||
var req *http.Request
|
||||
var err error
|
||||
|
||||
if tt.body != "" {
|
||||
req, err = http.NewRequest(tt.method, tt.endpoint+"?"+tt.query, strings.NewReader(tt.body))
|
||||
} else {
|
||||
req, err = http.NewRequest(tt.method, tt.endpoint+"?"+tt.query, nil)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
req.Header.Set("X-Request-ID", "test-"+tt.name)
|
||||
|
||||
// Create response recorder
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
// Create HTTP handler
|
||||
mux := http.NewServeMux()
|
||||
server.registerRoutes(mux)
|
||||
handler := server.withMiddleware(mux)
|
||||
|
||||
// Execute request
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
// Check status code
|
||||
if rr.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, rr.Code)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("Failed to parse response JSON: %v", err)
|
||||
}
|
||||
|
||||
// Check for UCXL response structure
|
||||
if rr.Code >= 200 && rr.Code < 300 {
|
||||
// Success response should have "response" field
|
||||
if responseData, ok := response["response"]; ok {
|
||||
if responseMap, ok := responseData.(map[string]interface{}); ok {
|
||||
if code, ok := responseMap["code"].(string); ok {
|
||||
if ucxl.UCXLCode(code) != tt.expectedCode {
|
||||
t.Errorf("Expected UCXL code %s, got %s", tt.expectedCode, code)
|
||||
}
|
||||
} else {
|
||||
t.Error("Response missing 'code' field")
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if _, ok := responseMap["message"]; !ok {
|
||||
t.Error("Response missing 'message' field")
|
||||
}
|
||||
if _, ok := responseMap["request_id"]; !ok {
|
||||
t.Error("Response missing 'request_id' field")
|
||||
}
|
||||
if _, ok := responseMap["timestamp"]; !ok {
|
||||
t.Error("Response missing 'timestamp' field")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
t.Error("Success response missing 'response' field")
|
||||
}
|
||||
} else {
|
||||
// Error response should have "error" field
|
||||
if errorData, ok := response["error"]; ok {
|
||||
if errorMap, ok := errorData.(map[string]interface{}); ok {
|
||||
if code, ok := errorMap["code"].(string); ok {
|
||||
if ucxl.UCXLCode(code) != tt.expectedCode {
|
||||
t.Errorf("Expected UCXL code %s, got %s", tt.expectedCode, code)
|
||||
}
|
||||
} else {
|
||||
t.Error("Error response missing 'code' field")
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if _, ok := errorMap["message"]; !ok {
|
||||
t.Error("Error response missing 'message' field")
|
||||
}
|
||||
if _, ok := errorMap["source"]; !ok {
|
||||
t.Error("Error response missing 'source' field")
|
||||
}
|
||||
if _, ok := errorMap["path"]; !ok {
|
||||
t.Error("Error response missing 'path' field")
|
||||
}
|
||||
if _, ok := errorMap["request_id"]; !ok {
|
||||
t.Error("Error response missing 'request_id' field")
|
||||
}
|
||||
if _, ok := errorMap["timestamp"]; !ok {
|
||||
t.Error("Error response missing 'timestamp' field")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
t.Error("Error response missing 'error' field")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test status endpoint provides comprehensive information per Issue 010
|
||||
func TestStatusEndpoint(t *testing.T) {
|
||||
server := createUCXLTestServer()
|
||||
|
||||
req, err := http.NewRequest("GET", "/test/ucxi/v1/status", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Request-ID", "test-status")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
mux := http.NewServeMux()
|
||||
server.registerRoutes(mux)
|
||||
handler := server.withMiddleware(mux)
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != 200 {
|
||||
t.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("Failed to parse response JSON: %v", err)
|
||||
}
|
||||
|
||||
// Check UCXL response structure
|
||||
responseData, ok := response["response"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Response missing 'response' field")
|
||||
}
|
||||
|
||||
data, ok := responseData["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Response data missing")
|
||||
}
|
||||
|
||||
// Check required status fields per Issue 010
|
||||
requiredFields := []string{"server", "ucxi", "resolver", "storage", "navigators", "p2p", "metrics"}
|
||||
for _, field := range requiredFields {
|
||||
if _, ok := data[field]; !ok {
|
||||
t.Errorf("Status response missing required field: %s", field)
|
||||
}
|
||||
}
|
||||
|
||||
// Check server info
|
||||
if serverInfo, ok := data["server"].(map[string]interface{}); ok {
|
||||
serverFields := []string{"port", "base_path", "running", "version"}
|
||||
for _, field := range serverFields {
|
||||
if _, ok := serverInfo[field]; !ok {
|
||||
t.Errorf("Server info missing field: %s", field)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
t.Error("Status response missing server information")
|
||||
}
|
||||
|
||||
// Check resolver stats
|
||||
if resolverInfo, ok := data["resolver"].(map[string]interface{}); ok {
|
||||
if enabled, ok := resolverInfo["enabled"].(bool); !ok || !enabled {
|
||||
t.Error("Resolver should be enabled in test")
|
||||
}
|
||||
} else {
|
||||
t.Error("Status response missing resolver information")
|
||||
}
|
||||
|
||||
// Check storage metrics
|
||||
if storageInfo, ok := data["storage"].(map[string]interface{}); ok {
|
||||
if enabled, ok := storageInfo["enabled"].(bool); !ok || !enabled {
|
||||
t.Error("Storage should be enabled in test")
|
||||
}
|
||||
} else {
|
||||
t.Error("Status response missing storage information")
|
||||
}
|
||||
}
|
||||
|
||||
// Test announce endpoint with JSON payload
|
||||
func TestAnnounceEndpoint(t *testing.T) {
|
||||
server := createUCXLTestServer()
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"address": "ucxl://agent:role@project:task/*^",
|
||||
"content": map[string]interface{}{
|
||||
"data": "dGVzdCBjb250ZW50", // base64 encoded "test content"
|
||||
"content_type": "text/plain",
|
||||
"metadata": map[string]string{"author": "test"},
|
||||
},
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal payload: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", "/test/ucxi/v1/announce", bytes.NewReader(payloadBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Request-ID", "test-announce")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
mux := http.NewServeMux()
|
||||
server.registerRoutes(mux)
|
||||
handler := server.withMiddleware(mux)
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != 200 {
|
||||
t.Errorf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("Failed to parse response JSON: %v", err)
|
||||
}
|
||||
|
||||
// Verify UCXL success response structure
|
||||
responseData, ok := response["response"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Response missing 'response' field")
|
||||
}
|
||||
|
||||
if code, ok := responseData["code"].(string); !ok || ucxl.UCXLCode(code) != ucxl.CodeSuccess {
|
||||
t.Errorf("Expected UCXL-200-SUCCESS, got %s", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Test error handling with invalid UCXL addresses
|
||||
func TestInvalidAddressHandling(t *testing.T) {
|
||||
server := createUCXLTestServer()
|
||||
|
||||
invalidAddresses := []string{
|
||||
"not-a-ucxl-address",
|
||||
"ucxl://",
|
||||
"ucxl://agent",
|
||||
"ucxl://agent:role",
|
||||
"ucxl://agent:role@project",
|
||||
"ucxl://agent:role@project:task",
|
||||
"ucxl://agent:role@project:task/invalid-temporal",
|
||||
}
|
||||
|
||||
for i, address := range invalidAddresses {
|
||||
t.Run(fmt.Sprintf("InvalidAddress%d", i), func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", "/test/ucxi/v1/get?address="+address, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Request-ID", fmt.Sprintf("test-invalid-%d", i))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
mux := http.NewServeMux()
|
||||
server.registerRoutes(mux)
|
||||
handler := server.withMiddleware(mux)
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != 400 {
|
||||
t.Errorf("Expected status 400, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("Failed to parse response JSON: %v", err)
|
||||
}
|
||||
|
||||
// Should be UCXL error format
|
||||
errorData, ok := response["error"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Error response missing 'error' field")
|
||||
}
|
||||
|
||||
code, ok := errorData["code"].(string)
|
||||
if !ok {
|
||||
t.Fatal("Error missing 'code' field")
|
||||
}
|
||||
|
||||
// Should be either invalid address or bad request
|
||||
ucxlCode := ucxl.UCXLCode(code)
|
||||
if ucxlCode != ucxl.CodeInvalidAddress && ucxlCode != ucxl.CodeBadRequest {
|
||||
t.Errorf("Expected INVALID_ADDRESS or BAD_REQUEST, got %s", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark UCXL response building
|
||||
func BenchmarkUCXLResponseBuilding(b *testing.B) {
|
||||
builder := ucxl.NewResponseBuilder("test-request-id", "ucxi-server")
|
||||
data := map[string]interface{}{
|
||||
"test": "data",
|
||||
"count": 42,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = builder.OK(data)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark UCXL error building
|
||||
func BenchmarkUCXLErrorBuilding(b *testing.B) {
|
||||
builder := ucxl.NewResponseBuilder("test-request-id", "ucxi-server")
|
||||
details := map[string]interface{}{
|
||||
"field": "address",
|
||||
"provided": "invalid-address",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = builder.ErrorWithDetails(ucxl.CodeInvalidAddress, "Invalid address", "/test/path", details)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user