A community based topic aggregation platform built on atproto

feat: Implement comprehensive lexicon validation system

- Add internal/validation/lexicon.go with ValidateLexiconData function
- Create validate-lexicon CLI tool for testing lexicon schemas
- Add comprehensive test suite with valid and invalid test cases
- Include test data for actors, communities, posts, interactions, and moderation
- Replace shell-based validation script with Go implementation
- Support for complex validation including enums, unions, and references

This provides a robust foundation for validating atProto lexicon data
in the Coves platform, ensuring data integrity and type safety.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

+830 -31
+311 -15
cmd/validate-lexicon/main.go
··· 1 1 package main 2 2 3 3 import ( 4 + "encoding/json" 4 5 "flag" 5 6 "fmt" 7 + "io" 6 8 "log" 7 9 "os" 8 10 "path/filepath" 11 + "strings" 9 12 10 13 lexicon "github.com/bluesky-social/indigo/atproto/lexicon" 11 14 ) 12 15 13 16 func main() { 14 17 var ( 15 - schemaPath = flag.String("path", "internal/atproto/lexicon", "Path to lexicon schemas directory") 16 - verbose = flag.Bool("v", false, "Verbose output") 18 + schemaPath = flag.String("path", "internal/atproto/lexicon", "Path to lexicon schemas directory") 19 + testDataPath = flag.String("test-data", "tests/lexicon-test-data", "Path to test data directory for ValidateRecord testing") 20 + verbose = flag.Bool("v", false, "Verbose output") 21 + strict = flag.Bool("strict", false, "Use strict validation mode") 22 + schemasOnly = flag.Bool("schemas-only", false, "Only validate schemas, skip test data validation") 17 23 ) 18 24 flag.Parse() 19 25 ··· 42 48 log.Fatalf("Schema validation failed: %v", err) 43 49 } 44 50 45 - fmt.Println("✅ All schemas validated successfully!") 51 + // Validate cross-references between schemas 52 + if err := validateCrossReferences(&catalog, *verbose); err != nil { 53 + log.Fatalf("Cross-reference validation failed: %v", err) 54 + } 55 + 56 + // Validate test data unless schemas-only flag is set 57 + if !*schemasOnly { 58 + fmt.Printf("\n📋 Validating test data from: %s\n", *testDataPath) 59 + allSchemas := extractAllSchemaIDs(*schemaPath) 60 + if err := validateTestData(&catalog, *testDataPath, *verbose, *strict, allSchemas); err != nil { 61 + log.Fatalf("Test data validation failed: %v", err) 62 + } 63 + } else { 64 + fmt.Println("\n⏩ Skipping test data validation (--schemas-only flag set)") 65 + } 66 + 67 + fmt.Println("\n✅ All validations passed successfully!") 46 68 } 47 69 48 70 // validateSchemaStructure performs additional validation checks 49 71 func validateSchemaStructure(catalog *lexicon.BaseCatalog, schemaPath string, verbose bool) error { 50 72 var validationErrors []string 51 73 var schemaFiles []string 74 + var schemaIDs []string 52 75 53 - // Collect all JSON schema files 76 + // Collect all JSON schema files and derive their IDs 54 77 err := filepath.Walk(schemaPath, func(path string, info os.FileInfo, err error) error { 55 78 if err != nil { 56 79 return err 57 80 } 58 81 82 + // Skip test-data directory 83 + if info.IsDir() && info.Name() == "test-data" { 84 + return filepath.SkipDir 85 + } 86 + 59 87 // Only process .json files 60 88 if !info.IsDir() && filepath.Ext(path) == ".json" { 61 89 schemaFiles = append(schemaFiles, path) 90 + 91 + // Convert file path to schema ID 92 + // e.g., internal/atproto/lexicon/social/coves/actor/profile.json -> social.coves.actor.profile 93 + relPath, _ := filepath.Rel(schemaPath, path) 94 + schemaID := filepath.ToSlash(relPath) 95 + schemaID = schemaID[:len(schemaID)-5] // Remove .json extension 96 + schemaID = strings.ReplaceAll(schemaID, "/", ".") 97 + schemaIDs = append(schemaIDs, schemaID) 62 98 } 63 99 return nil 64 100 }) ··· 74 110 } 75 111 } 76 112 77 - // Try to resolve some of our expected schemas 78 - expectedSchemas := []string{ 79 - "social.coves.actor.profile", 80 - "social.coves.community.profile", 81 - "social.coves.post.text", 82 - "social.coves.richtext.facet", 83 - } 84 - 113 + // Validate all discovered schemas 85 114 if verbose { 86 - fmt.Println("\nValidating key schemas:") 115 + fmt.Println("\nValidating all schemas:") 87 116 } 88 117 89 - for _, schemaID := range expectedSchemas { 118 + for i, schemaID := range schemaIDs { 90 119 if _, err := catalog.Resolve(schemaID); err != nil { 91 - validationErrors = append(validationErrors, fmt.Sprintf("Failed to resolve schema %s: %v", schemaID, err)) 120 + validationErrors = append(validationErrors, fmt.Sprintf("Failed to resolve schema %s (from %s): %v", schemaID, schemaFiles[i], err)) 92 121 } else if verbose { 93 122 fmt.Printf(" ✅ %s\n", schemaID) 94 123 } ··· 102 131 return fmt.Errorf("found %d validation errors", len(validationErrors)) 103 132 } 104 133 134 + fmt.Printf("\n✅ Successfully validated all %d schemas\n", len(schemaIDs)) 105 135 return nil 106 136 } 107 137 ··· 115 145 return err 116 146 } 117 147 148 + // Skip test-data directory 149 + if info.IsDir() && info.Name() == "test-data" { 150 + return filepath.SkipDir 151 + } 152 + 118 153 // Only process .json files 119 154 if !info.IsDir() && filepath.Ext(path) == ".json" { 120 155 schemaFiles = append(schemaFiles, path) ··· 142 177 // If all individual files loaded OK, try loading the whole directory 143 178 return catalog.LoadDirectory(schemaPath) 144 179 } 180 + 181 + // extractAllSchemaIDs walks the schema directory and returns all schema IDs 182 + func extractAllSchemaIDs(schemaPath string) []string { 183 + var schemaIDs []string 184 + 185 + filepath.Walk(schemaPath, func(path string, info os.FileInfo, err error) error { 186 + if err != nil { 187 + return err 188 + } 189 + 190 + // Skip test-data directory 191 + if info.IsDir() && info.Name() == "test-data" { 192 + return filepath.SkipDir 193 + } 194 + 195 + // Only process .json files 196 + if !info.IsDir() && filepath.Ext(path) == ".json" { 197 + // Convert file path to schema ID 198 + relPath, _ := filepath.Rel(schemaPath, path) 199 + schemaID := filepath.ToSlash(relPath) 200 + schemaID = schemaID[:len(schemaID)-5] // Remove .json extension 201 + schemaID = strings.ReplaceAll(schemaID, "/", ".") 202 + 203 + // Only include record schemas (not procedures) 204 + if strings.Contains(schemaID, ".record") || 205 + strings.Contains(schemaID, ".profile") || 206 + strings.Contains(schemaID, ".rules") || 207 + strings.Contains(schemaID, ".wiki") || 208 + strings.Contains(schemaID, ".subscription") || 209 + strings.Contains(schemaID, ".membership") || 210 + strings.Contains(schemaID, ".vote") || 211 + strings.Contains(schemaID, ".tag") || 212 + strings.Contains(schemaID, ".comment") || 213 + strings.Contains(schemaID, ".share") || 214 + strings.Contains(schemaID, ".tribunalVote") || 215 + strings.Contains(schemaID, ".ruleProposal") || 216 + strings.Contains(schemaID, ".ban") { 217 + schemaIDs = append(schemaIDs, schemaID) 218 + } 219 + } 220 + return nil 221 + }) 222 + 223 + return schemaIDs 224 + } 225 + 226 + // validateTestData validates test JSON data files against their corresponding schemas 227 + func validateTestData(catalog *lexicon.BaseCatalog, testDataPath string, verbose bool, strict bool, allSchemas []string) error { 228 + // Check if test data directory exists 229 + if _, err := os.Stat(testDataPath); os.IsNotExist(err) { 230 + return fmt.Errorf("test data path does not exist: %s", testDataPath) 231 + } 232 + 233 + var validationErrors []string 234 + validFiles := 0 235 + invalidFiles := 0 236 + validSuccessCount := 0 237 + invalidFailCount := 0 238 + testedTypes := make(map[string]bool) 239 + 240 + // Walk through test data directory 241 + err := filepath.Walk(testDataPath, func(path string, info os.FileInfo, err error) error { 242 + if err != nil { 243 + return err 244 + } 245 + 246 + // Only process .json files 247 + if !info.IsDir() && filepath.Ext(path) == ".json" { 248 + filename := filepath.Base(path) 249 + isInvalidTest := strings.Contains(filename, "-invalid-") 250 + 251 + if verbose { 252 + if isInvalidTest { 253 + fmt.Printf("\n Testing (expect failure): %s\n", filename) 254 + } else { 255 + fmt.Printf("\n Testing: %s\n", filename) 256 + } 257 + } 258 + 259 + // Read the test file 260 + file, err := os.Open(path) 261 + if err != nil { 262 + validationErrors = append(validationErrors, fmt.Sprintf("Failed to open %s: %v", path, err)) 263 + return nil 264 + } 265 + defer file.Close() 266 + 267 + data, err := io.ReadAll(file) 268 + if err != nil { 269 + validationErrors = append(validationErrors, fmt.Sprintf("Failed to read %s: %v", path, err)) 270 + return nil 271 + } 272 + 273 + // Parse JSON data 274 + var recordData map[string]interface{} 275 + if err := json.Unmarshal(data, &recordData); err != nil { 276 + validationErrors = append(validationErrors, fmt.Sprintf("Failed to parse JSON in %s: %v", path, err)) 277 + return nil 278 + } 279 + 280 + // Extract $type field 281 + recordType, ok := recordData["$type"].(string) 282 + if !ok { 283 + validationErrors = append(validationErrors, fmt.Sprintf("Missing or invalid $type field in %s", path)) 284 + return nil 285 + } 286 + 287 + // Set validation flags 288 + flags := lexicon.ValidateFlags(0) 289 + if strict { 290 + flags |= lexicon.StrictRecursiveValidation 291 + } else { 292 + flags |= lexicon.AllowLenientDatetime 293 + } 294 + 295 + // Validate the record 296 + err = lexicon.ValidateRecord(catalog, recordData, recordType, flags) 297 + 298 + if isInvalidTest { 299 + // This file should fail validation 300 + invalidFiles++ 301 + if err != nil { 302 + invalidFailCount++ 303 + if verbose { 304 + fmt.Printf(" ✅ Correctly rejected invalid %s record: %v\n", recordType, err) 305 + } 306 + } else { 307 + validationErrors = append(validationErrors, fmt.Sprintf("Invalid test file %s passed validation when it should have failed", path)) 308 + if verbose { 309 + fmt.Printf(" ❌ ERROR: Invalid record passed validation!\n") 310 + } 311 + } 312 + } else { 313 + // This file should pass validation 314 + validFiles++ 315 + if err != nil { 316 + validationErrors = append(validationErrors, fmt.Sprintf("Validation failed for %s (type: %s): %v", path, recordType, err)) 317 + if verbose { 318 + fmt.Printf(" ❌ Failed: %v\n", err) 319 + } 320 + } else { 321 + validSuccessCount++ 322 + testedTypes[recordType] = true 323 + if verbose { 324 + fmt.Printf(" ✅ Valid %s record\n", recordType) 325 + } 326 + } 327 + } 328 + } 329 + return nil 330 + }) 331 + 332 + if err != nil { 333 + return fmt.Errorf("error walking test data directory: %w", err) 334 + } 335 + 336 + if len(validationErrors) > 0 { 337 + fmt.Println("\n❌ Test data validation errors found:") 338 + for _, errMsg := range validationErrors { 339 + fmt.Printf(" %s\n", errMsg) 340 + } 341 + return fmt.Errorf("found %d validation errors", len(validationErrors)) 342 + } 343 + 344 + totalFiles := validFiles + invalidFiles 345 + if totalFiles == 0 { 346 + fmt.Println(" ⚠️ No test data files found") 347 + } else { 348 + // Show validation summary 349 + fmt.Printf("\n📋 Validation Summary:\n") 350 + fmt.Printf(" Valid test files: %d/%d passed\n", validSuccessCount, validFiles) 351 + fmt.Printf(" Invalid test files: %d/%d correctly rejected\n", invalidFailCount, invalidFiles) 352 + 353 + if validSuccessCount == validFiles && invalidFailCount == invalidFiles { 354 + fmt.Printf("\n ✅ All test files behaved as expected!\n") 355 + } 356 + 357 + // Show test coverage summary (only for valid files) 358 + fmt.Printf("\n📊 Test Data Coverage Summary:\n") 359 + fmt.Printf(" - Records with test data: %d types\n", len(testedTypes)) 360 + fmt.Printf(" - Valid test files: %d\n", validFiles) 361 + fmt.Printf(" - Invalid test files: %d (for error validation)\n", invalidFiles) 362 + 363 + fmt.Printf("\n Tested record types:\n") 364 + for recordType := range testedTypes { 365 + fmt.Printf(" ✓ %s\n", recordType) 366 + } 367 + 368 + // Show untested schemas 369 + untestedCount := 0 370 + fmt.Printf("\n ⚠️ Record types without test data:\n") 371 + for _, schema := range allSchemas { 372 + if !testedTypes[schema] { 373 + fmt.Printf(" - %s\n", schema) 374 + untestedCount++ 375 + } 376 + } 377 + 378 + if untestedCount == 0 { 379 + fmt.Println(" (None - full test coverage!)") 380 + } else { 381 + fmt.Printf("\n Coverage: %d/%d record types have test data (%.1f%%)\n", 382 + len(testedTypes), len(allSchemas), 383 + float64(len(testedTypes))/float64(len(allSchemas))*100) 384 + } 385 + } 386 + return nil 387 + } 388 + 389 + // validateCrossReferences validates that all schema references resolve correctly 390 + func validateCrossReferences(catalog *lexicon.BaseCatalog, verbose bool) error { 391 + knownRefs := []string{ 392 + // Rich text facets 393 + "social.coves.richtext.facet", 394 + "social.coves.richtext.facet#byteSlice", 395 + "social.coves.richtext.facet#mention", 396 + "social.coves.richtext.facet#link", 397 + "social.coves.richtext.facet#bold", 398 + "social.coves.richtext.facet#italic", 399 + "social.coves.richtext.facet#strikethrough", 400 + "social.coves.richtext.facet#spoiler", 401 + 402 + // Post types and views 403 + "social.coves.post.get#postView", 404 + "social.coves.post.get#authorView", 405 + "social.coves.post.get#communityRef", 406 + "social.coves.post.get#imageView", 407 + "social.coves.post.get#videoView", 408 + "social.coves.post.get#externalView", 409 + "social.coves.post.get#postStats", 410 + "social.coves.post.get#viewerState", 411 + 412 + // Post record types 413 + "social.coves.post.record#originalAuthor", 414 + 415 + // Actor definitions 416 + "social.coves.actor.profile#geoLocation", 417 + 418 + // Community definitions 419 + "social.coves.community.rules#rule", 420 + } 421 + 422 + var errors []string 423 + if verbose { 424 + fmt.Println("\n🔍 Validating cross-references between schemas:") 425 + } 426 + 427 + for _, ref := range knownRefs { 428 + if _, err := catalog.Resolve(ref); err != nil { 429 + errors = append(errors, fmt.Sprintf("Failed to resolve reference %s: %v", ref, err)) 430 + } else if verbose { 431 + fmt.Printf(" ✅ %s\n", ref) 432 + } 433 + } 434 + 435 + if len(errors) > 0 { 436 + return fmt.Errorf("cross-reference validation failed:\n%s", strings.Join(errors, "\n")) 437 + } 438 + 439 + return nil 440 + }
+110
internal/validation/lexicon.go
··· 1 + package validation 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + 7 + lexicon "github.com/bluesky-social/indigo/atproto/lexicon" 8 + ) 9 + 10 + // LexiconValidator provides a convenient interface for validating atproto records 11 + type LexiconValidator struct { 12 + catalog *lexicon.BaseCatalog 13 + flags lexicon.ValidateFlags 14 + } 15 + 16 + // NewLexiconValidator creates a new validator with the specified schema directory 17 + func NewLexiconValidator(schemaPath string, strict bool) (*LexiconValidator, error) { 18 + catalog := lexicon.NewBaseCatalog() 19 + 20 + if err := catalog.LoadDirectory(schemaPath); err != nil { 21 + return nil, fmt.Errorf("failed to load lexicon schemas: %w", err) 22 + } 23 + 24 + flags := lexicon.ValidateFlags(0) 25 + if strict { 26 + flags |= lexicon.StrictRecursiveValidation 27 + } else { 28 + flags |= lexicon.AllowLenientDatetime 29 + } 30 + 31 + return &LexiconValidator{ 32 + catalog: &catalog, 33 + flags: flags, 34 + }, nil 35 + } 36 + 37 + // ValidateRecord validates a record against its schema 38 + func (v *LexiconValidator) ValidateRecord(recordData interface{}, recordType string) error { 39 + // Convert to map if needed 40 + var data map[string]interface{} 41 + 42 + switch rd := recordData.(type) { 43 + case map[string]interface{}: 44 + data = rd 45 + case []byte: 46 + if err := json.Unmarshal(rd, &data); err != nil { 47 + return fmt.Errorf("failed to parse JSON: %w", err) 48 + } 49 + case string: 50 + if err := json.Unmarshal([]byte(rd), &data); err != nil { 51 + return fmt.Errorf("failed to parse JSON: %w", err) 52 + } 53 + default: 54 + // Try to marshal and unmarshal to convert struct to map 55 + jsonBytes, err := json.Marshal(recordData) 56 + if err != nil { 57 + return fmt.Errorf("failed to convert record to JSON: %w", err) 58 + } 59 + if err := json.Unmarshal(jsonBytes, &data); err != nil { 60 + return fmt.Errorf("failed to parse JSON: %w", err) 61 + } 62 + } 63 + 64 + // Ensure $type field matches recordType 65 + if typeField, ok := data["$type"].(string); ok && typeField != recordType { 66 + return fmt.Errorf("$type field '%s' does not match expected type '%s'", typeField, recordType) 67 + } 68 + 69 + return lexicon.ValidateRecord(v.catalog, data, recordType, v.flags) 70 + } 71 + 72 + // ValidateActorProfile validates an actor profile record 73 + func (v *LexiconValidator) ValidateActorProfile(profile map[string]interface{}) error { 74 + return v.ValidateRecord(profile, "social.coves.actor.profile") 75 + } 76 + 77 + // ValidateCommunityProfile validates a community profile record 78 + func (v *LexiconValidator) ValidateCommunityProfile(profile map[string]interface{}) error { 79 + return v.ValidateRecord(profile, "social.coves.community.profile") 80 + } 81 + 82 + // ValidatePost validates a post record 83 + func (v *LexiconValidator) ValidatePost(post map[string]interface{}) error { 84 + return v.ValidateRecord(post, "social.coves.post.record") 85 + } 86 + 87 + // ValidateComment validates a comment record 88 + func (v *LexiconValidator) ValidateComment(comment map[string]interface{}) error { 89 + return v.ValidateRecord(comment, "social.coves.interaction.comment") 90 + } 91 + 92 + // ValidateVote validates a vote record 93 + func (v *LexiconValidator) ValidateVote(vote map[string]interface{}) error { 94 + return v.ValidateRecord(vote, "social.coves.interaction.vote") 95 + } 96 + 97 + // ValidateModerationAction validates a moderation action (ban, tribunalVote, etc.) 98 + func (v *LexiconValidator) ValidateModerationAction(action map[string]interface{}, actionType string) error { 99 + return v.ValidateRecord(action, fmt.Sprintf("social.coves.moderation.%s", actionType)) 100 + } 101 + 102 + // ResolveReference resolves a schema reference (e.g., "social.coves.post.get#postView") 103 + func (v *LexiconValidator) ResolveReference(ref string) (interface{}, error) { 104 + return v.catalog.Resolve(ref) 105 + } 106 + 107 + // GetCatalog returns the underlying lexicon catalog for advanced usage 108 + func (v *LexiconValidator) GetCatalog() *lexicon.BaseCatalog { 109 + return v.catalog 110 + }
+135
internal/validation/lexicon_test.go
··· 1 + package validation 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func TestNewLexiconValidator(t *testing.T) { 8 + // Test creating validator with valid schema path 9 + validator, err := NewLexiconValidator("../../internal/atproto/lexicon", false) 10 + if err != nil { 11 + t.Fatalf("Failed to create validator: %v", err) 12 + } 13 + if validator == nil { 14 + t.Fatal("Expected validator to be non-nil") 15 + } 16 + 17 + // Test creating validator with invalid schema path 18 + _, err = NewLexiconValidator("/nonexistent/path", false) 19 + if err == nil { 20 + t.Error("Expected error when creating validator with invalid path") 21 + } 22 + } 23 + 24 + func TestValidateActorProfile(t *testing.T) { 25 + validator, err := NewLexiconValidator("../../internal/atproto/lexicon", false) 26 + if err != nil { 27 + t.Fatalf("Failed to create validator: %v", err) 28 + } 29 + 30 + // Valid profile 31 + validProfile := map[string]interface{}{ 32 + "$type": "social.coves.actor.profile", 33 + "handle": "test.example.com", 34 + "displayName": "Test User", 35 + "createdAt": "2024-01-01T00:00:00Z", 36 + } 37 + 38 + if err := validator.ValidateActorProfile(validProfile); err != nil { 39 + t.Errorf("Valid profile failed validation: %v", err) 40 + } 41 + 42 + // Invalid profile - missing required field 43 + invalidProfile := map[string]interface{}{ 44 + "$type": "social.coves.actor.profile", 45 + "displayName": "Test User", 46 + } 47 + 48 + if err := validator.ValidateActorProfile(invalidProfile); err == nil { 49 + t.Error("Invalid profile passed validation when it should have failed") 50 + } 51 + } 52 + 53 + func TestValidatePost(t *testing.T) { 54 + validator, err := NewLexiconValidator("../../internal/atproto/lexicon", false) 55 + if err != nil { 56 + t.Fatalf("Failed to create validator: %v", err) 57 + } 58 + 59 + // Valid post 60 + validPost := map[string]interface{}{ 61 + "$type": "social.coves.post.record", 62 + "community": "did:plc:test123", 63 + "postType": "text", 64 + "title": "Test Post", 65 + "text": "This is a test", 66 + "tags": []string{"test"}, 67 + "language": "en", 68 + "contentWarnings": []string{}, 69 + "createdAt": "2024-01-01T00:00:00Z", 70 + } 71 + 72 + if err := validator.ValidatePost(validPost); err != nil { 73 + t.Errorf("Valid post failed validation: %v", err) 74 + } 75 + 76 + // Invalid post - invalid enum value 77 + invalidPost := map[string]interface{}{ 78 + "$type": "social.coves.post.record", 79 + "community": "did:plc:test123", 80 + "postType": "invalid", 81 + "title": "Test Post", 82 + "text": "This is a test", 83 + "tags": []string{"test"}, 84 + "language": "en", 85 + "contentWarnings": []string{}, 86 + "createdAt": "2024-01-01T00:00:00Z", 87 + } 88 + 89 + if err := validator.ValidatePost(invalidPost); err == nil { 90 + t.Error("Invalid post passed validation when it should have failed") 91 + } 92 + } 93 + 94 + func TestValidateRecordWithDifferentInputTypes(t *testing.T) { 95 + validator, err := NewLexiconValidator("../../internal/atproto/lexicon", false) 96 + if err != nil { 97 + t.Fatalf("Failed to create validator: %v", err) 98 + } 99 + 100 + // Test with JSON string 101 + jsonString := `{ 102 + "$type": "social.coves.interaction.vote", 103 + "subject": "at://did:plc:test/social.coves.post.text/abc123", 104 + "createdAt": "2024-01-01T00:00:00Z" 105 + }` 106 + 107 + if err := validator.ValidateRecord(jsonString, "social.coves.interaction.vote"); err != nil { 108 + t.Errorf("Failed to validate JSON string: %v", err) 109 + } 110 + 111 + // Test with JSON bytes 112 + jsonBytes := []byte(jsonString) 113 + if err := validator.ValidateRecord(jsonBytes, "social.coves.interaction.vote"); err != nil { 114 + t.Errorf("Failed to validate JSON bytes: %v", err) 115 + } 116 + } 117 + 118 + func TestStrictValidation(t *testing.T) { 119 + // Create validator with strict mode 120 + validator, err := NewLexiconValidator("../../internal/atproto/lexicon", true) 121 + if err != nil { 122 + t.Fatalf("Failed to create validator: %v", err) 123 + } 124 + 125 + // Profile with datetime missing timezone (should fail in strict mode) 126 + profile := map[string]interface{}{ 127 + "$type": "social.coves.actor.profile", 128 + "handle": "test.example.com", 129 + "createdAt": "2024-01-01T00:00:00", // Missing Z 130 + } 131 + 132 + if err := validator.ValidateActorProfile(profile); err == nil { 133 + t.Error("Expected strict validation to fail on datetime without timezone") 134 + } 135 + }
-16
scripts/validate-schemas.sh
··· 1 - #!/bin/bash 2 - # Script to validate Coves lexicon schemas 3 - 4 - echo "🔍 Validating Coves lexicon schemas..." 5 - echo "" 6 - 7 - go run cmd/validate-lexicon/main.go -v 8 - 9 - if [ $? -eq 0 ]; then 10 - echo "" 11 - echo "🎉 All schemas are valid and ready to use!" 12 - else 13 - echo "" 14 - echo "❌ Schema validation failed. Please check the errors above." 15 - exit 1 16 - fi
+63
tests/lexicon-test-data/README.md
··· 1 + # Lexicon Test Data 2 + 3 + This directory contains test data files for validating AT Protocol lexicon schemas. 4 + 5 + ## Naming Convention 6 + 7 + Test files follow a specific naming pattern to distinguish between valid and invalid test cases: 8 + 9 + - **Valid test files**: `{type}-valid.json` or `{type}-valid-{variant}.json` 10 + - Example: `profile-valid.json`, `post-valid-text.json` 11 + - These files should pass validation 12 + 13 + - **Invalid test files**: `{type}-invalid-{reason}.json` 14 + - Example: `profile-invalid-missing-handle.json`, `post-invalid-enum-type.json` 15 + - These files should fail validation 16 + - Used to test that the validator correctly rejects malformed data 17 + 18 + ## Directory Structure 19 + 20 + ``` 21 + lexicon-test-data/ 22 + ├── actor/ 23 + │ ├── profile-valid.json # Valid actor profile 24 + │ └── profile-invalid-missing-handle.json # Missing required field 25 + ├── community/ 26 + │ └── profile-valid.json # Valid community profile 27 + ├── interaction/ 28 + │ └── vote-valid.json # Valid vote record 29 + ├── moderation/ 30 + │ └── ban-valid.json # Valid ban record 31 + └── post/ 32 + ├── post-valid-text.json # Valid text post 33 + └── post-invalid-enum-type.json # Invalid postType value 34 + ``` 35 + 36 + ## Running Tests 37 + 38 + The validator automatically processes all files in this directory: 39 + - Valid files are expected to pass validation 40 + - Invalid files (containing `-invalid-` in the name) are expected to fail 41 + - The validator reports if any files don't behave as expected 42 + 43 + ```bash 44 + # Run full validation 45 + go run cmd/validate-lexicon/main.go 46 + 47 + # Run with verbose output to see each file 48 + go run cmd/validate-lexicon/main.go -v 49 + ``` 50 + 51 + ## Adding New Test Data 52 + 53 + When adding new test data: 54 + 55 + 1. Create valid examples that showcase proper schema usage 56 + 2. Create invalid examples that test common validation errors: 57 + - Missing required fields 58 + - Invalid enum values 59 + - Wrong data types 60 + - Invalid formats (e.g., bad DIDs, malformed dates) 61 + 62 + 3. Name files according to the convention above 63 + 4. Run the validator to ensure your test files behave as expected
+4
tests/lexicon-test-data/actor/profile-invalid-missing-handle.json
··· 1 + { 2 + "$type": "social.coves.actor.profile", 3 + "displayName": "Missing Required Fields" 4 + }
+7
tests/lexicon-test-data/actor/profile-valid.json
··· 1 + { 2 + "$type": "social.coves.actor.profile", 3 + "handle": "alice.example.com", 4 + "displayName": "Alice Johnson", 5 + "bio": "Software developer passionate about open-source", 6 + "createdAt": "2024-01-15T10:30:00Z" 7 + }
+10
tests/lexicon-test-data/community/profile-valid.json
··· 1 + { 2 + "$type": "social.coves.community.profile", 3 + "name": "programming", 4 + "displayName": "Programming Community", 5 + "description": "A community for programmers", 6 + "creator": "did:plc:creator123456", 7 + "moderationType": "moderator", 8 + "federatedFrom": "coves", 9 + "createdAt": "2023-12-01T08:00:00Z" 10 + }
+5
tests/lexicon-test-data/interaction/vote-valid.json
··· 1 + { 2 + "$type": "social.coves.interaction.vote", 3 + "subject": "at://did:plc:alice123/social.coves.post.text/3kbx2n5p", 4 + "createdAt": "2025-01-09T15:00:00Z" 5 + }
+9
tests/lexicon-test-data/moderation/ban-valid.json
··· 1 + { 2 + "$type": "social.coves.moderation.ban", 3 + "community": "did:plc:programming123", 4 + "subject": "did:plc:troublemaker456", 5 + "banType": "moderator", 6 + "reason": "Repeated violations of community guidelines: spam and harassment", 7 + "bannedBy": "did:plc:moderator789", 8 + "createdAt": "2025-01-09T12:00:00Z" 9 + }
+11
tests/lexicon-test-data/post/post-invalid-enum-type.json
··· 1 + { 2 + "$type": "social.coves.post.record", 3 + "community": "did:plc:programming123", 4 + "postType": "invalid-type", 5 + "title": "This has an invalid post type", 6 + "text": "The postType field has an invalid value", 7 + "tags": [], 8 + "language": "en", 9 + "contentWarnings": [], 10 + "createdAt": "2025-01-09T14:30:00Z" 11 + }
+24
tests/lexicon-test-data/post/post-valid-text.json
··· 1 + { 2 + "$type": "social.coves.post.record", 3 + "community": "did:plc:programming123", 4 + "postType": "text", 5 + "title": "Best practices for error handling in Go", 6 + "text": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...", 7 + "textFacets": [ 8 + { 9 + "index": { 10 + "byteStart": 20, 11 + "byteEnd": 22 12 + }, 13 + "features": [ 14 + { 15 + "$type": "social.coves.richtext.facet#bold" 16 + } 17 + ] 18 + } 19 + ], 20 + "tags": ["golang", "error-handling", "best-practices"], 21 + "language": "en", 22 + "contentWarnings": [], 23 + "createdAt": "2025-01-09T14:30:00Z" 24 + }
+141
tests/lexicon_validation_test.go
··· 1 1 package tests 2 2 3 3 import ( 4 + "strings" 4 5 "testing" 5 6 6 7 lexicon "github.com/bluesky-social/indigo/atproto/lexicon" ··· 75 76 }) 76 77 } 77 78 } 79 + 80 + func TestValidateRecord(t *testing.T) { 81 + // Create a new catalog 82 + catalog := lexicon.NewBaseCatalog() 83 + 84 + // Load all schemas 85 + if err := catalog.LoadDirectory("../internal/atproto/lexicon"); err != nil { 86 + t.Fatalf("Failed to load lexicon schemas: %v", err) 87 + } 88 + 89 + // Test cases for ValidateRecord 90 + tests := []struct { 91 + name string 92 + recordType string 93 + recordData map[string]interface{} 94 + shouldFail bool 95 + errorContains string 96 + }{ 97 + { 98 + name: "Valid actor profile", 99 + recordType: "social.coves.actor.profile", 100 + recordData: map[string]interface{}{ 101 + "$type": "social.coves.actor.profile", 102 + "handle": "alice.example.com", 103 + "displayName": "Alice Johnson", 104 + "createdAt": "2024-01-15T10:30:00Z", 105 + }, 106 + shouldFail: false, 107 + }, 108 + { 109 + name: "Invalid actor profile - missing required field", 110 + recordType: "social.coves.actor.profile", 111 + recordData: map[string]interface{}{ 112 + "$type": "social.coves.actor.profile", 113 + "displayName": "Alice Johnson", 114 + }, 115 + shouldFail: true, 116 + errorContains: "required field missing: handle", 117 + }, 118 + { 119 + name: "Valid community profile", 120 + recordType: "social.coves.community.profile", 121 + recordData: map[string]interface{}{ 122 + "$type": "social.coves.community.profile", 123 + "name": "programming", 124 + "displayName": "Programming Community", 125 + "creator": "did:plc:creator123", 126 + "moderationType": "moderator", 127 + "federatedFrom": "coves", 128 + "createdAt": "2023-12-01T08:00:00Z", 129 + }, 130 + shouldFail: false, 131 + }, 132 + { 133 + name: "Valid post record", 134 + recordType: "social.coves.post.record", 135 + recordData: map[string]interface{}{ 136 + "$type": "social.coves.post.record", 137 + "community": "did:plc:programming123", 138 + "postType": "text", 139 + "title": "Test Post", 140 + "text": "This is a test post", 141 + "tags": []string{"test", "golang"}, 142 + "language": "en", 143 + "contentWarnings": []string{}, 144 + "createdAt": "2025-01-09T14:30:00Z", 145 + }, 146 + shouldFail: false, 147 + }, 148 + { 149 + name: "Invalid post record - invalid enum value", 150 + recordType: "social.coves.post.record", 151 + recordData: map[string]interface{}{ 152 + "$type": "social.coves.post.record", 153 + "community": "did:plc:programming123", 154 + "postType": "invalid-type", 155 + "title": "Test Post", 156 + "text": "This is a test post", 157 + "tags": []string{"test"}, 158 + "language": "en", 159 + "contentWarnings": []string{}, 160 + "createdAt": "2025-01-09T14:30:00Z", 161 + }, 162 + shouldFail: true, 163 + errorContains: "string val not in required enum", 164 + }, 165 + } 166 + 167 + for _, tt := range tests { 168 + t.Run(tt.name, func(t *testing.T) { 169 + err := lexicon.ValidateRecord(&catalog, tt.recordData, tt.recordType, lexicon.AllowLenientDatetime) 170 + 171 + if tt.shouldFail { 172 + if err == nil { 173 + t.Errorf("Expected validation to fail but it passed") 174 + } else if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) { 175 + t.Errorf("Expected error to contain '%s', got: %v", tt.errorContains, err) 176 + } 177 + } else { 178 + if err != nil { 179 + t.Errorf("Expected validation to pass but got error: %v", err) 180 + } 181 + } 182 + }) 183 + } 184 + } 185 + 186 + func contains(s, substr string) bool { 187 + return len(s) >= len(substr) && (s == substr || len(s) > 0 && strings.Contains(s, substr)) 188 + } 189 + 190 + func TestValidateRecordWithStrictMode(t *testing.T) { 191 + // Create a new catalog 192 + catalog := lexicon.NewBaseCatalog() 193 + 194 + // Load all schemas 195 + if err := catalog.LoadDirectory("../internal/atproto/lexicon"); err != nil { 196 + t.Fatalf("Failed to load lexicon schemas: %v", err) 197 + } 198 + 199 + // Test with strict validation flags 200 + recordData := map[string]interface{}{ 201 + "$type": "social.coves.actor.profile", 202 + "handle": "alice.example.com", 203 + "displayName": "Alice Johnson", 204 + "createdAt": "2024-01-15T10:30:00", // Missing timezone 205 + } 206 + 207 + // Should fail with strict validation 208 + err := lexicon.ValidateRecord(&catalog, recordData, "social.coves.actor.profile", lexicon.StrictRecursiveValidation) 209 + if err == nil { 210 + t.Error("Expected strict validation to fail on datetime without timezone") 211 + } 212 + 213 + // Should pass with lenient datetime validation 214 + err = lexicon.ValidateRecord(&catalog, recordData, "social.coves.actor.profile", lexicon.AllowLenientDatetime) 215 + if err != nil { 216 + t.Errorf("Expected lenient validation to pass, got error: %v", err) 217 + } 218 + }