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