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.community.rules#rule": "rule definition in community rules",
77 "social.coves.actor.defs#profileView": "profileView definition in actor defs",
78 "social.coves.actor.defs#profileStats": "profileStats definition in actor defs",
79 "social.coves.actor.defs#viewerState": "viewerState definition in actor defs",
80 "social.coves.community.defs#communityView": "communityView definition in community defs",
81 "social.coves.community.defs#communityStats": "communityStats definition in community defs",
82 }
83
84 for ref, description := range crossRefs {
85 t.Run(ref, func(t *testing.T) {
86 if _, err := catalog.Resolve(ref); err != nil {
87 t.Errorf("Failed to resolve cross-reference %s (%s): %v", ref, description, err)
88 }
89 })
90 }
91}
92
93func TestValidateRecord(t *testing.T) {
94 // Create a new catalog
95 catalog := lexicon.NewBaseCatalog()
96
97 // Load all schemas
98 if err := catalog.LoadDirectory("../internal/atproto/lexicon"); err != nil {
99 t.Fatalf("Failed to load lexicon schemas: %v", err)
100 }
101
102 // Test cases for ValidateRecord
103 tests := []struct {
104 recordData map[string]interface{}
105 name string
106 recordType string
107 errorContains string
108 shouldFail bool
109 }{
110 {
111 name: "Valid actor profile",
112 recordType: "social.coves.actor.profile",
113 recordData: map[string]interface{}{
114 "$type": "social.coves.actor.profile",
115 "displayName": "Alice Johnson",
116 "createdAt": "2024-01-15T10:30:00Z",
117 },
118 shouldFail: false,
119 },
120 {
121 name: "Invalid actor profile - missing required field",
122 recordType: "social.coves.actor.profile",
123 recordData: map[string]interface{}{
124 "$type": "social.coves.actor.profile",
125 "displayName": "Alice Johnson",
126 // Missing required createdAt
127 },
128 shouldFail: true,
129 errorContains: "required field missing",
130 },
131 {
132 name: "Valid community profile",
133 recordType: "social.coves.community.profile",
134 recordData: map[string]interface{}{
135 "$type": "social.coves.community.profile",
136 "name": "programming",
137 "displayName": "Programming Community",
138 "createdBy": "did:plc:creator123",
139 "hostedBy": "did:plc:coves123",
140 "visibility": "public",
141 "moderationType": "moderator",
142 "createdAt": "2023-12-01T08:00:00Z",
143 },
144 shouldFail: false,
145 },
146 {
147 name: "Valid post record",
148 recordType: "social.coves.community.post",
149 recordData: map[string]interface{}{
150 "$type": "social.coves.community.post",
151 "community": "did:plc:programming123",
152 "author": "did:plc:testauthor123",
153 "title": "Test Post",
154 "content": "This is a test post",
155 "createdAt": "2025-01-09T14:30:00Z",
156 },
157 shouldFail: false,
158 },
159 {
160 name: "Invalid post record - missing required field",
161 recordType: "social.coves.community.post",
162 recordData: map[string]interface{}{
163 "$type": "social.coves.community.post",
164 "community": "did:plc:programming123",
165 // Missing required "author" field
166 "title": "Test Post",
167 "content": "This is a test post",
168 "createdAt": "2025-01-09T14:30:00Z",
169 },
170 shouldFail: true,
171 errorContains: "required field missing",
172 },
173 }
174
175 for _, tt := range tests {
176 t.Run(tt.name, func(t *testing.T) {
177 err := lexicon.ValidateRecord(&catalog, tt.recordData, tt.recordType, lexicon.AllowLenientDatetime)
178
179 if tt.shouldFail {
180 if err == nil {
181 t.Errorf("Expected validation to fail but it passed")
182 } else if tt.errorContains != "" && !contains(err.Error(), tt.errorContains) {
183 t.Errorf("Expected error to contain '%s', got: %v", tt.errorContains, err)
184 }
185 } else {
186 if err != nil {
187 t.Errorf("Expected validation to pass but got error: %v", err)
188 }
189 }
190 })
191 }
192}
193
194func contains(s, substr string) bool {
195 return len(s) >= len(substr) && (s == substr || len(s) > 0 && strings.Contains(s, substr))
196}
197
198func TestValidateRecordWithStrictMode(t *testing.T) {
199 // Create a new catalog
200 catalog := lexicon.NewBaseCatalog()
201
202 // Load all schemas
203 if err := catalog.LoadDirectory("../internal/atproto/lexicon"); err != nil {
204 t.Fatalf("Failed to load lexicon schemas: %v", err)
205 }
206
207 // Test with strict validation flags
208 recordData := map[string]interface{}{
209 "$type": "social.coves.actor.profile",
210 "displayName": "Alice Johnson",
211 "createdAt": "2024-01-15T10:30:00", // Missing timezone
212 }
213
214 // Should fail with strict validation
215 err := lexicon.ValidateRecord(&catalog, recordData, "social.coves.actor.profile", lexicon.StrictRecursiveValidation)
216 if err == nil {
217 t.Error("Expected strict validation to fail on datetime without timezone")
218 }
219
220 // Should pass with lenient datetime validation
221 err = lexicon.ValidateRecord(&catalog, recordData, "social.coves.actor.profile", lexicon.AllowLenientDatetime)
222 if err != nil {
223 t.Errorf("Expected lenient validation to pass, got error: %v", err)
224 }
225}