A community based topic aggregation platform built on atproto
1package integration 2 3import ( 4 "context" 5 "fmt" 6 "testing" 7 "time" 8 9 "Coves/internal/atproto/jetstream" 10 "Coves/internal/core/comments" 11 "Coves/internal/core/users" 12 "Coves/internal/db/postgres" 13) 14 15// TestCommentVote_CreateAndUpdate tests voting on comments and vote count updates 16func TestCommentVote_CreateAndUpdate(t *testing.T) { 17 db := setupTestDB(t) 18 defer func() { 19 if err := db.Close(); err != nil { 20 t.Logf("Failed to close database: %v", err) 21 } 22 }() 23 24 ctx := context.Background() 25 commentRepo := postgres.NewCommentRepository(db) 26 voteRepo := postgres.NewVoteRepository(db) 27 userRepo := postgres.NewUserRepository(db) 28 userService := users.NewUserService(userRepo, nil, "http://localhost:3001") 29 30 voteConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) 31 commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db) 32 33 // Use fixed timestamp to prevent flaky tests 34 fixedTime := time.Date(2025, 11, 6, 12, 0, 0, 0, time.UTC) 35 36 // Setup test data 37 testUser := createTestUser(t, db, "voter.test", "did:plc:voter123") 38 testCommunity, err := createFeedTestCommunity(db, ctx, "testcommunity", "owner.test") 39 if err != nil { 40 t.Fatalf("Failed to create test community: %v", err) 41 } 42 testPostURI := createTestPost(t, db, testCommunity, testUser.DID, "Test Post", 0, fixedTime) 43 44 t.Run("Upvote on comment increments count", func(t *testing.T) { 45 // Create a comment 46 commentRKey := generateTID() 47 commentURI := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", testUser.DID, commentRKey) 48 commentCID := "bafycomment123" 49 50 commentEvent := &jetstream.JetstreamEvent{ 51 Did: testUser.DID, 52 Kind: "commit", 53 Commit: &jetstream.CommitEvent{ 54 Rev: "test-rev", 55 Operation: "create", 56 Collection: "social.coves.feed.comment", 57 RKey: commentRKey, 58 CID: commentCID, 59 Record: map[string]interface{}{ 60 "$type": "social.coves.feed.comment", 61 "content": "Comment to vote on", 62 "reply": map[string]interface{}{ 63 "root": map[string]interface{}{ 64 "uri": testPostURI, 65 "cid": "bafypost", 66 }, 67 "parent": map[string]interface{}{ 68 "uri": testPostURI, 69 "cid": "bafypost", 70 }, 71 }, 72 "createdAt": fixedTime.Format(time.RFC3339), 73 }, 74 }, 75 } 76 77 if err := commentConsumer.HandleEvent(ctx, commentEvent); err != nil { 78 t.Fatalf("Failed to create comment: %v", err) 79 } 80 81 // Verify initial counts 82 comment, err := commentRepo.GetByURI(ctx, commentURI) 83 if err != nil { 84 t.Fatalf("Failed to get comment: %v", err) 85 } 86 if comment.UpvoteCount != 0 { 87 t.Errorf("Expected initial upvote_count = 0, got %d", comment.UpvoteCount) 88 } 89 90 // Create upvote on comment 91 voteRKey := generateTID() 92 voteURI := fmt.Sprintf("at://%s/social.coves.feed.vote/%s", testUser.DID, voteRKey) 93 94 voteEvent := &jetstream.JetstreamEvent{ 95 Did: testUser.DID, 96 Kind: "commit", 97 Commit: &jetstream.CommitEvent{ 98 Rev: "test-rev", 99 Operation: "create", 100 Collection: "social.coves.feed.vote", 101 RKey: voteRKey, 102 CID: "bafyvote123", 103 Record: map[string]interface{}{ 104 "$type": "social.coves.feed.vote", 105 "subject": map[string]interface{}{ 106 "uri": commentURI, 107 "cid": commentCID, 108 }, 109 "direction": "up", 110 "createdAt": fixedTime.Format(time.RFC3339), 111 }, 112 }, 113 } 114 115 if err := voteConsumer.HandleEvent(ctx, voteEvent); err != nil { 116 t.Fatalf("Failed to create vote: %v", err) 117 } 118 119 // Verify vote was indexed 120 vote, err := voteRepo.GetByURI(ctx, voteURI) 121 if err != nil { 122 t.Fatalf("Failed to get vote: %v", err) 123 } 124 if vote.SubjectURI != commentURI { 125 t.Errorf("Expected vote subject_uri = %s, got %s", commentURI, vote.SubjectURI) 126 } 127 if vote.Direction != "up" { 128 t.Errorf("Expected vote direction = 'up', got %s", vote.Direction) 129 } 130 131 // Verify comment counts updated 132 updatedComment, err := commentRepo.GetByURI(ctx, commentURI) 133 if err != nil { 134 t.Fatalf("Failed to get updated comment: %v", err) 135 } 136 if updatedComment.UpvoteCount != 1 { 137 t.Errorf("Expected upvote_count = 1, got %d", updatedComment.UpvoteCount) 138 } 139 if updatedComment.Score != 1 { 140 t.Errorf("Expected score = 1, got %d", updatedComment.Score) 141 } 142 }) 143 144 t.Run("Downvote on comment increments downvote count", func(t *testing.T) { 145 // Create a comment 146 commentRKey := generateTID() 147 commentURI := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", testUser.DID, commentRKey) 148 commentCID := "bafycomment456" 149 150 commentEvent := &jetstream.JetstreamEvent{ 151 Did: testUser.DID, 152 Kind: "commit", 153 Commit: &jetstream.CommitEvent{ 154 Rev: "test-rev", 155 Operation: "create", 156 Collection: "social.coves.feed.comment", 157 RKey: commentRKey, 158 CID: commentCID, 159 Record: map[string]interface{}{ 160 "$type": "social.coves.feed.comment", 161 "content": "Comment to downvote", 162 "reply": map[string]interface{}{ 163 "root": map[string]interface{}{ 164 "uri": testPostURI, 165 "cid": "bafypost", 166 }, 167 "parent": map[string]interface{}{ 168 "uri": testPostURI, 169 "cid": "bafypost", 170 }, 171 }, 172 "createdAt": fixedTime.Format(time.RFC3339), 173 }, 174 }, 175 } 176 177 if err := commentConsumer.HandleEvent(ctx, commentEvent); err != nil { 178 t.Fatalf("Failed to create comment: %v", err) 179 } 180 181 // Create downvote 182 voteRKey := generateTID() 183 184 voteEvent := &jetstream.JetstreamEvent{ 185 Did: testUser.DID, 186 Kind: "commit", 187 Commit: &jetstream.CommitEvent{ 188 Rev: "test-rev", 189 Operation: "create", 190 Collection: "social.coves.feed.vote", 191 RKey: voteRKey, 192 CID: "bafyvote456", 193 Record: map[string]interface{}{ 194 "$type": "social.coves.feed.vote", 195 "subject": map[string]interface{}{ 196 "uri": commentURI, 197 "cid": commentCID, 198 }, 199 "direction": "down", 200 "createdAt": fixedTime.Format(time.RFC3339), 201 }, 202 }, 203 } 204 205 if err := voteConsumer.HandleEvent(ctx, voteEvent); err != nil { 206 t.Fatalf("Failed to create downvote: %v", err) 207 } 208 209 // Verify comment counts 210 updatedComment, err := commentRepo.GetByURI(ctx, commentURI) 211 if err != nil { 212 t.Fatalf("Failed to get updated comment: %v", err) 213 } 214 if updatedComment.DownvoteCount != 1 { 215 t.Errorf("Expected downvote_count = 1, got %d", updatedComment.DownvoteCount) 216 } 217 if updatedComment.Score != -1 { 218 t.Errorf("Expected score = -1, got %d", updatedComment.Score) 219 } 220 }) 221 222 t.Run("Delete vote decrements comment counts", func(t *testing.T) { 223 // Create comment 224 commentRKey := generateTID() 225 commentURI := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", testUser.DID, commentRKey) 226 commentCID := "bafycomment789" 227 228 commentEvent := &jetstream.JetstreamEvent{ 229 Did: testUser.DID, 230 Kind: "commit", 231 Commit: &jetstream.CommitEvent{ 232 Rev: "test-rev", 233 Operation: "create", 234 Collection: "social.coves.feed.comment", 235 RKey: commentRKey, 236 CID: commentCID, 237 Record: map[string]interface{}{ 238 "$type": "social.coves.feed.comment", 239 "content": "Comment for vote deletion test", 240 "reply": map[string]interface{}{ 241 "root": map[string]interface{}{ 242 "uri": testPostURI, 243 "cid": "bafypost", 244 }, 245 "parent": map[string]interface{}{ 246 "uri": testPostURI, 247 "cid": "bafypost", 248 }, 249 }, 250 "createdAt": fixedTime.Format(time.RFC3339), 251 }, 252 }, 253 } 254 255 if err := commentConsumer.HandleEvent(ctx, commentEvent); err != nil { 256 t.Fatalf("Failed to create comment: %v", err) 257 } 258 259 // Create vote 260 voteRKey := generateTID() 261 262 createVoteEvent := &jetstream.JetstreamEvent{ 263 Did: testUser.DID, 264 Kind: "commit", 265 Commit: &jetstream.CommitEvent{ 266 Rev: "test-rev", 267 Operation: "create", 268 Collection: "social.coves.feed.vote", 269 RKey: voteRKey, 270 CID: "bafyvote789", 271 Record: map[string]interface{}{ 272 "$type": "social.coves.feed.vote", 273 "subject": map[string]interface{}{ 274 "uri": commentURI, 275 "cid": commentCID, 276 }, 277 "direction": "up", 278 "createdAt": fixedTime.Format(time.RFC3339), 279 }, 280 }, 281 } 282 283 if err := voteConsumer.HandleEvent(ctx, createVoteEvent); err != nil { 284 t.Fatalf("Failed to create vote: %v", err) 285 } 286 287 // Verify vote exists 288 commentAfterVote, _ := commentRepo.GetByURI(ctx, commentURI) 289 if commentAfterVote.UpvoteCount != 1 { 290 t.Fatalf("Expected upvote_count = 1 before delete, got %d", commentAfterVote.UpvoteCount) 291 } 292 293 // Delete vote 294 deleteVoteEvent := &jetstream.JetstreamEvent{ 295 Did: testUser.DID, 296 Kind: "commit", 297 Commit: &jetstream.CommitEvent{ 298 Rev: "test-rev", 299 Operation: "delete", 300 Collection: "social.coves.feed.vote", 301 RKey: voteRKey, 302 }, 303 } 304 305 if err := voteConsumer.HandleEvent(ctx, deleteVoteEvent); err != nil { 306 t.Fatalf("Failed to delete vote: %v", err) 307 } 308 309 // Verify counts decremented 310 commentAfterDelete, err := commentRepo.GetByURI(ctx, commentURI) 311 if err != nil { 312 t.Fatalf("Failed to get comment after vote delete: %v", err) 313 } 314 if commentAfterDelete.UpvoteCount != 0 { 315 t.Errorf("Expected upvote_count = 0 after delete, got %d", commentAfterDelete.UpvoteCount) 316 } 317 if commentAfterDelete.Score != 0 { 318 t.Errorf("Expected score = 0 after delete, got %d", commentAfterDelete.Score) 319 } 320 }) 321} 322 323// TestCommentVote_ViewerState tests viewer vote state in comment query responses 324func TestCommentVote_ViewerState(t *testing.T) { 325 db := setupTestDB(t) 326 defer func() { 327 if err := db.Close(); err != nil { 328 t.Logf("Failed to close database: %v", err) 329 } 330 }() 331 332 ctx := context.Background() 333 commentRepo := postgres.NewCommentRepository(db) 334 voteRepo := postgres.NewVoteRepository(db) 335 postRepo := postgres.NewPostRepository(db) 336 userRepo := postgres.NewUserRepository(db) 337 communityRepo := postgres.NewCommunityRepository(db) 338 userService := users.NewUserService(userRepo, nil, "http://localhost:3001") 339 340 voteConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) 341 commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db) 342 343 // Use fixed timestamp to prevent flaky tests 344 fixedTime := time.Date(2025, 11, 6, 12, 0, 0, 0, time.UTC) 345 346 // Setup test data 347 testUser := createTestUser(t, db, "viewer.test", "did:plc:viewer123") 348 testCommunity, err := createFeedTestCommunity(db, ctx, "testcommunity", "owner.test") 349 if err != nil { 350 t.Fatalf("Failed to create test community: %v", err) 351 } 352 testPostURI := createTestPost(t, db, testCommunity, testUser.DID, "Test Post", 0, fixedTime) 353 354 t.Run("Viewer with vote sees vote state", func(t *testing.T) { 355 // Create comment 356 commentRKey := generateTID() 357 commentURI := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", testUser.DID, commentRKey) 358 commentCID := "bafycomment111" 359 360 commentEvent := &jetstream.JetstreamEvent{ 361 Did: testUser.DID, 362 Kind: "commit", 363 Commit: &jetstream.CommitEvent{ 364 Rev: "test-rev", 365 Operation: "create", 366 Collection: "social.coves.feed.comment", 367 RKey: commentRKey, 368 CID: commentCID, 369 Record: map[string]interface{}{ 370 "$type": "social.coves.feed.comment", 371 "content": "Comment with viewer vote", 372 "reply": map[string]interface{}{ 373 "root": map[string]interface{}{ 374 "uri": testPostURI, 375 "cid": "bafypost", 376 }, 377 "parent": map[string]interface{}{ 378 "uri": testPostURI, 379 "cid": "bafypost", 380 }, 381 }, 382 "createdAt": fixedTime.Format(time.RFC3339), 383 }, 384 }, 385 } 386 387 if err := commentConsumer.HandleEvent(ctx, commentEvent); err != nil { 388 t.Fatalf("Failed to create comment: %v", err) 389 } 390 391 // Create vote 392 voteRKey := generateTID() 393 voteURI := fmt.Sprintf("at://%s/social.coves.feed.vote/%s", testUser.DID, voteRKey) 394 395 voteEvent := &jetstream.JetstreamEvent{ 396 Did: testUser.DID, 397 Kind: "commit", 398 Commit: &jetstream.CommitEvent{ 399 Rev: "test-rev", 400 Operation: "create", 401 Collection: "social.coves.feed.vote", 402 RKey: voteRKey, 403 CID: "bafyvote111", 404 Record: map[string]interface{}{ 405 "$type": "social.coves.feed.vote", 406 "subject": map[string]interface{}{ 407 "uri": commentURI, 408 "cid": commentCID, 409 }, 410 "direction": "up", 411 "createdAt": fixedTime.Format(time.RFC3339), 412 }, 413 }, 414 } 415 416 if err := voteConsumer.HandleEvent(ctx, voteEvent); err != nil { 417 t.Fatalf("Failed to create vote: %v", err) 418 } 419 420 // Query comments with viewer authentication 421 commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 422 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 423 PostURI: testPostURI, 424 Sort: "new", 425 Depth: 10, 426 Limit: 100, 427 ViewerDID: &testUser.DID, 428 }) 429 if err != nil { 430 t.Fatalf("Failed to get comments: %v", err) 431 } 432 433 if len(response.Comments) == 0 { 434 t.Fatal("Expected at least one comment in response") 435 } 436 437 // Find our comment 438 var foundComment *comments.CommentView 439 for _, threadView := range response.Comments { 440 if threadView.Comment.URI == commentURI { 441 foundComment = threadView.Comment 442 break 443 } 444 } 445 446 if foundComment == nil { 447 t.Fatal("Expected to find test comment in response") 448 } 449 450 // Verify viewer state 451 if foundComment.Viewer == nil { 452 t.Fatal("Expected viewer state for authenticated request") 453 } 454 if foundComment.Viewer.Vote == nil { 455 t.Error("Expected viewer.vote to be populated") 456 } else if *foundComment.Viewer.Vote != "up" { 457 t.Errorf("Expected viewer.vote = 'up', got %s", *foundComment.Viewer.Vote) 458 } 459 if foundComment.Viewer.VoteURI == nil { 460 t.Error("Expected viewer.voteUri to be populated") 461 } else if *foundComment.Viewer.VoteURI != voteURI { 462 t.Errorf("Expected viewer.voteUri = %s, got %s", voteURI, *foundComment.Viewer.VoteURI) 463 } 464 }) 465 466 t.Run("Viewer without vote sees empty state", func(t *testing.T) { 467 // Create comment (no vote) 468 commentRKey := generateTID() 469 commentURI := fmt.Sprintf("at://%s/social.coves.feed.comment/%s", testUser.DID, commentRKey) 470 471 commentEvent := &jetstream.JetstreamEvent{ 472 Did: testUser.DID, 473 Kind: "commit", 474 Commit: &jetstream.CommitEvent{ 475 Rev: "test-rev", 476 Operation: "create", 477 Collection: "social.coves.feed.comment", 478 RKey: commentRKey, 479 CID: "bafycomment222", 480 Record: map[string]interface{}{ 481 "$type": "social.coves.feed.comment", 482 "content": "Comment without viewer vote", 483 "reply": map[string]interface{}{ 484 "root": map[string]interface{}{ 485 "uri": testPostURI, 486 "cid": "bafypost", 487 }, 488 "parent": map[string]interface{}{ 489 "uri": testPostURI, 490 "cid": "bafypost", 491 }, 492 }, 493 "createdAt": fixedTime.Format(time.RFC3339), 494 }, 495 }, 496 } 497 498 if err := commentConsumer.HandleEvent(ctx, commentEvent); err != nil { 499 t.Fatalf("Failed to create comment: %v", err) 500 } 501 502 // Query with authentication but no vote 503 commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 504 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 505 PostURI: testPostURI, 506 Sort: "new", 507 Depth: 10, 508 Limit: 100, 509 ViewerDID: &testUser.DID, 510 }) 511 if err != nil { 512 t.Fatalf("Failed to get comments: %v", err) 513 } 514 515 if len(response.Comments) == 0 { 516 t.Fatal("Expected at least one comment in response") 517 } 518 519 // Find our comment 520 var foundComment *comments.CommentView 521 for _, threadView := range response.Comments { 522 if threadView.Comment.URI == commentURI { 523 foundComment = threadView.Comment 524 break 525 } 526 } 527 528 if foundComment == nil { 529 t.Fatal("Expected to find test comment in response") 530 } 531 532 // Verify viewer state exists but no vote 533 if foundComment.Viewer == nil { 534 t.Fatal("Expected viewer state for authenticated request") 535 } 536 if foundComment.Viewer.Vote != nil { 537 t.Errorf("Expected viewer.vote = nil (no vote), got %v", *foundComment.Viewer.Vote) 538 } 539 if foundComment.Viewer.VoteURI != nil { 540 t.Errorf("Expected viewer.voteUri = nil (no vote), got %v", *foundComment.Viewer.VoteURI) 541 } 542 }) 543 544 t.Run("Unauthenticated request has no viewer state", func(t *testing.T) { 545 // Query without authentication 546 commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo) 547 response, err := commentService.GetComments(ctx, &comments.GetCommentsRequest{ 548 PostURI: testPostURI, 549 Sort: "new", 550 Depth: 10, 551 Limit: 100, 552 ViewerDID: nil, // No authentication 553 }) 554 if err != nil { 555 t.Fatalf("Failed to get comments: %v", err) 556 } 557 558 if len(response.Comments) > 0 { 559 // Verify no viewer state 560 if response.Comments[0].Comment.Viewer != nil { 561 t.Error("Expected viewer = nil for unauthenticated request") 562 } 563 } 564 }) 565}