A community based topic aggregation platform built on atproto
1package tests
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 lexicon "github.com/bluesky-social/indigo/atproto/lexicon"
10)
11
12func TestLexiconSchemaValidation(t *testing.T) {
13 // Create a new catalog
14 catalog := lexicon.NewBaseCatalog()
15
16 // Load all schemas from the lexicon directory
17 schemaPath := "../internal/atproto/lexicon"
18 if err := catalog.LoadDirectory(schemaPath); err != nil {
19 t.Fatalf("Failed to load lexicon schemas: %v", err)
20 }
21
22 // Walk through the directory and find all lexicon files
23 var lexiconFiles []string
24 err := filepath.Walk(schemaPath, func(path string, info os.FileInfo, err error) error {
25 if err != nil {
26 return err
27 }
28 if strings.HasSuffix(path, ".json") && !info.IsDir() {
29 lexiconFiles = append(lexiconFiles, path)
30 }
31 return nil
32 })
33 if err != nil {
34 t.Fatalf("Failed to walk directory: %v", err)
35 }
36
37 t.Logf("Found %d lexicon files to validate", len(lexiconFiles))
38
39 // Extract schema IDs from file paths and test resolution
40 for _, filePath := range lexiconFiles {
41 // Convert file path to schema ID
42 // e.g., ../internal/atproto/lexicon/social/coves/actor/profile.json -> social.coves.actor.profile
43 relPath, err := filepath.Rel(schemaPath, filePath)
44 if err != nil {
45 t.Fatalf("Failed to get relative path for %s: %v", filePath, err)
46 }
47 relPath = strings.TrimSuffix(relPath, ".json")
48 schemaID := strings.ReplaceAll(relPath, string(filepath.Separator), ".")
49
50 t.Run(schemaID, func(t *testing.T) {
51 // Skip validation for definition-only files (*.defs) - they don't need a "main" section
52 // These files only contain shared type definitions referenced by other schemas
53 if strings.HasSuffix(schemaID, ".defs") {
54 t.Skip("Skipping defs-only file (no main section required)")
55 }
56
57 if _, resolveErr := catalog.Resolve(schemaID); resolveErr != nil {
58 t.Errorf("Failed to resolve schema %s: %v", schemaID, resolveErr)
59 }
60 })
61 }
62}
63
64func TestLexiconCrossReferences(t *testing.T) {
65 // Create a new catalog
66 catalog := lexicon.NewBaseCatalog()
67
68 // Load all schemas
69 if err := catalog.LoadDirectory("../internal/atproto/lexicon"); err != nil {
70 t.Fatalf("Failed to load lexicon schemas: %v", err)
71 }
72
73 // Test specific cross-references that should work
74 crossRefs := map[string]string{
75 "social.coves.richtext.facet#byteSlice": "byteSlice definition in facet schema",
76 "social.coves.actor.profile#geoLocation": "geoLocation definition in actor profile",
77 "social.coves.community.rules#rule": "rule definition in community rules",
78 }
79
80 for ref, description := range crossRefs {
81 t.Run(ref, func(t *testing.T) {
82 if _, err := catalog.Resolve(ref); err != nil {
83 t.Errorf("Failed to resolve cross-reference %s (%s): %v", ref, description, err)
84 }
85 })
86 }
87}
88
89func TestValidateRecord(t *testing.T) {
90 // Create a new catalog
91 catalog := lexicon.NewBaseCatalog()
92
93 // Load all schemas
94 if err := catalog.LoadDirectory("../internal/atproto/lexicon"); err != nil {
95 t.Fatalf("Failed to load lexicon schemas: %v", err)
96 }
97
98 // Test cases for ValidateRecord
99 tests := []struct {
100 recordData map[string]interface{}
101 name string
102 recordType string
103 errorContains string
104 shouldFail bool
105 }{
106 {
107 name: "Valid actor profile",
108 recordType: "social.coves.actor.profile",
109 recordData: map[string]interface{}{
110 "$type": "social.coves.actor.profile",
111 "handle": "alice.example.com",
112 "displayName": "Alice Johnson",
113 "createdAt": "2024-01-15T10:30:00Z",
114 },
115 shouldFail: false,
116 },
117 {
118 name: "Invalid actor profile - missing required field",
119 recordType: "social.coves.actor.profile",
120 recordData: map[string]interface{}{
121 "$type": "social.coves.actor.profile",
122 "displayName": "Alice Johnson",
123 },
124 shouldFail: true,
125 errorContains: "required field missing: handle",
126 },
127 {
128 name: "Valid community profile",
129 recordType: "social.coves.community.profile",
130 recordData: map[string]interface{}{
131 "$type": "social.coves.community.profile",
132 "handle": "programming.community.coves.social",
133 "name": "programming",
134 "displayName": "Programming Community",
135 "createdBy": "did:plc:creator123",
136 "hostedBy": "did:plc:coves123",
137 "visibility": "public",
138 "moderationType": "moderator",
139 "federatedFrom": "coves",
140 "createdAt": "2023-12-01T08:00:00Z",
141 },
142 shouldFail: false,
143 },
144 {
145 name: "Valid post record",
146 recordType: "social.coves.community.post",
147 recordData: map[string]interface{}{
148 "$type": "social.coves.community.post",
149 "community": "did:plc:programming123",
150 "author": "did:plc:testauthor123",
151 "title": "Test Post",
152 "content": "This is a test post",
153 "createdAt": "2025-01-09T14:30:00Z",
154 },
155 shouldFail: false,
156 },
157 {
158 name: "Invalid post record - missing required field",
159 recordType: "social.coves.community.post",
160 recordData: map[string]interface{}{
161 "$type": "social.coves.community.post",
162 "community": "did:plc:programming123",
163 // Missing required "author" field
164 "title": "Test Post",
165 "content": "This is a test post",
166 "createdAt": "2025-01-09T14:30:00Z",
167 },
168 shouldFail: true,
169 errorContains: "required field missing",
170 },
171 }
172
173 for _, tt := range tests {
174 t.Run(tt.name, func(t *testing.T) {
175 err := lexicon.ValidateRecord(&catalog, tt.recordData, tt.recordType, lexicon.AllowLenientDatetime)
176
177 if tt.shouldFail {
178 if err == nil {
179 t.Errorf("Expected validation to fail but it passed")
180 } else if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) {
181 t.Errorf("Expected error to contain '%s', got: %v", tt.errorContains, err)
182 }
183 } else {
184 if err != nil {
185 t.Errorf("Expected validation to pass but got error: %v", err)
186 }
187 }
188 })
189 }
190}
191
192func contains(s, substr string) bool {
193 return len(s) >= len(substr) && (s == substr || len(s) > 0 && strings.Contains(s, substr))
194}
195
196func TestValidateRecordWithStrictMode(t *testing.T) {
197 // Create a new catalog
198 catalog := lexicon.NewBaseCatalog()
199
200 // Load all schemas
201 if err := catalog.LoadDirectory("../internal/atproto/lexicon"); err != nil {
202 t.Fatalf("Failed to load lexicon schemas: %v", err)
203 }
204
205 // Test with strict validation flags
206 recordData := map[string]interface{}{
207 "$type": "social.coves.actor.profile",
208 "handle": "alice.example.com",
209 "displayName": "Alice Johnson",
210 "createdAt": "2024-01-15T10:30:00", // Missing timezone
211 }
212
213 // Should fail with strict validation
214 err := lexicon.ValidateRecord(&catalog, recordData, "social.coves.actor.profile", lexicon.StrictRecursiveValidation)
215 if err == nil {
216 t.Error("Expected strict validation to fail on datetime without timezone")
217 }
218
219 // Should pass with lenient datetime validation
220 err = lexicon.ValidateRecord(&catalog, recordData, "social.coves.actor.profile", lexicon.AllowLenientDatetime)
221 if err != nil {
222 t.Errorf("Expected lenient validation to pass, got error: %v", err)
223 }
224}