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}