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>

+311 -15
cmd/validate-lexicon/main.go
···
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
lexicon "github.com/bluesky-social/indigo/atproto/lexicon"
)
func main() {
var (
-
schemaPath = flag.String("path", "internal/atproto/lexicon", "Path to lexicon schemas directory")
-
verbose = flag.Bool("v", false, "Verbose output")
)
flag.Parse()
···
log.Fatalf("Schema validation failed: %v", err)
}
-
fmt.Println("✅ All schemas validated successfully!")
}
// validateSchemaStructure performs additional validation checks
func validateSchemaStructure(catalog *lexicon.BaseCatalog, schemaPath string, verbose bool) error {
var validationErrors []string
var schemaFiles []string
-
// Collect all JSON schema files
err := filepath.Walk(schemaPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Only process .json files
if !info.IsDir() && filepath.Ext(path) == ".json" {
schemaFiles = append(schemaFiles, path)
}
return nil
})
···
}
}
-
// Try to resolve some of our expected schemas
-
expectedSchemas := []string{
-
"social.coves.actor.profile",
-
"social.coves.community.profile",
-
"social.coves.post.text",
-
"social.coves.richtext.facet",
-
}
-
if verbose {
-
fmt.Println("\nValidating key schemas:")
}
-
for _, schemaID := range expectedSchemas {
if _, err := catalog.Resolve(schemaID); err != nil {
-
validationErrors = append(validationErrors, fmt.Sprintf("Failed to resolve schema %s: %v", schemaID, err))
} else if verbose {
fmt.Printf(" ✅ %s\n", schemaID)
}
···
return fmt.Errorf("found %d validation errors", len(validationErrors))
}
return nil
}
···
return err
}
// Only process .json files
if !info.IsDir() && filepath.Ext(path) == ".json" {
schemaFiles = append(schemaFiles, path)
···
// If all individual files loaded OK, try loading the whole directory
return catalog.LoadDirectory(schemaPath)
}
···
package main
import (
+
"encoding/json"
"flag"
"fmt"
+
"io"
"log"
"os"
"path/filepath"
+
"strings"
lexicon "github.com/bluesky-social/indigo/atproto/lexicon"
)
func main() {
var (
+
schemaPath = flag.String("path", "internal/atproto/lexicon", "Path to lexicon schemas directory")
+
testDataPath = flag.String("test-data", "tests/lexicon-test-data", "Path to test data directory for ValidateRecord testing")
+
verbose = flag.Bool("v", false, "Verbose output")
+
strict = flag.Bool("strict", false, "Use strict validation mode")
+
schemasOnly = flag.Bool("schemas-only", false, "Only validate schemas, skip test data validation")
)
flag.Parse()
···
log.Fatalf("Schema validation failed: %v", err)
}
+
// Validate cross-references between schemas
+
if err := validateCrossReferences(&catalog, *verbose); err != nil {
+
log.Fatalf("Cross-reference validation failed: %v", err)
+
}
+
+
// Validate test data unless schemas-only flag is set
+
if !*schemasOnly {
+
fmt.Printf("\n📋 Validating test data from: %s\n", *testDataPath)
+
allSchemas := extractAllSchemaIDs(*schemaPath)
+
if err := validateTestData(&catalog, *testDataPath, *verbose, *strict, allSchemas); err != nil {
+
log.Fatalf("Test data validation failed: %v", err)
+
}
+
} else {
+
fmt.Println("\n⏩ Skipping test data validation (--schemas-only flag set)")
+
}
+
+
fmt.Println("\n✅ All validations passed successfully!")
}
// validateSchemaStructure performs additional validation checks
func validateSchemaStructure(catalog *lexicon.BaseCatalog, schemaPath string, verbose bool) error {
var validationErrors []string
var schemaFiles []string
+
var schemaIDs []string
+
// Collect all JSON schema files and derive their IDs
err := filepath.Walk(schemaPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
+
// Skip test-data directory
+
if info.IsDir() && info.Name() == "test-data" {
+
return filepath.SkipDir
+
}
+
// Only process .json files
if !info.IsDir() && filepath.Ext(path) == ".json" {
schemaFiles = append(schemaFiles, path)
+
+
// Convert file path to schema ID
+
// e.g., internal/atproto/lexicon/social/coves/actor/profile.json -> social.coves.actor.profile
+
relPath, _ := filepath.Rel(schemaPath, path)
+
schemaID := filepath.ToSlash(relPath)
+
schemaID = schemaID[:len(schemaID)-5] // Remove .json extension
+
schemaID = strings.ReplaceAll(schemaID, "/", ".")
+
schemaIDs = append(schemaIDs, schemaID)
}
return nil
})
···
}
}
+
// Validate all discovered schemas
if verbose {
+
fmt.Println("\nValidating all schemas:")
}
+
for i, schemaID := range schemaIDs {
if _, err := catalog.Resolve(schemaID); err != nil {
+
validationErrors = append(validationErrors, fmt.Sprintf("Failed to resolve schema %s (from %s): %v", schemaID, schemaFiles[i], err))
} else if verbose {
fmt.Printf(" ✅ %s\n", schemaID)
}
···
return fmt.Errorf("found %d validation errors", len(validationErrors))
}
+
fmt.Printf("\n✅ Successfully validated all %d schemas\n", len(schemaIDs))
return nil
}
···
return err
}
+
// Skip test-data directory
+
if info.IsDir() && info.Name() == "test-data" {
+
return filepath.SkipDir
+
}
+
// Only process .json files
if !info.IsDir() && filepath.Ext(path) == ".json" {
schemaFiles = append(schemaFiles, path)
···
// If all individual files loaded OK, try loading the whole directory
return catalog.LoadDirectory(schemaPath)
}
+
+
// extractAllSchemaIDs walks the schema directory and returns all schema IDs
+
func extractAllSchemaIDs(schemaPath string) []string {
+
var schemaIDs []string
+
+
filepath.Walk(schemaPath, func(path string, info os.FileInfo, err error) error {
+
if err != nil {
+
return err
+
}
+
+
// Skip test-data directory
+
if info.IsDir() && info.Name() == "test-data" {
+
return filepath.SkipDir
+
}
+
+
// Only process .json files
+
if !info.IsDir() && filepath.Ext(path) == ".json" {
+
// Convert file path to schema ID
+
relPath, _ := filepath.Rel(schemaPath, path)
+
schemaID := filepath.ToSlash(relPath)
+
schemaID = schemaID[:len(schemaID)-5] // Remove .json extension
+
schemaID = strings.ReplaceAll(schemaID, "/", ".")
+
+
// Only include record schemas (not procedures)
+
if strings.Contains(schemaID, ".record") ||
+
strings.Contains(schemaID, ".profile") ||
+
strings.Contains(schemaID, ".rules") ||
+
strings.Contains(schemaID, ".wiki") ||
+
strings.Contains(schemaID, ".subscription") ||
+
strings.Contains(schemaID, ".membership") ||
+
strings.Contains(schemaID, ".vote") ||
+
strings.Contains(schemaID, ".tag") ||
+
strings.Contains(schemaID, ".comment") ||
+
strings.Contains(schemaID, ".share") ||
+
strings.Contains(schemaID, ".tribunalVote") ||
+
strings.Contains(schemaID, ".ruleProposal") ||
+
strings.Contains(schemaID, ".ban") {
+
schemaIDs = append(schemaIDs, schemaID)
+
}
+
}
+
return nil
+
})
+
+
return schemaIDs
+
}
+
+
// validateTestData validates test JSON data files against their corresponding schemas
+
func validateTestData(catalog *lexicon.BaseCatalog, testDataPath string, verbose bool, strict bool, allSchemas []string) error {
+
// Check if test data directory exists
+
if _, err := os.Stat(testDataPath); os.IsNotExist(err) {
+
return fmt.Errorf("test data path does not exist: %s", testDataPath)
+
}
+
+
var validationErrors []string
+
validFiles := 0
+
invalidFiles := 0
+
validSuccessCount := 0
+
invalidFailCount := 0
+
testedTypes := make(map[string]bool)
+
+
// Walk through test data directory
+
err := filepath.Walk(testDataPath, func(path string, info os.FileInfo, err error) error {
+
if err != nil {
+
return err
+
}
+
+
// Only process .json files
+
if !info.IsDir() && filepath.Ext(path) == ".json" {
+
filename := filepath.Base(path)
+
isInvalidTest := strings.Contains(filename, "-invalid-")
+
+
if verbose {
+
if isInvalidTest {
+
fmt.Printf("\n Testing (expect failure): %s\n", filename)
+
} else {
+
fmt.Printf("\n Testing: %s\n", filename)
+
}
+
}
+
+
// Read the test file
+
file, err := os.Open(path)
+
if err != nil {
+
validationErrors = append(validationErrors, fmt.Sprintf("Failed to open %s: %v", path, err))
+
return nil
+
}
+
defer file.Close()
+
+
data, err := io.ReadAll(file)
+
if err != nil {
+
validationErrors = append(validationErrors, fmt.Sprintf("Failed to read %s: %v", path, err))
+
return nil
+
}
+
+
// Parse JSON data
+
var recordData map[string]interface{}
+
if err := json.Unmarshal(data, &recordData); err != nil {
+
validationErrors = append(validationErrors, fmt.Sprintf("Failed to parse JSON in %s: %v", path, err))
+
return nil
+
}
+
+
// Extract $type field
+
recordType, ok := recordData["$type"].(string)
+
if !ok {
+
validationErrors = append(validationErrors, fmt.Sprintf("Missing or invalid $type field in %s", path))
+
return nil
+
}
+
+
// Set validation flags
+
flags := lexicon.ValidateFlags(0)
+
if strict {
+
flags |= lexicon.StrictRecursiveValidation
+
} else {
+
flags |= lexicon.AllowLenientDatetime
+
}
+
+
// Validate the record
+
err = lexicon.ValidateRecord(catalog, recordData, recordType, flags)
+
+
if isInvalidTest {
+
// This file should fail validation
+
invalidFiles++
+
if err != nil {
+
invalidFailCount++
+
if verbose {
+
fmt.Printf(" ✅ Correctly rejected invalid %s record: %v\n", recordType, err)
+
}
+
} else {
+
validationErrors = append(validationErrors, fmt.Sprintf("Invalid test file %s passed validation when it should have failed", path))
+
if verbose {
+
fmt.Printf(" ❌ ERROR: Invalid record passed validation!\n")
+
}
+
}
+
} else {
+
// This file should pass validation
+
validFiles++
+
if err != nil {
+
validationErrors = append(validationErrors, fmt.Sprintf("Validation failed for %s (type: %s): %v", path, recordType, err))
+
if verbose {
+
fmt.Printf(" ❌ Failed: %v\n", err)
+
}
+
} else {
+
validSuccessCount++
+
testedTypes[recordType] = true
+
if verbose {
+
fmt.Printf(" ✅ Valid %s record\n", recordType)
+
}
+
}
+
}
+
}
+
return nil
+
})
+
+
if err != nil {
+
return fmt.Errorf("error walking test data directory: %w", err)
+
}
+
+
if len(validationErrors) > 0 {
+
fmt.Println("\n❌ Test data validation errors found:")
+
for _, errMsg := range validationErrors {
+
fmt.Printf(" %s\n", errMsg)
+
}
+
return fmt.Errorf("found %d validation errors", len(validationErrors))
+
}
+
+
totalFiles := validFiles + invalidFiles
+
if totalFiles == 0 {
+
fmt.Println(" ⚠️ No test data files found")
+
} else {
+
// Show validation summary
+
fmt.Printf("\n📋 Validation Summary:\n")
+
fmt.Printf(" Valid test files: %d/%d passed\n", validSuccessCount, validFiles)
+
fmt.Printf(" Invalid test files: %d/%d correctly rejected\n", invalidFailCount, invalidFiles)
+
+
if validSuccessCount == validFiles && invalidFailCount == invalidFiles {
+
fmt.Printf("\n ✅ All test files behaved as expected!\n")
+
}
+
+
// Show test coverage summary (only for valid files)
+
fmt.Printf("\n📊 Test Data Coverage Summary:\n")
+
fmt.Printf(" - Records with test data: %d types\n", len(testedTypes))
+
fmt.Printf(" - Valid test files: %d\n", validFiles)
+
fmt.Printf(" - Invalid test files: %d (for error validation)\n", invalidFiles)
+
+
fmt.Printf("\n Tested record types:\n")
+
for recordType := range testedTypes {
+
fmt.Printf(" ✓ %s\n", recordType)
+
}
+
+
// Show untested schemas
+
untestedCount := 0
+
fmt.Printf("\n ⚠️ Record types without test data:\n")
+
for _, schema := range allSchemas {
+
if !testedTypes[schema] {
+
fmt.Printf(" - %s\n", schema)
+
untestedCount++
+
}
+
}
+
+
if untestedCount == 0 {
+
fmt.Println(" (None - full test coverage!)")
+
} else {
+
fmt.Printf("\n Coverage: %d/%d record types have test data (%.1f%%)\n",
+
len(testedTypes), len(allSchemas),
+
float64(len(testedTypes))/float64(len(allSchemas))*100)
+
}
+
}
+
return nil
+
}
+
+
// validateCrossReferences validates that all schema references resolve correctly
+
func validateCrossReferences(catalog *lexicon.BaseCatalog, verbose bool) error {
+
knownRefs := []string{
+
// Rich text facets
+
"social.coves.richtext.facet",
+
"social.coves.richtext.facet#byteSlice",
+
"social.coves.richtext.facet#mention",
+
"social.coves.richtext.facet#link",
+
"social.coves.richtext.facet#bold",
+
"social.coves.richtext.facet#italic",
+
"social.coves.richtext.facet#strikethrough",
+
"social.coves.richtext.facet#spoiler",
+
+
// Post types and views
+
"social.coves.post.get#postView",
+
"social.coves.post.get#authorView",
+
"social.coves.post.get#communityRef",
+
"social.coves.post.get#imageView",
+
"social.coves.post.get#videoView",
+
"social.coves.post.get#externalView",
+
"social.coves.post.get#postStats",
+
"social.coves.post.get#viewerState",
+
+
// Post record types
+
"social.coves.post.record#originalAuthor",
+
+
// Actor definitions
+
"social.coves.actor.profile#geoLocation",
+
+
// Community definitions
+
"social.coves.community.rules#rule",
+
}
+
+
var errors []string
+
if verbose {
+
fmt.Println("\n🔍 Validating cross-references between schemas:")
+
}
+
+
for _, ref := range knownRefs {
+
if _, err := catalog.Resolve(ref); err != nil {
+
errors = append(errors, fmt.Sprintf("Failed to resolve reference %s: %v", ref, err))
+
} else if verbose {
+
fmt.Printf(" ✅ %s\n", ref)
+
}
+
}
+
+
if len(errors) > 0 {
+
return fmt.Errorf("cross-reference validation failed:\n%s", strings.Join(errors, "\n"))
+
}
+
+
return nil
+
}
+110
internal/validation/lexicon.go
···
···
+
package validation
+
+
import (
+
"encoding/json"
+
"fmt"
+
+
lexicon "github.com/bluesky-social/indigo/atproto/lexicon"
+
)
+
+
// LexiconValidator provides a convenient interface for validating atproto records
+
type LexiconValidator struct {
+
catalog *lexicon.BaseCatalog
+
flags lexicon.ValidateFlags
+
}
+
+
// NewLexiconValidator creates a new validator with the specified schema directory
+
func NewLexiconValidator(schemaPath string, strict bool) (*LexiconValidator, error) {
+
catalog := lexicon.NewBaseCatalog()
+
+
if err := catalog.LoadDirectory(schemaPath); err != nil {
+
return nil, fmt.Errorf("failed to load lexicon schemas: %w", err)
+
}
+
+
flags := lexicon.ValidateFlags(0)
+
if strict {
+
flags |= lexicon.StrictRecursiveValidation
+
} else {
+
flags |= lexicon.AllowLenientDatetime
+
}
+
+
return &LexiconValidator{
+
catalog: &catalog,
+
flags: flags,
+
}, nil
+
}
+
+
// ValidateRecord validates a record against its schema
+
func (v *LexiconValidator) ValidateRecord(recordData interface{}, recordType string) error {
+
// Convert to map if needed
+
var data map[string]interface{}
+
+
switch rd := recordData.(type) {
+
case map[string]interface{}:
+
data = rd
+
case []byte:
+
if err := json.Unmarshal(rd, &data); err != nil {
+
return fmt.Errorf("failed to parse JSON: %w", err)
+
}
+
case string:
+
if err := json.Unmarshal([]byte(rd), &data); err != nil {
+
return fmt.Errorf("failed to parse JSON: %w", err)
+
}
+
default:
+
// Try to marshal and unmarshal to convert struct to map
+
jsonBytes, err := json.Marshal(recordData)
+
if err != nil {
+
return fmt.Errorf("failed to convert record to JSON: %w", err)
+
}
+
if err := json.Unmarshal(jsonBytes, &data); err != nil {
+
return fmt.Errorf("failed to parse JSON: %w", err)
+
}
+
}
+
+
// Ensure $type field matches recordType
+
if typeField, ok := data["$type"].(string); ok && typeField != recordType {
+
return fmt.Errorf("$type field '%s' does not match expected type '%s'", typeField, recordType)
+
}
+
+
return lexicon.ValidateRecord(v.catalog, data, recordType, v.flags)
+
}
+
+
// ValidateActorProfile validates an actor profile record
+
func (v *LexiconValidator) ValidateActorProfile(profile map[string]interface{}) error {
+
return v.ValidateRecord(profile, "social.coves.actor.profile")
+
}
+
+
// ValidateCommunityProfile validates a community profile record
+
func (v *LexiconValidator) ValidateCommunityProfile(profile map[string]interface{}) error {
+
return v.ValidateRecord(profile, "social.coves.community.profile")
+
}
+
+
// ValidatePost validates a post record
+
func (v *LexiconValidator) ValidatePost(post map[string]interface{}) error {
+
return v.ValidateRecord(post, "social.coves.post.record")
+
}
+
+
// ValidateComment validates a comment record
+
func (v *LexiconValidator) ValidateComment(comment map[string]interface{}) error {
+
return v.ValidateRecord(comment, "social.coves.interaction.comment")
+
}
+
+
// ValidateVote validates a vote record
+
func (v *LexiconValidator) ValidateVote(vote map[string]interface{}) error {
+
return v.ValidateRecord(vote, "social.coves.interaction.vote")
+
}
+
+
// ValidateModerationAction validates a moderation action (ban, tribunalVote, etc.)
+
func (v *LexiconValidator) ValidateModerationAction(action map[string]interface{}, actionType string) error {
+
return v.ValidateRecord(action, fmt.Sprintf("social.coves.moderation.%s", actionType))
+
}
+
+
// ResolveReference resolves a schema reference (e.g., "social.coves.post.get#postView")
+
func (v *LexiconValidator) ResolveReference(ref string) (interface{}, error) {
+
return v.catalog.Resolve(ref)
+
}
+
+
// GetCatalog returns the underlying lexicon catalog for advanced usage
+
func (v *LexiconValidator) GetCatalog() *lexicon.BaseCatalog {
+
return v.catalog
+
}
+135
internal/validation/lexicon_test.go
···
···
+
package validation
+
+
import (
+
"testing"
+
)
+
+
func TestNewLexiconValidator(t *testing.T) {
+
// Test creating validator with valid schema path
+
validator, err := NewLexiconValidator("../../internal/atproto/lexicon", false)
+
if err != nil {
+
t.Fatalf("Failed to create validator: %v", err)
+
}
+
if validator == nil {
+
t.Fatal("Expected validator to be non-nil")
+
}
+
+
// Test creating validator with invalid schema path
+
_, err = NewLexiconValidator("/nonexistent/path", false)
+
if err == nil {
+
t.Error("Expected error when creating validator with invalid path")
+
}
+
}
+
+
func TestValidateActorProfile(t *testing.T) {
+
validator, err := NewLexiconValidator("../../internal/atproto/lexicon", false)
+
if err != nil {
+
t.Fatalf("Failed to create validator: %v", err)
+
}
+
+
// Valid profile
+
validProfile := map[string]interface{}{
+
"$type": "social.coves.actor.profile",
+
"handle": "test.example.com",
+
"displayName": "Test User",
+
"createdAt": "2024-01-01T00:00:00Z",
+
}
+
+
if err := validator.ValidateActorProfile(validProfile); err != nil {
+
t.Errorf("Valid profile failed validation: %v", err)
+
}
+
+
// Invalid profile - missing required field
+
invalidProfile := map[string]interface{}{
+
"$type": "social.coves.actor.profile",
+
"displayName": "Test User",
+
}
+
+
if err := validator.ValidateActorProfile(invalidProfile); err == nil {
+
t.Error("Invalid profile passed validation when it should have failed")
+
}
+
}
+
+
func TestValidatePost(t *testing.T) {
+
validator, err := NewLexiconValidator("../../internal/atproto/lexicon", false)
+
if err != nil {
+
t.Fatalf("Failed to create validator: %v", err)
+
}
+
+
// Valid post
+
validPost := map[string]interface{}{
+
"$type": "social.coves.post.record",
+
"community": "did:plc:test123",
+
"postType": "text",
+
"title": "Test Post",
+
"text": "This is a test",
+
"tags": []string{"test"},
+
"language": "en",
+
"contentWarnings": []string{},
+
"createdAt": "2024-01-01T00:00:00Z",
+
}
+
+
if err := validator.ValidatePost(validPost); err != nil {
+
t.Errorf("Valid post failed validation: %v", err)
+
}
+
+
// Invalid post - invalid enum value
+
invalidPost := map[string]interface{}{
+
"$type": "social.coves.post.record",
+
"community": "did:plc:test123",
+
"postType": "invalid",
+
"title": "Test Post",
+
"text": "This is a test",
+
"tags": []string{"test"},
+
"language": "en",
+
"contentWarnings": []string{},
+
"createdAt": "2024-01-01T00:00:00Z",
+
}
+
+
if err := validator.ValidatePost(invalidPost); err == nil {
+
t.Error("Invalid post passed validation when it should have failed")
+
}
+
}
+
+
func TestValidateRecordWithDifferentInputTypes(t *testing.T) {
+
validator, err := NewLexiconValidator("../../internal/atproto/lexicon", false)
+
if err != nil {
+
t.Fatalf("Failed to create validator: %v", err)
+
}
+
+
// Test with JSON string
+
jsonString := `{
+
"$type": "social.coves.interaction.vote",
+
"subject": "at://did:plc:test/social.coves.post.text/abc123",
+
"createdAt": "2024-01-01T00:00:00Z"
+
}`
+
+
if err := validator.ValidateRecord(jsonString, "social.coves.interaction.vote"); err != nil {
+
t.Errorf("Failed to validate JSON string: %v", err)
+
}
+
+
// Test with JSON bytes
+
jsonBytes := []byte(jsonString)
+
if err := validator.ValidateRecord(jsonBytes, "social.coves.interaction.vote"); err != nil {
+
t.Errorf("Failed to validate JSON bytes: %v", err)
+
}
+
}
+
+
func TestStrictValidation(t *testing.T) {
+
// Create validator with strict mode
+
validator, err := NewLexiconValidator("../../internal/atproto/lexicon", true)
+
if err != nil {
+
t.Fatalf("Failed to create validator: %v", err)
+
}
+
+
// Profile with datetime missing timezone (should fail in strict mode)
+
profile := map[string]interface{}{
+
"$type": "social.coves.actor.profile",
+
"handle": "test.example.com",
+
"createdAt": "2024-01-01T00:00:00", // Missing Z
+
}
+
+
if err := validator.ValidateActorProfile(profile); err == nil {
+
t.Error("Expected strict validation to fail on datetime without timezone")
+
}
+
}
-16
scripts/validate-schemas.sh
···
-
#!/bin/bash
-
# Script to validate Coves lexicon schemas
-
-
echo "🔍 Validating Coves lexicon schemas..."
-
echo ""
-
-
go run cmd/validate-lexicon/main.go -v
-
-
if [ $? -eq 0 ]; then
-
echo ""
-
echo "🎉 All schemas are valid and ready to use!"
-
else
-
echo ""
-
echo "❌ Schema validation failed. Please check the errors above."
-
exit 1
-
fi
···
+63
tests/lexicon-test-data/README.md
···
···
+
# Lexicon Test Data
+
+
This directory contains test data files for validating AT Protocol lexicon schemas.
+
+
## Naming Convention
+
+
Test files follow a specific naming pattern to distinguish between valid and invalid test cases:
+
+
- **Valid test files**: `{type}-valid.json` or `{type}-valid-{variant}.json`
+
- Example: `profile-valid.json`, `post-valid-text.json`
+
- These files should pass validation
+
+
- **Invalid test files**: `{type}-invalid-{reason}.json`
+
- Example: `profile-invalid-missing-handle.json`, `post-invalid-enum-type.json`
+
- These files should fail validation
+
- Used to test that the validator correctly rejects malformed data
+
+
## Directory Structure
+
+
```
+
lexicon-test-data/
+
├── actor/
+
│ ├── profile-valid.json # Valid actor profile
+
│ └── profile-invalid-missing-handle.json # Missing required field
+
├── community/
+
│ └── profile-valid.json # Valid community profile
+
├── interaction/
+
│ └── vote-valid.json # Valid vote record
+
├── moderation/
+
│ └── ban-valid.json # Valid ban record
+
└── post/
+
├── post-valid-text.json # Valid text post
+
└── post-invalid-enum-type.json # Invalid postType value
+
```
+
+
## Running Tests
+
+
The validator automatically processes all files in this directory:
+
- Valid files are expected to pass validation
+
- Invalid files (containing `-invalid-` in the name) are expected to fail
+
- The validator reports if any files don't behave as expected
+
+
```bash
+
# Run full validation
+
go run cmd/validate-lexicon/main.go
+
+
# Run with verbose output to see each file
+
go run cmd/validate-lexicon/main.go -v
+
```
+
+
## Adding New Test Data
+
+
When adding new test data:
+
+
1. Create valid examples that showcase proper schema usage
+
2. Create invalid examples that test common validation errors:
+
- Missing required fields
+
- Invalid enum values
+
- Wrong data types
+
- Invalid formats (e.g., bad DIDs, malformed dates)
+
+
3. Name files according to the convention above
+
4. Run the validator to ensure your test files behave as expected
+4
tests/lexicon-test-data/actor/profile-invalid-missing-handle.json
···
···
+
{
+
"$type": "social.coves.actor.profile",
+
"displayName": "Missing Required Fields"
+
}
+7
tests/lexicon-test-data/actor/profile-valid.json
···
···
+
{
+
"$type": "social.coves.actor.profile",
+
"handle": "alice.example.com",
+
"displayName": "Alice Johnson",
+
"bio": "Software developer passionate about open-source",
+
"createdAt": "2024-01-15T10:30:00Z"
+
}
+10
tests/lexicon-test-data/community/profile-valid.json
···
···
+
{
+
"$type": "social.coves.community.profile",
+
"name": "programming",
+
"displayName": "Programming Community",
+
"description": "A community for programmers",
+
"creator": "did:plc:creator123456",
+
"moderationType": "moderator",
+
"federatedFrom": "coves",
+
"createdAt": "2023-12-01T08:00:00Z"
+
}
+5
tests/lexicon-test-data/interaction/vote-valid.json
···
···
+
{
+
"$type": "social.coves.interaction.vote",
+
"subject": "at://did:plc:alice123/social.coves.post.text/3kbx2n5p",
+
"createdAt": "2025-01-09T15:00:00Z"
+
}
+9
tests/lexicon-test-data/moderation/ban-valid.json
···
···
+
{
+
"$type": "social.coves.moderation.ban",
+
"community": "did:plc:programming123",
+
"subject": "did:plc:troublemaker456",
+
"banType": "moderator",
+
"reason": "Repeated violations of community guidelines: spam and harassment",
+
"bannedBy": "did:plc:moderator789",
+
"createdAt": "2025-01-09T12:00:00Z"
+
}
+11
tests/lexicon-test-data/post/post-invalid-enum-type.json
···
···
+
{
+
"$type": "social.coves.post.record",
+
"community": "did:plc:programming123",
+
"postType": "invalid-type",
+
"title": "This has an invalid post type",
+
"text": "The postType field has an invalid value",
+
"tags": [],
+
"language": "en",
+
"contentWarnings": [],
+
"createdAt": "2025-01-09T14:30:00Z"
+
}
+24
tests/lexicon-test-data/post/post-valid-text.json
···
···
+
{
+
"$type": "social.coves.post.record",
+
"community": "did:plc:programming123",
+
"postType": "text",
+
"title": "Best practices for error handling in Go",
+
"text": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...",
+
"textFacets": [
+
{
+
"index": {
+
"byteStart": 20,
+
"byteEnd": 22
+
},
+
"features": [
+
{
+
"$type": "social.coves.richtext.facet#bold"
+
}
+
]
+
}
+
],
+
"tags": ["golang", "error-handling", "best-practices"],
+
"language": "en",
+
"contentWarnings": [],
+
"createdAt": "2025-01-09T14:30:00Z"
+
}
+141
tests/lexicon_validation_test.go
···
package tests
import (
"testing"
lexicon "github.com/bluesky-social/indigo/atproto/lexicon"
···
})
}
}
···
package tests
import (
+
"strings"
"testing"
lexicon "github.com/bluesky-social/indigo/atproto/lexicon"
···
})
}
}
+
+
func TestValidateRecord(t *testing.T) {
+
// Create a new catalog
+
catalog := lexicon.NewBaseCatalog()
+
+
// Load all schemas
+
if err := catalog.LoadDirectory("../internal/atproto/lexicon"); err != nil {
+
t.Fatalf("Failed to load lexicon schemas: %v", err)
+
}
+
+
// Test cases for ValidateRecord
+
tests := []struct {
+
name string
+
recordType string
+
recordData map[string]interface{}
+
shouldFail bool
+
errorContains string
+
}{
+
{
+
name: "Valid actor profile",
+
recordType: "social.coves.actor.profile",
+
recordData: map[string]interface{}{
+
"$type": "social.coves.actor.profile",
+
"handle": "alice.example.com",
+
"displayName": "Alice Johnson",
+
"createdAt": "2024-01-15T10:30:00Z",
+
},
+
shouldFail: false,
+
},
+
{
+
name: "Invalid actor profile - missing required field",
+
recordType: "social.coves.actor.profile",
+
recordData: map[string]interface{}{
+
"$type": "social.coves.actor.profile",
+
"displayName": "Alice Johnson",
+
},
+
shouldFail: true,
+
errorContains: "required field missing: handle",
+
},
+
{
+
name: "Valid community profile",
+
recordType: "social.coves.community.profile",
+
recordData: map[string]interface{}{
+
"$type": "social.coves.community.profile",
+
"name": "programming",
+
"displayName": "Programming Community",
+
"creator": "did:plc:creator123",
+
"moderationType": "moderator",
+
"federatedFrom": "coves",
+
"createdAt": "2023-12-01T08:00:00Z",
+
},
+
shouldFail: false,
+
},
+
{
+
name: "Valid post record",
+
recordType: "social.coves.post.record",
+
recordData: map[string]interface{}{
+
"$type": "social.coves.post.record",
+
"community": "did:plc:programming123",
+
"postType": "text",
+
"title": "Test Post",
+
"text": "This is a test post",
+
"tags": []string{"test", "golang"},
+
"language": "en",
+
"contentWarnings": []string{},
+
"createdAt": "2025-01-09T14:30:00Z",
+
},
+
shouldFail: false,
+
},
+
{
+
name: "Invalid post record - invalid enum value",
+
recordType: "social.coves.post.record",
+
recordData: map[string]interface{}{
+
"$type": "social.coves.post.record",
+
"community": "did:plc:programming123",
+
"postType": "invalid-type",
+
"title": "Test Post",
+
"text": "This is a test post",
+
"tags": []string{"test"},
+
"language": "en",
+
"contentWarnings": []string{},
+
"createdAt": "2025-01-09T14:30:00Z",
+
},
+
shouldFail: true,
+
errorContains: "string val not in required enum",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
err := lexicon.ValidateRecord(&catalog, tt.recordData, tt.recordType, lexicon.AllowLenientDatetime)
+
+
if tt.shouldFail {
+
if err == nil {
+
t.Errorf("Expected validation to fail but it passed")
+
} else if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) {
+
t.Errorf("Expected error to contain '%s', got: %v", tt.errorContains, err)
+
}
+
} else {
+
if err != nil {
+
t.Errorf("Expected validation to pass but got error: %v", err)
+
}
+
}
+
})
+
}
+
}
+
+
func contains(s, substr string) bool {
+
return len(s) >= len(substr) && (s == substr || len(s) > 0 && strings.Contains(s, substr))
+
}
+
+
func TestValidateRecordWithStrictMode(t *testing.T) {
+
// Create a new catalog
+
catalog := lexicon.NewBaseCatalog()
+
+
// Load all schemas
+
if err := catalog.LoadDirectory("../internal/atproto/lexicon"); err != nil {
+
t.Fatalf("Failed to load lexicon schemas: %v", err)
+
}
+
+
// Test with strict validation flags
+
recordData := map[string]interface{}{
+
"$type": "social.coves.actor.profile",
+
"handle": "alice.example.com",
+
"displayName": "Alice Johnson",
+
"createdAt": "2024-01-15T10:30:00", // Missing timezone
+
}
+
+
// Should fail with strict validation
+
err := lexicon.ValidateRecord(&catalog, recordData, "social.coves.actor.profile", lexicon.StrictRecursiveValidation)
+
if err == nil {
+
t.Error("Expected strict validation to fail on datetime without timezone")
+
}
+
+
// Should pass with lenient datetime validation
+
err = lexicon.ValidateRecord(&catalog, recordData, "social.coves.actor.profile", lexicon.AllowLenientDatetime)
+
if err != nil {
+
t.Errorf("Expected lenient validation to pass, got error: %v", err)
+
}
+
}