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