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