···
4
+
"Coves/internal/atproto/jetstream"
5
+
"Coves/internal/core/users"
6
+
"Coves/internal/db/postgres"
13
+
// TestPostConsumer_CommentCountReconciliation tests that post comment_count
14
+
// is correctly reconciled when comments arrive before the parent post.
16
+
// This addresses the issue identified in comment_consumer.go:362 where the FIXME
17
+
// comment suggests reconciliation is not implemented. This test verifies that
18
+
// the reconciliation logic in post_consumer.go:210-226 works correctly.
19
+
func TestPostConsumer_CommentCountReconciliation(t *testing.T) {
20
+
db := setupTestDB(t)
22
+
if err := db.Close(); err != nil {
23
+
t.Logf("Failed to close database: %v", err)
27
+
ctx := context.Background()
29
+
// Set up repositories and consumers
30
+
postRepo := postgres.NewPostRepository(db)
31
+
commentRepo := postgres.NewCommentRepository(db)
32
+
communityRepo := postgres.NewCommunityRepository(db)
33
+
userRepo := postgres.NewUserRepository(db)
34
+
userService := users.NewUserService(userRepo, nil, getTestPDSURL())
36
+
commentConsumer := jetstream.NewCommentEventConsumer(commentRepo, db)
37
+
postConsumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService, db)
40
+
testUser := createTestUser(t, db, "reconcile.test", "did:plc:reconcile123")
41
+
testCommunity, err := createFeedTestCommunity(db, ctx, "reconcile-community", "owner.test")
43
+
t.Fatalf("Failed to create test community: %v", err)
46
+
t.Run("Single comment arrives before post - count reconciled", func(t *testing.T) {
47
+
// Scenario: User creates a post
48
+
// Another user creates a comment on that post
49
+
// Due to Jetstream ordering, comment event arrives BEFORE post event
50
+
// When post is finally indexed, comment_count should be 1, not 0
52
+
postRkey := generateTID()
53
+
postURI := fmt.Sprintf("at://%s/social.coves.community.post/%s", testCommunity, postRkey)
55
+
commentRkey := generateTID()
56
+
commentURI := fmt.Sprintf("at://%s/social.coves.community.comment/%s", testUser.DID, commentRkey)
58
+
// Step 1: Index comment FIRST (before parent post exists)
59
+
commentEvent := &jetstream.JetstreamEvent{
62
+
Commit: &jetstream.CommitEvent{
64
+
Operation: "create",
65
+
Collection: "social.coves.community.comment",
68
+
Record: map[string]interface{}{
69
+
"$type": "social.coves.community.comment",
70
+
"content": "Comment arriving before parent post!",
71
+
"reply": map[string]interface{}{
72
+
"root": map[string]interface{}{
73
+
"uri": postURI, // Points to post that doesn't exist yet
76
+
"parent": map[string]interface{}{
81
+
"createdAt": time.Now().Format(time.RFC3339),
86
+
err := commentConsumer.HandleEvent(ctx, commentEvent)
88
+
t.Fatalf("Failed to handle comment event: %v", err)
91
+
// Verify comment was indexed
92
+
comment, err := commentRepo.GetByURI(ctx, commentURI)
94
+
t.Fatalf("Comment not indexed: %v", err)
96
+
if comment.ParentURI != postURI {
97
+
t.Errorf("Expected comment parent_uri %s, got %s", postURI, comment.ParentURI)
100
+
// Step 2: Now index post (arrives late due to Jetstream ordering)
101
+
postEvent := &jetstream.JetstreamEvent{
102
+
Did: testCommunity,
104
+
Commit: &jetstream.CommitEvent{
106
+
Operation: "create",
107
+
Collection: "social.coves.community.post",
110
+
Record: map[string]interface{}{
111
+
"$type": "social.coves.community.post",
112
+
"community": testCommunity,
113
+
"author": testUser.DID,
114
+
"title": "Post arriving after comment",
115
+
"content": "This post's comment arrived first!",
116
+
"createdAt": time.Now().Format(time.RFC3339),
121
+
err = postConsumer.HandleEvent(ctx, postEvent)
123
+
t.Fatalf("Failed to handle post event: %v", err)
126
+
// Step 3: Verify post was indexed with CORRECT comment_count
127
+
post, err := postRepo.GetByURI(ctx, postURI)
129
+
t.Fatalf("Post not indexed: %v", err)
132
+
// THIS IS THE KEY TEST: Post should have comment_count = 1 due to reconciliation
133
+
if post.CommentCount != 1 {
134
+
t.Errorf("Expected post comment_count to be 1 (reconciled), got %d", post.CommentCount)
135
+
t.Logf("This indicates the reconciliation logic in post_consumer.go is not working!")
136
+
t.Logf("The FIXME comment at comment_consumer.go:362 may still be valid.")
139
+
// Verify via direct query as well
140
+
var dbCommentCount int
141
+
err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", postURI).Scan(&dbCommentCount)
143
+
t.Fatalf("Failed to query post comment_count: %v", err)
145
+
if dbCommentCount != 1 {
146
+
t.Errorf("Expected DB comment_count to be 1, got %d", dbCommentCount)
150
+
t.Run("Multiple comments arrive before post - count reconciled to correct total", func(t *testing.T) {
151
+
postRkey := generateTID()
152
+
postURI := fmt.Sprintf("at://%s/social.coves.community.post/%s", testCommunity, postRkey)
154
+
// Step 1: Index 3 comments BEFORE the post exists
155
+
for i := 1; i <= 3; i++ {
156
+
commentRkey := generateTID()
157
+
commentEvent := &jetstream.JetstreamEvent{
160
+
Commit: &jetstream.CommitEvent{
161
+
Rev: fmt.Sprintf("comment-%d-rev", i),
162
+
Operation: "create",
163
+
Collection: "social.coves.community.comment",
165
+
CID: fmt.Sprintf("bafycomment%d", i),
166
+
Record: map[string]interface{}{
167
+
"$type": "social.coves.community.comment",
168
+
"content": fmt.Sprintf("Comment %d before post", i),
169
+
"reply": map[string]interface{}{
170
+
"root": map[string]interface{}{
172
+
"cid": "bafypost2",
174
+
"parent": map[string]interface{}{
176
+
"cid": "bafypost2",
179
+
"createdAt": time.Now().Format(time.RFC3339),
184
+
err := commentConsumer.HandleEvent(ctx, commentEvent)
186
+
t.Fatalf("Failed to handle comment %d event: %v", i, err)
190
+
// Step 2: Now index the post
191
+
postEvent := &jetstream.JetstreamEvent{
192
+
Did: testCommunity,
194
+
Commit: &jetstream.CommitEvent{
196
+
Operation: "create",
197
+
Collection: "social.coves.community.post",
200
+
Record: map[string]interface{}{
201
+
"$type": "social.coves.community.post",
202
+
"community": testCommunity,
203
+
"author": testUser.DID,
204
+
"title": "Post with 3 pre-existing comments",
205
+
"content": "All 3 comments arrived before this post!",
206
+
"createdAt": time.Now().Format(time.RFC3339),
211
+
err := postConsumer.HandleEvent(ctx, postEvent)
213
+
t.Fatalf("Failed to handle post event: %v", err)
216
+
// Step 3: Verify post has comment_count = 3
217
+
post, err := postRepo.GetByURI(ctx, postURI)
219
+
t.Fatalf("Post not indexed: %v", err)
222
+
if post.CommentCount != 3 {
223
+
t.Errorf("Expected post comment_count to be 3 (reconciled), got %d", post.CommentCount)
227
+
t.Run("Comments before and after post - count remains accurate", func(t *testing.T) {
228
+
postRkey := generateTID()
229
+
postURI := fmt.Sprintf("at://%s/social.coves.community.post/%s", testCommunity, postRkey)
231
+
// Step 1: Index 2 comments BEFORE post
232
+
for i := 1; i <= 2; i++ {
233
+
commentRkey := generateTID()
234
+
commentEvent := &jetstream.JetstreamEvent{
237
+
Commit: &jetstream.CommitEvent{
238
+
Rev: fmt.Sprintf("before-%d-rev", i),
239
+
Operation: "create",
240
+
Collection: "social.coves.community.comment",
242
+
CID: fmt.Sprintf("bafybefore%d", i),
243
+
Record: map[string]interface{}{
244
+
"$type": "social.coves.community.comment",
245
+
"content": fmt.Sprintf("Before comment %d", i),
246
+
"reply": map[string]interface{}{
247
+
"root": map[string]interface{}{
249
+
"cid": "bafypost3",
251
+
"parent": map[string]interface{}{
253
+
"cid": "bafypost3",
256
+
"createdAt": time.Now().Format(time.RFC3339),
261
+
err := commentConsumer.HandleEvent(ctx, commentEvent)
263
+
t.Fatalf("Failed to handle before-comment %d: %v", i, err)
267
+
// Step 2: Index the post (should reconcile to 2)
268
+
postEvent := &jetstream.JetstreamEvent{
269
+
Did: testCommunity,
271
+
Commit: &jetstream.CommitEvent{
273
+
Operation: "create",
274
+
Collection: "social.coves.community.post",
277
+
Record: map[string]interface{}{
278
+
"$type": "social.coves.community.post",
279
+
"community": testCommunity,
280
+
"author": testUser.DID,
281
+
"title": "Post with before and after comments",
282
+
"content": "Testing mixed ordering",
283
+
"createdAt": time.Now().Format(time.RFC3339),
288
+
err := postConsumer.HandleEvent(ctx, postEvent)
290
+
t.Fatalf("Failed to handle post event: %v", err)
293
+
// Verify count is 2
294
+
post, err := postRepo.GetByURI(ctx, postURI)
296
+
t.Fatalf("Post not indexed: %v", err)
298
+
if post.CommentCount != 2 {
299
+
t.Errorf("Expected comment_count=2 after reconciliation, got %d", post.CommentCount)
302
+
// Step 3: Add 1 more comment AFTER post exists
303
+
commentRkey := generateTID()
304
+
afterCommentEvent := &jetstream.JetstreamEvent{
307
+
Commit: &jetstream.CommitEvent{
309
+
Operation: "create",
310
+
Collection: "social.coves.community.comment",
313
+
Record: map[string]interface{}{
314
+
"$type": "social.coves.community.comment",
315
+
"content": "Comment after post exists",
316
+
"reply": map[string]interface{}{
317
+
"root": map[string]interface{}{
319
+
"cid": "bafypost3",
321
+
"parent": map[string]interface{}{
323
+
"cid": "bafypost3",
326
+
"createdAt": time.Now().Format(time.RFC3339),
331
+
err = commentConsumer.HandleEvent(ctx, afterCommentEvent)
333
+
t.Fatalf("Failed to handle after-comment: %v", err)
336
+
// Verify count incremented to 3
337
+
post, err = postRepo.GetByURI(ctx, postURI)
339
+
t.Fatalf("Failed to get post after increment: %v", err)
341
+
if post.CommentCount != 3 {
342
+
t.Errorf("Expected comment_count=3 after increment, got %d", post.CommentCount)
346
+
t.Run("Idempotent post indexing preserves comment_count", func(t *testing.T) {
347
+
postRkey := generateTID()
348
+
postURI := fmt.Sprintf("at://%s/social.coves.community.post/%s", testCommunity, postRkey)
350
+
// Create comment first
351
+
commentRkey := generateTID()
352
+
commentEvent := &jetstream.JetstreamEvent{
355
+
Commit: &jetstream.CommitEvent{
356
+
Rev: "idem-comment-rev",
357
+
Operation: "create",
358
+
Collection: "social.coves.community.comment",
360
+
CID: "bafyidemcomment",
361
+
Record: map[string]interface{}{
362
+
"$type": "social.coves.community.comment",
363
+
"content": "Comment for idempotent test",
364
+
"reply": map[string]interface{}{
365
+
"root": map[string]interface{}{
367
+
"cid": "bafyidempost",
369
+
"parent": map[string]interface{}{
371
+
"cid": "bafyidempost",
374
+
"createdAt": time.Now().Format(time.RFC3339),
379
+
err := commentConsumer.HandleEvent(ctx, commentEvent)
381
+
t.Fatalf("Failed to create comment: %v", err)
384
+
// Index post (should reconcile to 1)
385
+
postEvent := &jetstream.JetstreamEvent{
386
+
Did: testCommunity,
388
+
Commit: &jetstream.CommitEvent{
389
+
Rev: "idem-post-rev",
390
+
Operation: "create",
391
+
Collection: "social.coves.community.post",
393
+
CID: "bafyidempost",
394
+
Record: map[string]interface{}{
395
+
"$type": "social.coves.community.post",
396
+
"community": testCommunity,
397
+
"author": testUser.DID,
398
+
"title": "Idempotent test post",
399
+
"content": "Testing idempotent indexing",
400
+
"createdAt": time.Now().Format(time.RFC3339),
405
+
err = postConsumer.HandleEvent(ctx, postEvent)
407
+
t.Fatalf("Failed to index post first time: %v", err)
410
+
// Verify count is 1
411
+
post, err := postRepo.GetByURI(ctx, postURI)
413
+
t.Fatalf("Failed to get post: %v", err)
415
+
if post.CommentCount != 1 {
416
+
t.Errorf("Expected comment_count=1 after first index, got %d", post.CommentCount)
419
+
// Replay same post event (idempotent - should skip)
420
+
err = postConsumer.HandleEvent(ctx, postEvent)
422
+
t.Fatalf("Idempotent post event should not error: %v", err)
425
+
// Verify count still 1 (not reset to 0)
426
+
post, err = postRepo.GetByURI(ctx, postURI)
428
+
t.Fatalf("Failed to get post after replay: %v", err)
430
+
if post.CommentCount != 1 {
431
+
t.Errorf("Expected comment_count=1 after replay (idempotent), got %d", post.CommentCount)