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}