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