A community based topic aggregation platform built on atproto

test: add comprehensive identifier resolution test coverage

Add 31 test cases covering all identifier resolution code paths:

TestCommunityIdentifierResolution (14 E2E tests):
- DID format resolution (3 tests)
- Canonical handle format (3 tests)
- At-identifier format (2 tests)
- Scoped format !name@instance (5 tests)
- Edge cases (4 tests)

TestResolveScopedIdentifier_InputValidation (9 tests):
- Reject special characters, spaces, invalid DNS labels
- Reject names starting/ending with hyphens
- Reject names exceeding 63 character DNS limit
- Accept valid alphanumeric names with hyphens/numbers
- Validate domain format

TestGetDisplayHandle (5 tests):
- Standard two-part domains
- Multi-part TLDs (e.g., .co.uk)
- Subdomain instances
- Malformed input graceful fallback

TestIdentifierResolution_ErrorContext (3 tests):
- Verify error messages include identifier for debugging
- Verify DID, handle, and scoped errors provide context

Fixes:
- Use environment variables for configuration (no hardcoded coves.social)
- Correct NewPDSAccountProvisioner argument order (instanceDomain, pdsURL)
- Support self-hosted instances via INSTANCE_DOMAIN env var

All tests passing with real PDS integration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+449
+449
tests/integration/community_identifier_resolution_test.go
··· 1 + package integration 2 + 3 + import ( 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 18 + func 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:3000" 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 216 + func 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 325 + func 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 388 + func 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 + }