A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "Coves/internal/atproto/identity" 5 "Coves/internal/core/communities" 6 "Coves/internal/core/posts" 7 "Coves/internal/core/users" 8 "Coves/internal/db/postgres" 9 "context" 10 "fmt" 11 "strings" 12 "testing" 13 14 "github.com/stretchr/testify/assert" 15 "github.com/stretchr/testify/require" 16) 17 18func TestPostCreation_Basic(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 // Setup: Initialize services 31 userRepo := postgres.NewUserRepository(db) 32 resolver := identity.NewResolver(db, identity.DefaultConfig()) 33 userService := users.NewUserService(userRepo, resolver, "http://localhost:3001") 34 35 communityRepo := postgres.NewCommunityRepository(db) 36 // Note: Provisioner not needed for this test (we're not actually creating communities) 37 communityService := communities.NewCommunityService( 38 communityRepo, 39 "http://localhost:3001", 40 "did:web:test.coves.social", 41 "test.coves.social", 42 nil, // provisioner 43 ) 44 45 postRepo := postgres.NewPostRepository(db) 46 postService := posts.NewPostService(postRepo, communityService, nil, "http://localhost:3001") // nil aggregatorService for user-only tests 47 48 ctx := context.Background() 49 50 // Cleanup: Remove any existing test data 51 _, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE 'did:plc:test%'") 52 _, _ = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:test%'") 53 _, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:test%'") 54 55 // Setup: Create test user 56 testUserDID := generateTestDID("postauthor") 57 testUserHandle := "postauthor.test" 58 59 _, err := userService.CreateUser(ctx, users.CreateUserRequest{ 60 DID: testUserDID, 61 Handle: testUserHandle, 62 PDSURL: "http://localhost:3001", 63 }) 64 require.NoError(t, err, "Failed to create test user") 65 66 // Setup: Create test community (insert directly to DB for speed) 67 testCommunity := &communities.Community{ 68 DID: generateTestDID("testcommunity"), 69 Handle: "testcommunity.community.test.coves.social", // Canonical atProto handle (no ! prefix, .community. format) 70 Name: "testcommunity", 71 DisplayName: "Test Community", 72 Description: "A community for testing posts", 73 Visibility: "public", 74 CreatedByDID: testUserDID, 75 HostedByDID: "did:web:test.coves.social", 76 PDSURL: "http://localhost:3001", 77 PDSAccessToken: "fake_token_for_test", // Won't actually call PDS in unit test 78 } 79 80 _, err = communityRepo.Create(ctx, testCommunity) 81 require.NoError(t, err, "Failed to create test community") 82 83 t.Run("Create text post successfully (with DID)", func(t *testing.T) { 84 // NOTE: This test validates the service layer logic only 85 // It will fail when trying to write to PDS because we're using a fake token 86 // For full E2E testing, you'd need a real PDS instance 87 88 content := "This is a test post" 89 title := "Test Post Title" 90 91 req := posts.CreatePostRequest{ 92 Community: testCommunity.DID, // Using DID directly 93 Title: &title, 94 Content: &content, 95 AuthorDID: testUserDID, 96 } 97 98 // This will fail at token refresh step (expected for unit test) 99 // We're using a fake token that can't be parsed 100 _, err := postService.CreatePost(ctx, req) 101 102 // For now, we expect an error because token is fake 103 // In a full E2E test with real PDS, this would succeed 104 require.Error(t, err) 105 t.Logf("Expected error (fake token): %v", err) 106 // Verify the error is from token refresh or PDS, not validation 107 assert.Contains(t, err.Error(), "failed to refresh community credentials") 108 }) 109 110 t.Run("Create text post with community handle", func(t *testing.T) { 111 // Test that we can use community handle instead of DID 112 // This validates at-identifier resolution per atProto best practices 113 114 content := "Post using handle instead of DID" 115 title := "Handle Test" 116 117 req := posts.CreatePostRequest{ 118 Community: testCommunity.Handle, // Using canonical atProto handle 119 Title: &title, 120 Content: &content, 121 AuthorDID: testUserDID, 122 } 123 124 // Should resolve handle to DID and proceed 125 // Will still fail at token refresh (expected with fake token) 126 _, err := postService.CreatePost(ctx, req) 127 require.Error(t, err) 128 // Should fail at token refresh, not community resolution 129 assert.Contains(t, err.Error(), "failed to refresh community credentials") 130 }) 131 132 t.Run("Create text post with ! prefix handle", func(t *testing.T) { 133 // Test that we can also use ! prefix with scoped format: !name@instance 134 // This is Coves-specific UX shorthand for name.community.instance 135 136 content := "Post using !-prefixed handle" 137 title := "Prefixed Handle Test" 138 139 // Extract name from handle: "gardening.community.coves.social" -> "gardening" 140 // Scoped format: !gardening@coves.social 141 handleParts := strings.Split(testCommunity.Handle, ".") 142 communityName := handleParts[0] 143 instanceDomain := strings.Join(handleParts[2:], ".") // Skip ".community." 144 scopedHandle := fmt.Sprintf("!%s@%s", communityName, instanceDomain) 145 146 req := posts.CreatePostRequest{ 147 Community: scopedHandle, // !gardening@coves.social 148 Title: &title, 149 Content: &content, 150 AuthorDID: testUserDID, 151 } 152 153 // Should resolve handle to DID and proceed 154 // Will still fail at token refresh (expected with fake token) 155 _, err := postService.CreatePost(ctx, req) 156 require.Error(t, err) 157 // Should fail at token refresh, not community resolution 158 assert.Contains(t, err.Error(), "failed to refresh community credentials") 159 }) 160 161 t.Run("Reject post with missing community", func(t *testing.T) { 162 content := "Post without community" 163 164 req := posts.CreatePostRequest{ 165 Community: "", // Missing! 166 Content: &content, 167 AuthorDID: testUserDID, 168 } 169 170 _, err := postService.CreatePost(ctx, req) 171 require.Error(t, err) 172 assert.True(t, posts.IsValidationError(err)) 173 }) 174 175 t.Run("Reject post with non-existent community handle", func(t *testing.T) { 176 content := "Post with non-existent handle" 177 178 req := posts.CreatePostRequest{ 179 Community: "nonexistent.community.test.coves.social", // Valid canonical handle format, but doesn't exist 180 Content: &content, 181 AuthorDID: testUserDID, 182 } 183 184 _, err := postService.CreatePost(ctx, req) 185 require.Error(t, err) 186 // Should fail with community not found (wrapped in error) 187 assert.Contains(t, err.Error(), "community not found") 188 }) 189 190 t.Run("Reject post with missing author DID", func(t *testing.T) { 191 content := "Post without author" 192 193 req := posts.CreatePostRequest{ 194 Community: testCommunity.DID, 195 Content: &content, 196 AuthorDID: "", // Missing! 197 } 198 199 _, err := postService.CreatePost(ctx, req) 200 require.Error(t, err) 201 assert.True(t, posts.IsValidationError(err)) 202 assert.Contains(t, err.Error(), "authorDid") 203 }) 204 205 t.Run("Reject post in non-existent community", func(t *testing.T) { 206 content := "Post in fake community" 207 208 req := posts.CreatePostRequest{ 209 Community: "did:plc:nonexistent", 210 Content: &content, 211 AuthorDID: testUserDID, 212 } 213 214 _, err := postService.CreatePost(ctx, req) 215 require.Error(t, err) 216 assert.Equal(t, posts.ErrCommunityNotFound, err) 217 }) 218 219 t.Run("Reject post with too-long content", func(t *testing.T) { 220 // Create content longer than 50k characters 221 longContent := string(make([]byte, 50001)) 222 223 req := posts.CreatePostRequest{ 224 Community: testCommunity.DID, 225 Content: &longContent, 226 AuthorDID: testUserDID, 227 } 228 229 _, err := postService.CreatePost(ctx, req) 230 require.Error(t, err) 231 assert.True(t, posts.IsValidationError(err)) 232 assert.Contains(t, err.Error(), "too long") 233 }) 234 235 t.Run("Reject post with invalid content label", func(t *testing.T) { 236 content := "Post with invalid label" 237 238 req := posts.CreatePostRequest{ 239 Community: testCommunity.DID, 240 Content: &content, 241 ContentLabels: []string{"invalid_label"}, // Not in known values! 242 AuthorDID: testUserDID, 243 } 244 245 _, err := postService.CreatePost(ctx, req) 246 require.Error(t, err) 247 assert.True(t, posts.IsValidationError(err)) 248 assert.Contains(t, err.Error(), "unknown content label") 249 }) 250 251 t.Run("Accept post with valid content labels", func(t *testing.T) { 252 content := "Post with valid labels" 253 254 req := posts.CreatePostRequest{ 255 Community: testCommunity.DID, 256 Content: &content, 257 ContentLabels: []string{"nsfw", "spoiler"}, 258 AuthorDID: testUserDID, 259 } 260 261 // Will fail at token refresh (expected with fake token) 262 _, err := postService.CreatePost(ctx, req) 263 require.Error(t, err) 264 // Should fail at token refresh, not validation 265 assert.Contains(t, err.Error(), "failed to refresh community credentials") 266 }) 267} 268 269// TestPostRepository_Create tests the repository layer 270func TestPostRepository_Create(t *testing.T) { 271 if testing.Short() { 272 t.Skip("Skipping integration test in short mode") 273 } 274 275 db := setupTestDB(t) 276 defer func() { 277 if err := db.Close(); err != nil { 278 t.Logf("Failed to close database: %v", err) 279 } 280 }() 281 282 // Cleanup first 283 _, _ = db.Exec("DELETE FROM posts WHERE community_did LIKE 'did:plc:test%'") 284 _, _ = db.Exec("DELETE FROM communities WHERE did LIKE 'did:plc:test%'") 285 _, _ = db.Exec("DELETE FROM users WHERE did LIKE 'did:plc:test%'") 286 287 // Setup: Create test user and community 288 ctx := context.Background() 289 userRepo := postgres.NewUserRepository(db) 290 communityRepo := postgres.NewCommunityRepository(db) 291 292 testUserDID := generateTestDID("postauthor2") 293 _, err := userRepo.Create(ctx, &users.User{ 294 DID: testUserDID, 295 Handle: "postauthor2.test", 296 PDSURL: "http://localhost:3001", 297 }) 298 require.NoError(t, err) 299 300 testCommunityDID := generateTestDID("testcommunity2") 301 _, err = communityRepo.Create(ctx, &communities.Community{ 302 DID: testCommunityDID, 303 Handle: "testcommunity2.community.test.coves.social", // Canonical format (no ! prefix) 304 Name: "testcommunity2", 305 Visibility: "public", 306 CreatedByDID: testUserDID, 307 HostedByDID: "did:web:test.coves.social", 308 PDSURL: "http://localhost:3001", 309 }) 310 require.NoError(t, err) 311 312 postRepo := postgres.NewPostRepository(db) 313 314 t.Run("Insert post successfully", func(t *testing.T) { 315 content := "Test post content" 316 title := "Test Title" 317 318 post := &posts.Post{ 319 URI: "at://" + testCommunityDID + "/social.coves.post.record/test123", 320 CID: "bafy2test123", 321 RKey: "test123", 322 AuthorDID: testUserDID, 323 CommunityDID: testCommunityDID, 324 Title: &title, 325 Content: &content, 326 } 327 328 err := postRepo.Create(ctx, post) 329 require.NoError(t, err) 330 assert.NotZero(t, post.ID, "Post should have ID after insert") 331 assert.NotZero(t, post.IndexedAt, "Post should have IndexedAt timestamp") 332 }) 333 334 t.Run("Reject duplicate post URI", func(t *testing.T) { 335 content := "Duplicate post" 336 337 post1 := &posts.Post{ 338 URI: "at://" + testCommunityDID + "/social.coves.post.record/duplicate", 339 CID: "bafy2duplicate1", 340 RKey: "duplicate", 341 AuthorDID: testUserDID, 342 CommunityDID: testCommunityDID, 343 Content: &content, 344 } 345 346 err := postRepo.Create(ctx, post1) 347 require.NoError(t, err) 348 349 // Try to insert again with same URI 350 post2 := &posts.Post{ 351 URI: "at://" + testCommunityDID + "/social.coves.post.record/duplicate", 352 CID: "bafy2duplicate2", 353 RKey: "duplicate", 354 AuthorDID: testUserDID, 355 CommunityDID: testCommunityDID, 356 Content: &content, 357 } 358 359 err = postRepo.Create(ctx, post2) 360 require.Error(t, err) 361 assert.Contains(t, err.Error(), "already indexed") 362 }) 363}