A community based topic aggregation platform built on atproto
1package integration
2
3import (
4 "Coves/internal/core/communities"
5 "Coves/internal/db/postgres"
6 "context"
7 "fmt"
8 "os"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15)
16
17// TestCommunityIdentifierResolution tests all formats accepted by ResolveCommunityIdentifier
18func TestCommunityIdentifierResolution(t *testing.T) {
19 if testing.Short() {
20 t.Skip("Skipping integration test in short mode")
21 }
22
23 db := setupTestDB(t)
24 defer func() {
25 if err := db.Close(); err != nil {
26 t.Logf("Failed to close database: %v", err)
27 }
28 }()
29
30 repo := postgres.NewCommunityRepository(db)
31 ctx := context.Background()
32
33 // Get configuration from environment
34 pdsURL := os.Getenv("PDS_URL")
35 if pdsURL == "" {
36 pdsURL = "http://localhost:3001" // Default to dev PDS port (see .env.dev)
37 }
38
39 instanceDomain := os.Getenv("INSTANCE_DOMAIN")
40 if instanceDomain == "" {
41 instanceDomain = "coves.social"
42 }
43
44 // Create provisioner (signature: instanceDomain, pdsURL)
45 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL)
46
47 // Create service
48 instanceDID := os.Getenv("INSTANCE_DID")
49 if instanceDID == "" {
50 instanceDID = "did:web:" + instanceDomain
51 }
52
53 service := communities.NewCommunityService(
54 repo,
55 pdsURL,
56 instanceDID,
57 instanceDomain,
58 provisioner,
59 )
60
61 // Create a test community to resolve
62 uniqueName := fmt.Sprintf("test%d", time.Now().UnixNano()%1000000)
63 req := communities.CreateCommunityRequest{
64 Name: uniqueName,
65 DisplayName: "Test Community",
66 Description: "A test community for identifier resolution",
67 Visibility: "public",
68 CreatedByDID: "did:plc:testowner123",
69 HostedByDID: instanceDID,
70 AllowExternalDiscovery: true,
71 }
72
73 community, err := service.CreateCommunity(ctx, req)
74 require.NoError(t, err, "Failed to create test community")
75 require.NotNil(t, community)
76
77 t.Run("DID format", func(t *testing.T) {
78 t.Run("resolves valid DID", func(t *testing.T) {
79 did, err := service.ResolveCommunityIdentifier(ctx, community.DID)
80 require.NoError(t, err)
81 assert.Equal(t, community.DID, did)
82 })
83
84 t.Run("rejects non-existent DID", func(t *testing.T) {
85 _, err := service.ResolveCommunityIdentifier(ctx, "did:plc:nonexistent123")
86 require.Error(t, err)
87 assert.Contains(t, err.Error(), "community not found")
88 })
89
90 t.Run("rejects malformed DID", func(t *testing.T) {
91 _, err := service.ResolveCommunityIdentifier(ctx, "did:invalid")
92 require.Error(t, err)
93 })
94 })
95
96 t.Run("Canonical handle format", func(t *testing.T) {
97 t.Run("resolves lowercase canonical handle", func(t *testing.T) {
98 did, err := service.ResolveCommunityIdentifier(ctx, community.Handle)
99 require.NoError(t, err)
100 assert.Equal(t, community.DID, did)
101 })
102
103 t.Run("resolves uppercase canonical handle (case-insensitive)", func(t *testing.T) {
104 // Use actual community handle in uppercase
105 upperHandle := fmt.Sprintf("%s.COMMUNITY.%s", uniqueName, strings.ToUpper(instanceDomain))
106 did, err := service.ResolveCommunityIdentifier(ctx, upperHandle)
107 require.NoError(t, err)
108 assert.Equal(t, community.DID, did)
109 })
110
111 t.Run("rejects non-existent canonical handle", func(t *testing.T) {
112 _, err := service.ResolveCommunityIdentifier(ctx, fmt.Sprintf("nonexistent.community.%s", instanceDomain))
113 require.Error(t, err)
114 assert.Contains(t, err.Error(), "community not found")
115 })
116 })
117
118 t.Run("At-identifier format", func(t *testing.T) {
119 t.Run("resolves @-prefixed handle", func(t *testing.T) {
120 atHandle := "@" + community.Handle
121 did, err := service.ResolveCommunityIdentifier(ctx, atHandle)
122 require.NoError(t, err)
123 assert.Equal(t, community.DID, did)
124 })
125
126 t.Run("resolves @-prefixed handle with uppercase (case-insensitive)", func(t *testing.T) {
127 atHandle := "@" + fmt.Sprintf("%s.COMMUNITY.%s", uniqueName, strings.ToUpper(instanceDomain))
128 did, err := service.ResolveCommunityIdentifier(ctx, atHandle)
129 require.NoError(t, err)
130 assert.Equal(t, community.DID, did)
131 })
132 })
133
134 t.Run("Scoped format (!name@instance)", func(t *testing.T) {
135 t.Run("resolves valid scoped identifier", func(t *testing.T) {
136 scopedID := fmt.Sprintf("!%s@%s", uniqueName, instanceDomain)
137 did, err := service.ResolveCommunityIdentifier(ctx, scopedID)
138 require.NoError(t, err)
139 assert.Equal(t, community.DID, did)
140 })
141
142 t.Run("resolves uppercase scoped identifier (case-insensitive domain)", func(t *testing.T) {
143 scopedID := fmt.Sprintf("!%s@%s", uniqueName, strings.ToUpper(instanceDomain))
144 did, err := service.ResolveCommunityIdentifier(ctx, scopedID)
145 require.NoError(t, err, "Should normalize uppercase domain to lowercase")
146 assert.Equal(t, community.DID, did)
147 })
148
149 t.Run("resolves mixed-case scoped identifier", func(t *testing.T) {
150 // Mix case of domain
151 mixedDomain := ""
152 for i, c := range instanceDomain {
153 if i%2 == 0 {
154 mixedDomain += strings.ToUpper(string(c))
155 } else {
156 mixedDomain += strings.ToLower(string(c))
157 }
158 }
159 scopedID := fmt.Sprintf("!%s@%s", uniqueName, mixedDomain)
160 did, err := service.ResolveCommunityIdentifier(ctx, scopedID)
161 require.NoError(t, err, "Should normalize all parts to lowercase")
162 assert.Equal(t, community.DID, did)
163 })
164
165 t.Run("rejects scoped identifier without @ symbol", func(t *testing.T) {
166 _, err := service.ResolveCommunityIdentifier(ctx, "!testcommunity")
167 require.Error(t, err)
168 assert.Contains(t, err.Error(), "must include @ symbol")
169 })
170
171 t.Run("rejects scoped identifier with empty name", func(t *testing.T) {
172 _, err := service.ResolveCommunityIdentifier(ctx, fmt.Sprintf("!@%s", instanceDomain))
173 require.Error(t, err)
174 assert.Contains(t, err.Error(), "community name cannot be empty")
175 })
176
177 t.Run("rejects scoped identifier with wrong instance", func(t *testing.T) {
178 _, err := service.ResolveCommunityIdentifier(ctx, "!testcommunity@wrong.social")
179 require.Error(t, err)
180 assert.Contains(t, err.Error(), "not hosted on this instance")
181 })
182
183 t.Run("rejects non-existent community in scoped format", func(t *testing.T) {
184 _, err := service.ResolveCommunityIdentifier(ctx, fmt.Sprintf("!nonexistent@%s", instanceDomain))
185 require.Error(t, err)
186 assert.Contains(t, err.Error(), "community not found")
187 })
188 })
189
190 t.Run("Edge cases", func(t *testing.T) {
191 t.Run("rejects empty identifier", func(t *testing.T) {
192 _, err := service.ResolveCommunityIdentifier(ctx, "")
193 require.Error(t, err)
194 })
195
196 t.Run("rejects whitespace-only identifier", func(t *testing.T) {
197 _, err := service.ResolveCommunityIdentifier(ctx, " ")
198 require.Error(t, err)
199 })
200
201 t.Run("handles leading/trailing whitespace in valid identifier", func(t *testing.T) {
202 did, err := service.ResolveCommunityIdentifier(ctx, " "+community.Handle+" ")
203 require.NoError(t, err)
204 assert.Equal(t, community.DID, did)
205 })
206
207 t.Run("rejects identifier without dots (not a valid handle)", func(t *testing.T) {
208 _, err := service.ResolveCommunityIdentifier(ctx, "nodots")
209 require.Error(t, err)
210 assert.Contains(t, err.Error(), "must be a DID, handle, or scoped identifier")
211 })
212 })
213}
214
215// TestResolveScopedIdentifier_InputValidation tests input sanitization
216func TestResolveScopedIdentifier_InputValidation(t *testing.T) {
217 if testing.Short() {
218 t.Skip("Skipping integration test in short mode")
219 }
220
221 db := setupTestDB(t)
222 defer func() {
223 if err := db.Close(); err != nil {
224 t.Logf("Failed to close database: %v", err)
225 }
226 }()
227
228 repo := postgres.NewCommunityRepository(db)
229 ctx := context.Background()
230
231 pdsURL := os.Getenv("PDS_URL")
232 if pdsURL == "" {
233 pdsURL = "http://localhost:3000"
234 }
235
236 instanceDomain := os.Getenv("INSTANCE_DOMAIN")
237 if instanceDomain == "" {
238 instanceDomain = "coves.social"
239 }
240
241 instanceDID := os.Getenv("INSTANCE_DID")
242 if instanceDID == "" {
243 instanceDID = "did:web:" + instanceDomain
244 }
245
246 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL)
247 service := communities.NewCommunityService(
248 repo,
249 pdsURL,
250 instanceDID,
251 instanceDomain,
252 provisioner,
253 )
254
255 tests := []struct {
256 name string
257 identifier string
258 expectError string
259 }{
260 {
261 name: "rejects special characters in name",
262 identifier: fmt.Sprintf("!<script>@%s", instanceDomain),
263 expectError: "valid DNS label",
264 },
265 {
266 name: "rejects name with spaces",
267 identifier: fmt.Sprintf("!test community@%s", instanceDomain),
268 expectError: "valid DNS label",
269 },
270 {
271 name: "rejects name starting with hyphen",
272 identifier: fmt.Sprintf("!-test@%s", instanceDomain),
273 expectError: "valid DNS label",
274 },
275 {
276 name: "rejects name ending with hyphen",
277 identifier: fmt.Sprintf("!test-@%s", instanceDomain),
278 expectError: "valid DNS label",
279 },
280 {
281 name: "rejects name exceeding 63 characters",
282 identifier: "!" + string(make([]byte, 64)) + "@" + instanceDomain,
283 expectError: "valid DNS label",
284 },
285 {
286 name: "accepts valid name with hyphens",
287 identifier: fmt.Sprintf("!test-community@%s", instanceDomain),
288 expectError: "", // Should create successfully or fail on not found
289 },
290 {
291 name: "accepts valid name with numbers",
292 identifier: fmt.Sprintf("!test123@%s", instanceDomain),
293 expectError: "", // Should create successfully or fail on not found
294 },
295 {
296 name: "rejects invalid domain format",
297 identifier: "!test@not a domain",
298 expectError: "invalid",
299 },
300 {
301 name: "rejects domain with special characters",
302 identifier: "!test@coves$.social",
303 expectError: "invalid",
304 },
305 }
306
307 for _, tt := range tests {
308 t.Run(tt.name, func(t *testing.T) {
309 _, err := service.ResolveCommunityIdentifier(ctx, tt.identifier)
310
311 if tt.expectError != "" {
312 require.Error(t, err)
313 assert.Contains(t, err.Error(), tt.expectError)
314 } else {
315 // Either succeeds or fails with "not found" (not a validation error)
316 if err != nil {
317 assert.Contains(t, err.Error(), "not found")
318 }
319 }
320 })
321 }
322}
323
324// TestGetDisplayHandle tests the GetDisplayHandle method
325func TestGetDisplayHandle(t *testing.T) {
326 tests := []struct {
327 name string
328 handle string
329 expectedDisplay string
330 }{
331 {
332 name: "standard two-part domain",
333 handle: "gardening.community.coves.social",
334 expectedDisplay: "!gardening@coves.social",
335 },
336 {
337 name: "multi-part TLD",
338 handle: "gaming.community.coves.co.uk",
339 expectedDisplay: "!gaming@coves.co.uk",
340 },
341 {
342 name: "subdomain instance",
343 handle: "test.community.dev.coves.social",
344 expectedDisplay: "!test@dev.coves.social",
345 },
346 {
347 name: "single part name",
348 handle: "a.community.coves.social",
349 expectedDisplay: "!a@coves.social",
350 },
351 }
352
353 for _, tt := range tests {
354 t.Run(tt.name, func(t *testing.T) {
355 // Create a community struct and set the handle
356 community := &communities.Community{
357 Handle: tt.handle,
358 }
359
360 // Test GetDisplayHandle
361 displayHandle := community.GetDisplayHandle()
362 assert.Equal(t, tt.expectedDisplay, displayHandle)
363 })
364 }
365
366 t.Run("handles malformed input gracefully", func(t *testing.T) {
367 // Test edge cases
368 testCases := []struct {
369 handle string
370 fallback string
371 }{
372 {"nodots", "nodots"}, // No dots - should return as-is
373 {"single.dot", "single.dot"}, // Single dot - should return as-is
374 {"", ""}, // Empty - should return as-is
375 }
376
377 for _, tc := range testCases {
378 community := &communities.Community{
379 Handle: tc.handle,
380 }
381 result := community.GetDisplayHandle()
382 assert.Equal(t, tc.fallback, result, "Should fallback to original handle for: %s", tc.handle)
383 }
384 })
385}
386
387// TestIdentifierResolution_ErrorContext verifies error messages include identifier context
388func TestIdentifierResolution_ErrorContext(t *testing.T) {
389 if testing.Short() {
390 t.Skip("Skipping integration test in short mode")
391 }
392
393 db := setupTestDB(t)
394 defer func() {
395 if err := db.Close(); err != nil {
396 t.Logf("Failed to close database: %v", err)
397 }
398 }()
399
400 repo := postgres.NewCommunityRepository(db)
401 ctx := context.Background()
402
403 pdsURL := os.Getenv("PDS_URL")
404 if pdsURL == "" {
405 pdsURL = "http://localhost:3000"
406 }
407
408 instanceDomain := os.Getenv("INSTANCE_DOMAIN")
409 if instanceDomain == "" {
410 instanceDomain = "coves.social"
411 }
412
413 instanceDID := os.Getenv("INSTANCE_DID")
414 if instanceDID == "" {
415 instanceDID = "did:web:" + instanceDomain
416 }
417
418 provisioner := communities.NewPDSAccountProvisioner(instanceDomain, pdsURL)
419 service := communities.NewCommunityService(
420 repo,
421 pdsURL,
422 instanceDID,
423 instanceDomain,
424 provisioner,
425 )
426
427 t.Run("DID error includes identifier", func(t *testing.T) {
428 testDID := "did:plc:nonexistent999"
429 _, err := service.ResolveCommunityIdentifier(ctx, testDID)
430 require.Error(t, err)
431 assert.Contains(t, err.Error(), "community not found")
432 assert.Contains(t, err.Error(), testDID) // Should include the DID in error
433 })
434
435 t.Run("handle error includes identifier", func(t *testing.T) {
436 testHandle := fmt.Sprintf("nonexistent.community.%s", instanceDomain)
437 _, err := service.ResolveCommunityIdentifier(ctx, testHandle)
438 require.Error(t, err)
439 assert.Contains(t, err.Error(), "community not found")
440 assert.Contains(t, err.Error(), testHandle) // Should include the handle in error
441 })
442
443 t.Run("scoped identifier error includes validation details", func(t *testing.T) {
444 _, err := service.ResolveCommunityIdentifier(ctx, "!test@wrong.instance")
445 require.Error(t, err)
446 assert.Contains(t, err.Error(), "not hosted on this instance")
447 assert.Contains(t, err.Error(), instanceDomain) // Should mention expected instance
448 })
449}