A community based topic aggregation platform built on atproto

feat(comments,admin): preserve deleted comment threads and add admin reports

Implement two significant changes to improve content moderation and thread integrity:

1. Admin Reports System
- Add off-protocol content reporting for serious issues (CSAM, doxing, etc.)
- New endpoint: POST /xrpc/social.coves.admin.submitReport
- Rate limited to 10 requests/minute, requires OAuth authentication
- Full stack: handler, service, repository, and database migration

2. Preserve Deleted Comment Thread Structure
- Deleted comments now appear as "[deleted]" placeholders instead of being hidden
- Comment counts are preserved on deletion to maintain accurate thread totals
- Nested replies now properly increment root post's comment_count
- Improved resurrection handling to avoid double-counting when same parent

3. Error Handling Improvements
- serializeOptionalFields() now returns errors instead of silently ignoring failures
- Reconciliation failures now roll back transactions (previously just logged warnings)
- Post consumer uses errors.Is() for proper error type checking

Changes:
- Add internal/api/handlers/adminreport/* for report submission
- Add internal/core/adminreports/* for domain logic and errors
- Add internal/db/postgres/admin_report_repo.go for persistence
- Add migration 028_create_admin_reports_table.sql
- Update comment_consumer.go to preserve counts on delete
- Update comment_repo.go to include deleted comments in queries
- Update post_consumer.go error handling and nested reply counting
- Update tests to verify new placeholder behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+2091 -130
+11
cmd/server/main.go
··· 39 39 "Coves/internal/core/posts" 40 40 "Coves/internal/core/timeline" 41 41 "Coves/internal/core/unfurl" 42 + "Coves/internal/core/adminreports" 42 43 "Coves/internal/core/users" 43 44 "Coves/internal/core/votes" 44 45 ··· 566 567 commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo, oauthClient, oauthStore, nil) 567 568 log.Println("✅ Comment service initialized (with author/community hydration and write support)") 568 569 570 + // Initialize admin report service (off-protocol reporting for serious content issues) 571 + adminReportRepo := postgresRepo.NewAdminReportRepository(db) 572 + adminReportService := adminreports.NewService(adminReportRepo) 573 + log.Println("✅ Admin report service initialized (for flagging serious content)") 574 + 569 575 // Initialize feed service 570 576 feedRepo := postgresRepo.NewCommunityFeedRepository(db, cursorSecret) 571 577 feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) ··· 750 756 log.Println(" - POST /xrpc/social.coves.community.comment.create") 751 757 log.Println(" - POST /xrpc/social.coves.community.comment.update") 752 758 log.Println(" - POST /xrpc/social.coves.community.comment.delete") 759 + 760 + // Register admin report routes (off-protocol content flagging) 761 + routes.RegisterAdminReportRoutes(r, adminReportService, authMiddleware) 762 + log.Println("✅ Admin report endpoint registered (requires OAuth)") 763 + log.Println(" - POST /xrpc/social.coves.admin.submitReport") 753 764 754 765 routes.RegisterCommunityFeedRoutes(r, feedService, voteService, blueskyService, authMiddleware) 755 766 log.Println("Feed XRPC endpoints registered (public with optional auth for viewer vote state)")
+70
internal/api/handlers/adminreport/errors.go
··· 1 + package adminreport 2 + 3 + import ( 4 + "Coves/internal/core/adminreports" 5 + "encoding/json" 6 + "errors" 7 + "log" 8 + "net/http" 9 + ) 10 + 11 + // errorResponse represents a standardized JSON error response 12 + type errorResponse struct { 13 + Error string `json:"error"` 14 + Message string `json:"message"` 15 + } 16 + 17 + // writeError writes a JSON error response with the given status code 18 + func writeError(w http.ResponseWriter, statusCode int, errorType, message string) { 19 + w.Header().Set("Content-Type", "application/json") 20 + w.WriteHeader(statusCode) 21 + if err := json.NewEncoder(w).Encode(errorResponse{ 22 + Error: errorType, 23 + Message: message, 24 + }); err != nil { 25 + log.Printf("Failed to encode error response: %v", err) 26 + } 27 + } 28 + 29 + // handleServiceError maps service-layer errors to HTTP responses 30 + func handleServiceError(w http.ResponseWriter, err error) { 31 + switch { 32 + case adminreports.IsValidationError(err): 33 + // Map specific validation errors to appropriate messages 34 + switch { 35 + case errors.Is(err, adminreports.ErrInvalidReason): 36 + writeError(w, http.StatusBadRequest, "InvalidReason", 37 + "Invalid report reason. Must be one of: csam, doxing, harassment, spam, illegal, other") 38 + case errors.Is(err, adminreports.ErrInvalidStatus): 39 + writeError(w, http.StatusBadRequest, "InvalidStatus", 40 + "Invalid report status. Must be one of: open, reviewing, resolved, dismissed") 41 + case errors.Is(err, adminreports.ErrInvalidTarget): 42 + writeError(w, http.StatusBadRequest, "InvalidTarget", 43 + "Invalid target URI. Must be a valid AT Protocol URI starting with at://") 44 + case errors.Is(err, adminreports.ErrExplanationTooLong): 45 + writeError(w, http.StatusBadRequest, "ExplanationTooLong", 46 + "Explanation exceeds maximum length of 1000 characters") 47 + case errors.Is(err, adminreports.ErrReporterRequired): 48 + writeError(w, http.StatusBadRequest, "ReporterRequired", 49 + "Reporter DID is required") 50 + case errors.Is(err, adminreports.ErrInvalidTargetType): 51 + writeError(w, http.StatusBadRequest, "InvalidTargetType", 52 + "Invalid target type. Must be one of: post, comment") 53 + default: 54 + // SECURITY: Don't expose internal error messages to clients 55 + // Log the actual error for debugging, but return a generic message 56 + log.Printf("Unhandled validation error in admin report handler: %v", err) 57 + writeError(w, http.StatusBadRequest, "InvalidRequest", 58 + "The request contains invalid data") 59 + } 60 + 61 + case adminreports.IsNotFound(err): 62 + writeError(w, http.StatusNotFound, "NotFound", "Report not found") 63 + 64 + default: 65 + // SECURITY: Don't leak internal error details to clients 66 + log.Printf("Unexpected error in admin report handler: %v", err) 67 + writeError(w, http.StatusInternalServerError, "InternalServerError", 68 + "An internal error occurred") 69 + } 70 + }
+93
internal/api/handlers/adminreport/submit.go
··· 1 + package adminreport 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/adminreports" 6 + "encoding/json" 7 + "log" 8 + "net/http" 9 + ) 10 + 11 + // SubmitHandler handles report submission requests 12 + type SubmitHandler struct { 13 + service adminreports.Service 14 + } 15 + 16 + // NewSubmitHandler creates a new handler for submitting admin reports 17 + func NewSubmitHandler(service adminreports.Service) *SubmitHandler { 18 + return &SubmitHandler{ 19 + service: service, 20 + } 21 + } 22 + 23 + // SubmitReportInput matches the lexicon input schema for social.coves.admin.submitReport 24 + type SubmitReportInput struct { 25 + TargetURI string `json:"targetUri"` 26 + Reason string `json:"reason"` 27 + Explanation string `json:"explanation"` 28 + } 29 + 30 + // SubmitReportOutput matches the lexicon output schema 31 + type SubmitReportOutput struct { 32 + Success bool `json:"success"` 33 + ReportID int64 `json:"reportId"` 34 + } 35 + 36 + // HandleSubmit handles report submission requests 37 + // POST /xrpc/social.coves.admin.submitReport 38 + // 39 + // Request body: { "targetUri": "at://...", "reason": "csam", "explanation": "..." } 40 + // Response: { "success": true, "reportId": 123 } 41 + func (h *SubmitHandler) HandleSubmit(w http.ResponseWriter, r *http.Request) { 42 + // 1. Check method is POST 43 + if r.Method != http.MethodPost { 44 + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 45 + return 46 + } 47 + 48 + // 2. Limit request body size to 10KB to prevent DoS attacks 49 + r.Body = http.MaxBytesReader(w, r.Body, 10*1024) 50 + 51 + // 3. Parse JSON body into SubmitReportInput 52 + var input SubmitReportInput 53 + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 54 + // Log the decode error for debugging (but don't expose to client) 55 + log.Printf("[ADMIN_REPORT] Failed to decode JSON request: %v", err) 56 + writeError(w, http.StatusBadRequest, "InvalidRequest", "Invalid request body") 57 + return 58 + } 59 + 60 + // 4. Get user DID from context (injected by auth middleware) 61 + userDID := middleware.GetUserDID(r) 62 + if userDID == "" { 63 + writeError(w, http.StatusUnauthorized, "AuthRequired", "Authentication required") 64 + return 65 + } 66 + 67 + // 5. Convert input to SubmitReportRequest 68 + req := adminreports.SubmitReportRequest{ 69 + ReporterDID: userDID, 70 + TargetURI: input.TargetURI, 71 + Reason: input.Reason, 72 + Explanation: input.Explanation, 73 + } 74 + 75 + // 6. Call service to submit report 76 + result, err := h.service.SubmitReport(r.Context(), req) 77 + if err != nil { 78 + handleServiceError(w, err) 79 + return 80 + } 81 + 82 + // 7. Return JSON response indicating success with report ID 83 + output := SubmitReportOutput{ 84 + Success: true, 85 + ReportID: result.ReportID, 86 + } 87 + 88 + w.Header().Set("Content-Type", "application/json") 89 + w.WriteHeader(http.StatusOK) 90 + if err := json.NewEncoder(w).Encode(output); err != nil { 91 + log.Printf("Failed to encode response: %v", err) 92 + } 93 + }
+514
internal/api/handlers/adminreport/submit_test.go
··· 1 + package adminreport 2 + 3 + import ( 4 + "Coves/internal/api/middleware" 5 + "Coves/internal/core/adminreports" 6 + "bytes" 7 + "context" 8 + "encoding/json" 9 + "errors" 10 + "net/http" 11 + "net/http/httptest" 12 + "strings" 13 + "testing" 14 + ) 15 + 16 + // mockService implements adminreports.Service for testing 17 + type mockService struct { 18 + submitReportFunc func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) 19 + } 20 + 21 + func (m *mockService) SubmitReport(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { 22 + if m.submitReportFunc != nil { 23 + return m.submitReportFunc(ctx, req) 24 + } 25 + return &adminreports.SubmitReportResult{ReportID: 1}, nil 26 + } 27 + 28 + func TestHandleSubmit_Success(t *testing.T) { 29 + svc := &mockService{ 30 + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { 31 + if req.ReporterDID != "did:plc:testuser123" { 32 + t.Errorf("expected ReporterDID %q, got %q", "did:plc:testuser123", req.ReporterDID) 33 + } 34 + if req.TargetURI != "at://did:plc:author123/social.coves.post/abc123" { 35 + t.Errorf("expected TargetURI %q, got %q", "at://did:plc:author123/social.coves.post/abc123", req.TargetURI) 36 + } 37 + if req.Reason != "spam" { 38 + t.Errorf("expected Reason %q, got %q", "spam", req.Reason) 39 + } 40 + if req.Explanation != "This is spam" { 41 + t.Errorf("expected Explanation %q, got %q", "This is spam", req.Explanation) 42 + } 43 + return &adminreports.SubmitReportResult{ReportID: 42}, nil 44 + }, 45 + } 46 + handler := NewSubmitHandler(svc) 47 + 48 + input := SubmitReportInput{ 49 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 50 + Reason: "spam", 51 + Explanation: "This is spam", 52 + } 53 + body, _ := json.Marshal(input) 54 + 55 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) 56 + req.Header.Set("Content-Type", "application/json") 57 + req = setTestUserDID(req, "did:plc:testuser123") 58 + 59 + w := httptest.NewRecorder() 60 + handler.HandleSubmit(w, req) 61 + 62 + if w.Code != http.StatusOK { 63 + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) 64 + } 65 + 66 + var output SubmitReportOutput 67 + if err := json.Unmarshal(w.Body.Bytes(), &output); err != nil { 68 + t.Fatalf("failed to unmarshal response: %v", err) 69 + } 70 + 71 + if !output.Success { 72 + t.Error("expected Success to be true") 73 + } 74 + if output.ReportID != 42 { 75 + t.Errorf("expected ReportID 42, got %d", output.ReportID) 76 + } 77 + } 78 + 79 + func TestHandleSubmit_MethodNotAllowed(t *testing.T) { 80 + handler := NewSubmitHandler(&mockService{}) 81 + 82 + methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch} 83 + for _, method := range methods { 84 + t.Run(method, func(t *testing.T) { 85 + req := httptest.NewRequest(method, "/xrpc/social.coves.admin.submitReport", nil) 86 + req = setTestUserDID(req, "did:plc:testuser123") 87 + 88 + w := httptest.NewRecorder() 89 + handler.HandleSubmit(w, req) 90 + 91 + if w.Code != http.StatusMethodNotAllowed { 92 + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code) 93 + } 94 + }) 95 + } 96 + } 97 + 98 + func TestHandleSubmit_Unauthenticated(t *testing.T) { 99 + handler := NewSubmitHandler(&mockService{}) 100 + 101 + input := SubmitReportInput{ 102 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 103 + Reason: "spam", 104 + Explanation: "This is spam", 105 + } 106 + body, _ := json.Marshal(input) 107 + 108 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) 109 + req.Header.Set("Content-Type", "application/json") 110 + // No auth context - simulates unauthenticated request 111 + 112 + w := httptest.NewRecorder() 113 + handler.HandleSubmit(w, req) 114 + 115 + if w.Code != http.StatusUnauthorized { 116 + t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code) 117 + } 118 + 119 + if !strings.Contains(w.Body.String(), "AuthRequired") { 120 + t.Errorf("expected AuthRequired error, got %s", w.Body.String()) 121 + } 122 + } 123 + 124 + func TestHandleSubmit_InvalidJSON(t *testing.T) { 125 + handler := NewSubmitHandler(&mockService{}) 126 + 127 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", strings.NewReader("not valid json")) 128 + req.Header.Set("Content-Type", "application/json") 129 + req = setTestUserDID(req, "did:plc:testuser123") 130 + 131 + w := httptest.NewRecorder() 132 + handler.HandleSubmit(w, req) 133 + 134 + if w.Code != http.StatusBadRequest { 135 + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) 136 + } 137 + 138 + if !strings.Contains(w.Body.String(), "InvalidRequest") { 139 + t.Errorf("expected InvalidRequest error, got %s", w.Body.String()) 140 + } 141 + } 142 + 143 + func TestHandleSubmit_InvalidReason(t *testing.T) { 144 + svc := &mockService{ 145 + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { 146 + return nil, adminreports.ErrInvalidReason 147 + }, 148 + } 149 + handler := NewSubmitHandler(svc) 150 + 151 + input := SubmitReportInput{ 152 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 153 + Reason: "invalid_reason", 154 + } 155 + body, _ := json.Marshal(input) 156 + 157 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) 158 + req.Header.Set("Content-Type", "application/json") 159 + req = setTestUserDID(req, "did:plc:testuser123") 160 + 161 + w := httptest.NewRecorder() 162 + handler.HandleSubmit(w, req) 163 + 164 + if w.Code != http.StatusBadRequest { 165 + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) 166 + } 167 + 168 + if !strings.Contains(w.Body.String(), "InvalidReason") { 169 + t.Errorf("expected InvalidReason error, got %s", w.Body.String()) 170 + } 171 + } 172 + 173 + func TestHandleSubmit_InvalidTarget(t *testing.T) { 174 + svc := &mockService{ 175 + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { 176 + return nil, adminreports.ErrInvalidTarget 177 + }, 178 + } 179 + handler := NewSubmitHandler(svc) 180 + 181 + input := SubmitReportInput{ 182 + TargetURI: "https://example.com", 183 + Reason: "spam", 184 + } 185 + body, _ := json.Marshal(input) 186 + 187 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) 188 + req.Header.Set("Content-Type", "application/json") 189 + req = setTestUserDID(req, "did:plc:testuser123") 190 + 191 + w := httptest.NewRecorder() 192 + handler.HandleSubmit(w, req) 193 + 194 + if w.Code != http.StatusBadRequest { 195 + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) 196 + } 197 + 198 + if !strings.Contains(w.Body.String(), "InvalidTarget") { 199 + t.Errorf("expected InvalidTarget error, got %s", w.Body.String()) 200 + } 201 + } 202 + 203 + func TestHandleSubmit_ExplanationTooLong(t *testing.T) { 204 + svc := &mockService{ 205 + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { 206 + return nil, adminreports.ErrExplanationTooLong 207 + }, 208 + } 209 + handler := NewSubmitHandler(svc) 210 + 211 + input := SubmitReportInput{ 212 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 213 + Reason: "spam", 214 + Explanation: strings.Repeat("a", 1001), 215 + } 216 + body, _ := json.Marshal(input) 217 + 218 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) 219 + req.Header.Set("Content-Type", "application/json") 220 + req = setTestUserDID(req, "did:plc:testuser123") 221 + 222 + w := httptest.NewRecorder() 223 + handler.HandleSubmit(w, req) 224 + 225 + if w.Code != http.StatusBadRequest { 226 + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) 227 + } 228 + 229 + if !strings.Contains(w.Body.String(), "ExplanationTooLong") { 230 + t.Errorf("expected ExplanationTooLong error, got %s", w.Body.String()) 231 + } 232 + } 233 + 234 + func TestHandleSubmit_InvalidStatus(t *testing.T) { 235 + svc := &mockService{ 236 + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { 237 + return nil, adminreports.ErrInvalidStatus 238 + }, 239 + } 240 + handler := NewSubmitHandler(svc) 241 + 242 + input := SubmitReportInput{ 243 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 244 + Reason: "spam", 245 + } 246 + body, _ := json.Marshal(input) 247 + 248 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) 249 + req.Header.Set("Content-Type", "application/json") 250 + req = setTestUserDID(req, "did:plc:testuser123") 251 + 252 + w := httptest.NewRecorder() 253 + handler.HandleSubmit(w, req) 254 + 255 + if w.Code != http.StatusBadRequest { 256 + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) 257 + } 258 + 259 + if !strings.Contains(w.Body.String(), "InvalidStatus") { 260 + t.Errorf("expected InvalidStatus error, got %s", w.Body.String()) 261 + } 262 + } 263 + 264 + func TestHandleSubmit_InvalidTargetType(t *testing.T) { 265 + svc := &mockService{ 266 + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { 267 + return nil, adminreports.ErrInvalidTargetType 268 + }, 269 + } 270 + handler := NewSubmitHandler(svc) 271 + 272 + input := SubmitReportInput{ 273 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 274 + Reason: "spam", 275 + } 276 + body, _ := json.Marshal(input) 277 + 278 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) 279 + req.Header.Set("Content-Type", "application/json") 280 + req = setTestUserDID(req, "did:plc:testuser123") 281 + 282 + w := httptest.NewRecorder() 283 + handler.HandleSubmit(w, req) 284 + 285 + if w.Code != http.StatusBadRequest { 286 + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) 287 + } 288 + 289 + if !strings.Contains(w.Body.String(), "InvalidTargetType") { 290 + t.Errorf("expected InvalidTargetType error, got %s", w.Body.String()) 291 + } 292 + } 293 + 294 + func TestHandleSubmit_NotFound(t *testing.T) { 295 + svc := &mockService{ 296 + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { 297 + return nil, adminreports.ErrReportNotFound 298 + }, 299 + } 300 + handler := NewSubmitHandler(svc) 301 + 302 + input := SubmitReportInput{ 303 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 304 + Reason: "spam", 305 + } 306 + body, _ := json.Marshal(input) 307 + 308 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) 309 + req.Header.Set("Content-Type", "application/json") 310 + req = setTestUserDID(req, "did:plc:testuser123") 311 + 312 + w := httptest.NewRecorder() 313 + handler.HandleSubmit(w, req) 314 + 315 + if w.Code != http.StatusNotFound { 316 + t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code) 317 + } 318 + 319 + if !strings.Contains(w.Body.String(), "NotFound") { 320 + t.Errorf("expected NotFound error, got %s", w.Body.String()) 321 + } 322 + } 323 + 324 + func TestHandleSubmit_InternalError(t *testing.T) { 325 + svc := &mockService{ 326 + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { 327 + return nil, errors.New("database connection failed") 328 + }, 329 + } 330 + handler := NewSubmitHandler(svc) 331 + 332 + input := SubmitReportInput{ 333 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 334 + Reason: "spam", 335 + } 336 + body, _ := json.Marshal(input) 337 + 338 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) 339 + req.Header.Set("Content-Type", "application/json") 340 + req = setTestUserDID(req, "did:plc:testuser123") 341 + 342 + w := httptest.NewRecorder() 343 + handler.HandleSubmit(w, req) 344 + 345 + if w.Code != http.StatusInternalServerError { 346 + t.Errorf("expected status %d, got %d", http.StatusInternalServerError, w.Code) 347 + } 348 + 349 + if !strings.Contains(w.Body.String(), "InternalServerError") { 350 + t.Errorf("expected InternalServerError error, got %s", w.Body.String()) 351 + } 352 + 353 + // SECURITY: Verify that the actual error message is not leaked 354 + if strings.Contains(w.Body.String(), "database") { 355 + t.Error("internal error details should not be exposed to client") 356 + } 357 + } 358 + 359 + func TestHandleSubmit_EmptyExplanation(t *testing.T) { 360 + svc := &mockService{ 361 + submitReportFunc: func(ctx context.Context, req adminreports.SubmitReportRequest) (*adminreports.SubmitReportResult, error) { 362 + if req.Explanation != "" { 363 + t.Errorf("expected empty Explanation, got %q", req.Explanation) 364 + } 365 + return &adminreports.SubmitReportResult{ReportID: 1}, nil 366 + }, 367 + } 368 + handler := NewSubmitHandler(svc) 369 + 370 + input := SubmitReportInput{ 371 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 372 + Reason: "spam", 373 + Explanation: "", 374 + } 375 + body, _ := json.Marshal(input) 376 + 377 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) 378 + req.Header.Set("Content-Type", "application/json") 379 + req = setTestUserDID(req, "did:plc:testuser123") 380 + 381 + w := httptest.NewRecorder() 382 + handler.HandleSubmit(w, req) 383 + 384 + if w.Code != http.StatusOK { 385 + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) 386 + } 387 + } 388 + 389 + func TestHandleSubmit_ContentTypeHeader(t *testing.T) { 390 + handler := NewSubmitHandler(&mockService{}) 391 + 392 + input := SubmitReportInput{ 393 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 394 + Reason: "spam", 395 + } 396 + body, _ := json.Marshal(input) 397 + 398 + req := httptest.NewRequest(http.MethodPost, "/xrpc/social.coves.admin.submitReport", bytes.NewReader(body)) 399 + req.Header.Set("Content-Type", "application/json") 400 + req = setTestUserDID(req, "did:plc:testuser123") 401 + 402 + w := httptest.NewRecorder() 403 + handler.HandleSubmit(w, req) 404 + 405 + contentType := w.Header().Get("Content-Type") 406 + if contentType != "application/json" { 407 + t.Errorf("expected Content-Type %q, got %q", "application/json", contentType) 408 + } 409 + } 410 + 411 + func TestWriteError(t *testing.T) { 412 + w := httptest.NewRecorder() 413 + writeError(w, http.StatusBadRequest, "TestError", "Test message") 414 + 415 + if w.Code != http.StatusBadRequest { 416 + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) 417 + } 418 + 419 + var resp errorResponse 420 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 421 + t.Fatalf("failed to unmarshal error response: %v", err) 422 + } 423 + 424 + if resp.Error != "TestError" { 425 + t.Errorf("expected error %q, got %q", "TestError", resp.Error) 426 + } 427 + if resp.Message != "Test message" { 428 + t.Errorf("expected message %q, got %q", "Test message", resp.Message) 429 + } 430 + } 431 + 432 + func TestHandleServiceError_AllValidationErrors(t *testing.T) { 433 + tests := []struct { 434 + name string 435 + err error 436 + expectedStatus int 437 + expectedError string 438 + }{ 439 + { 440 + name: "ErrInvalidReason", 441 + err: adminreports.ErrInvalidReason, 442 + expectedStatus: http.StatusBadRequest, 443 + expectedError: "InvalidReason", 444 + }, 445 + { 446 + name: "ErrInvalidStatus", 447 + err: adminreports.ErrInvalidStatus, 448 + expectedStatus: http.StatusBadRequest, 449 + expectedError: "InvalidStatus", 450 + }, 451 + { 452 + name: "ErrInvalidTarget", 453 + err: adminreports.ErrInvalidTarget, 454 + expectedStatus: http.StatusBadRequest, 455 + expectedError: "InvalidTarget", 456 + }, 457 + { 458 + name: "ErrExplanationTooLong", 459 + err: adminreports.ErrExplanationTooLong, 460 + expectedStatus: http.StatusBadRequest, 461 + expectedError: "ExplanationTooLong", 462 + }, 463 + { 464 + name: "ErrReporterRequired", 465 + err: adminreports.ErrReporterRequired, 466 + expectedStatus: http.StatusBadRequest, 467 + expectedError: "ReporterRequired", 468 + }, 469 + { 470 + name: "ErrInvalidTargetType", 471 + err: adminreports.ErrInvalidTargetType, 472 + expectedStatus: http.StatusBadRequest, 473 + expectedError: "InvalidTargetType", 474 + }, 475 + { 476 + name: "ErrReportNotFound", 477 + err: adminreports.ErrReportNotFound, 478 + expectedStatus: http.StatusNotFound, 479 + expectedError: "NotFound", 480 + }, 481 + { 482 + name: "internal error", 483 + err: errors.New("some internal error"), 484 + expectedStatus: http.StatusInternalServerError, 485 + expectedError: "InternalServerError", 486 + }, 487 + } 488 + 489 + for _, tt := range tests { 490 + t.Run(tt.name, func(t *testing.T) { 491 + w := httptest.NewRecorder() 492 + handleServiceError(w, tt.err) 493 + 494 + if w.Code != tt.expectedStatus { 495 + t.Errorf("expected status %d, got %d", tt.expectedStatus, w.Code) 496 + } 497 + 498 + var resp errorResponse 499 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { 500 + t.Fatalf("failed to unmarshal error response: %v", err) 501 + } 502 + 503 + if resp.Error != tt.expectedError { 504 + t.Errorf("expected error %q, got %q", tt.expectedError, resp.Error) 505 + } 506 + }) 507 + } 508 + } 509 + 510 + // setTestUserDID sets the user DID in the context for testing 511 + func setTestUserDID(req *http.Request, userDID string) *http.Request { 512 + ctx := middleware.SetTestUserDID(req.Context(), userDID) 513 + return req.WithContext(ctx) 514 + }
+32
internal/api/routes/adminreport.go
··· 1 + package routes 2 + 3 + import ( 4 + "Coves/internal/api/handlers/adminreport" 5 + "Coves/internal/api/middleware" 6 + "Coves/internal/core/adminreports" 7 + "time" 8 + 9 + "github.com/go-chi/chi/v5" 10 + ) 11 + 12 + // RegisterAdminReportRoutes registers admin report XRPC endpoints on the router 13 + // Implements social.coves.admin.* lexicon endpoints for content reporting 14 + // All endpoints require authentication and are rate limited 15 + func RegisterAdminReportRoutes(r chi.Router, service adminreports.Service, authMiddleware *middleware.OAuthAuthMiddleware) { 16 + // Initialize handlers 17 + submitHandler := adminreport.NewSubmitHandler(service) 18 + 19 + // Create rate limiter for report submission 20 + // Allow 10 reports per minute per user to prevent abuse 21 + // This is intentionally restrictive since report submission is a sensitive operation 22 + reportRateLimiter := middleware.NewRateLimiter(10, time.Minute) 23 + 24 + // Procedure endpoints (POST) - require authentication and rate limiting 25 + // social.coves.admin.submitReport - submit a report for admin review 26 + r.With( 27 + reportRateLimiter.Middleware, 28 + authMiddleware.RequireAuth, 29 + ).Post( 30 + "/xrpc/social.coves.admin.submitReport", 31 + submitHandler.HandleSubmit) 32 + }
+119 -91
internal/atproto/jetstream/comment_consumer.go
··· 100 100 } 101 101 102 102 // Serialize optional JSON fields 103 - facetsJSON, embedJSON, labelsJSON := serializeOptionalFields(commentRecord) 103 + facetsJSON, embedJSON, labelsJSON, err := serializeOptionalFields(commentRecord) 104 + if err != nil { 105 + return fmt.Errorf("failed to serialize optional fields: %w", err) 106 + } 104 107 105 108 // Build comment entity 106 109 comment := &comments.Comment{ ··· 177 180 } 178 181 179 182 // Serialize optional JSON fields 180 - facetsJSON, embedJSON, labelsJSON := serializeOptionalFields(commentRecord) 183 + facetsJSON, embedJSON, labelsJSON, err := serializeOptionalFields(commentRecord) 184 + if err != nil { 185 + return fmt.Errorf("failed to serialize optional fields: %w", err) 186 + } 181 187 182 188 // Build comment update entity (preserves vote counts and created_at) 183 189 comment := &comments.Comment{ ··· 241 247 // We must distinguish: idempotent replay (skip) vs resurrection (update + restore counts) 242 248 var existingID int64 243 249 var existingDeletedAt *time.Time 244 - checkQuery := `SELECT id, deleted_at FROM comments WHERE uri = $1` 245 - checkErr := tx.QueryRowContext(ctx, checkQuery, comment.URI).Scan(&existingID, &existingDeletedAt) 250 + var existingParentURI, existingRootURI string 251 + checkQuery := `SELECT id, deleted_at, parent_uri, root_uri FROM comments WHERE uri = $1` 252 + checkErr := tx.QueryRowContext(ctx, checkQuery, comment.URI).Scan(&existingID, &existingDeletedAt, &existingParentURI, &existingRootURI) 246 253 247 254 var commentID int64 255 + var isResurrectionWithSameParent bool // Track if we should skip parent count increment 248 256 249 257 if checkErr == nil { 250 258 // Comment exists ··· 263 271 // Clear deletion metadata to restore the comment 264 272 log.Printf("Resurrecting previously deleted comment: %s", comment.URI) 265 273 commentID = existingID 274 + 275 + // Check if parent is the same - if so, we should NOT increment parent counts 276 + // because deleteComment() no longer decrements counts (deleted = placeholder) 277 + // If parent is different, we need to increment the NEW parent's count 278 + isResurrectionWithSameParent = (existingParentURI == comment.ParentURI && existingRootURI == comment.RootURI) 266 279 267 280 resurrectQuery := ` 268 281 UPDATE comments ··· 355 368 356 369 // 1.5. Reconcile reply_count for this newly inserted comment 357 370 // In case any replies arrived out-of-order before this parent was indexed 371 + // NOTE: Counts include deleted comments since they're shown as "[deleted]" placeholders 372 + // 373 + // IMPORTANT: This reconciliation logic and the increment logic below (in parent count updates) 374 + // must stay in sync. Both use the same counting semantics: 375 + // - Count ALL comments (including deleted) since deleted comments appear as "[deleted]" placeholders 376 + // - This ensures reply_count matches the actual visible thread structure 377 + // If you modify one, you must review and potentially modify the other. 358 378 reconcileQuery := ` 359 379 UPDATE comments 360 380 SET reply_count = ( 361 381 SELECT COUNT(*) 362 382 FROM comments c 363 - WHERE c.parent_uri = $1 AND c.deleted_at IS NULL 383 + WHERE c.parent_uri = $1 364 384 ) 365 385 WHERE id = $2 366 386 ` 367 387 _, reconcileErr := tx.ExecContext(ctx, reconcileQuery, comment.URI, commentID) 368 388 if reconcileErr != nil { 369 - log.Printf("Warning: Failed to reconcile reply_count for %s: %v", comment.URI, reconcileErr) 370 - // Continue anyway - this is a best-effort reconciliation 389 + // Reconciliation failure is a critical error - it means reply_count will be incorrect 390 + // This could cause data inconsistency where the displayed count doesn't match reality 391 + // Roll back the transaction to maintain consistency 392 + return fmt.Errorf("failed to reconcile reply_count for %s: %w", comment.URI, reconcileErr) 371 393 } 372 394 373 395 // 2. Update parent counts atomically 374 396 // Parent could be a post (increment comment_count) or a comment (increment reply_count) 375 397 // Parse collection from parent URI to determine target table 376 398 // 377 - // NOTE: Post comment_count reconciliation IS implemented in post_consumer.go:210-226 399 + // SKIP if this is a resurrection with the same parent: 400 + // Since deleteComment() no longer decrements counts (deleted comments shown as "[deleted]" placeholders), 401 + // resurrecting a comment with the same parent should NOT increment the count again. 402 + // However, if the parent CHANGED (user recreated comment on different post/thread), we DO increment. 403 + // 404 + // NOTE: Post comment_count reconciliation IS implemented in PostEventConsumer.createPostAndUpdateCounts() 378 405 // When a comment arrives before its parent post, the post update below returns 0 rows 379 406 // and we log a warning. Later, when the post is indexed, the post consumer reconciles 380 407 // comment_count by counting all pre-existing comments. This ensures accurate counts 381 408 // despite out-of-order Jetstream event delivery. 382 409 // 383 410 // Test coverage: TestPostConsumer_CommentCountReconciliation in post_consumer_test.go 411 + if isResurrectionWithSameParent { 412 + log.Printf("Resurrection with same parent - skipping parent count increment for: %s", comment.URI) 413 + if err := tx.Commit(); err != nil { 414 + return fmt.Errorf("failed to commit transaction: %w", err) 415 + } 416 + return nil 417 + } 418 + 384 419 collection := utils.ExtractCollectionFromURI(comment.ParentURI) 385 420 386 - var updateQuery string 387 421 switch collection { 388 422 case "social.coves.community.post": 389 - // Comment on post - update posts.comment_count 390 - updateQuery = ` 423 + // Top-level comment on post - increment posts.comment_count 424 + // NOTE: No deleted_at filter - we increment even for deleted parents to match reconciliation behavior 425 + updateQuery := ` 391 426 UPDATE posts 392 427 SET comment_count = comment_count + 1 393 - WHERE uri = $1 AND deleted_at IS NULL 428 + WHERE uri = $1 394 429 ` 430 + result, err := tx.ExecContext(ctx, updateQuery, comment.ParentURI) 431 + if err != nil { 432 + return fmt.Errorf("failed to update post comment_count: %w", err) 433 + } 434 + rowsAffected, err := result.RowsAffected() 435 + if err != nil { 436 + return fmt.Errorf("failed to check update result: %w", err) 437 + } 438 + if rowsAffected == 0 { 439 + log.Printf("Warning: Post not found: %s (comment indexed anyway)", comment.ParentURI) 440 + } 395 441 396 442 case "social.coves.community.comment": 397 - // Reply to comment - update comments.reply_count 398 - updateQuery = ` 443 + // Nested reply to comment - update BOTH: 444 + // 1. Parent comment's reply_count (for thread structure) 445 + // 2. Root post's comment_count (for total thread count display) 446 + // NOTE: No deleted_at filter - we increment even for deleted parents to match reconciliation behavior 447 + 448 + // Update parent comment's reply_count 449 + replyQuery := ` 399 450 UPDATE comments 400 451 SET reply_count = reply_count + 1 401 - WHERE uri = $1 AND deleted_at IS NULL 452 + WHERE uri = $1 402 453 ` 454 + result, err := tx.ExecContext(ctx, replyQuery, comment.ParentURI) 455 + if err != nil { 456 + return fmt.Errorf("failed to update parent reply_count: %w", err) 457 + } 458 + rowsAffected, err := result.RowsAffected() 459 + if err != nil { 460 + return fmt.Errorf("failed to check reply update result: %w", err) 461 + } 462 + if rowsAffected == 0 { 463 + log.Printf("Warning: Parent comment not found: %s (comment indexed anyway)", comment.ParentURI) 464 + } 465 + 466 + // Also increment root post's comment_count for total thread count 467 + postQuery := ` 468 + UPDATE posts 469 + SET comment_count = comment_count + 1 470 + WHERE uri = $1 471 + ` 472 + result, err = tx.ExecContext(ctx, postQuery, comment.RootURI) 473 + if err != nil { 474 + return fmt.Errorf("failed to update root post comment_count: %w", err) 475 + } 476 + rowsAffected, err = result.RowsAffected() 477 + if err != nil { 478 + return fmt.Errorf("failed to check post update result: %w", err) 479 + } 480 + if rowsAffected == 0 { 481 + log.Printf("Warning: Root post not found: %s (comment indexed anyway)", comment.RootURI) 482 + } 403 483 404 484 default: 405 485 // Unknown or unsupported parent collection ··· 409 489 return fmt.Errorf("failed to commit transaction: %w", commitErr) 410 490 } 411 491 return nil 412 - } 413 - 414 - result, err := tx.ExecContext(ctx, updateQuery, comment.ParentURI) 415 - if err != nil { 416 - return fmt.Errorf("failed to update parent count: %w", err) 417 - } 418 - 419 - rowsAffected, err := result.RowsAffected() 420 - if err != nil { 421 - return fmt.Errorf("failed to check update result: %w", err) 422 - } 423 - 424 - // If parent not found, that's OK (parent might not be indexed yet) 425 - if rowsAffected == 0 { 426 - log.Printf("Warning: Parent not found or deleted: %s (comment indexed anyway)", comment.ParentURI) 427 492 } 428 493 429 494 // Commit transaction ··· 462 527 return fmt.Errorf("failed to delete comment: %w", err) 463 528 } 464 529 465 - // Idempotent: If no rows affected, comment already deleted 530 + // Idempotent: If no rows affected, comment already deleted - return early 466 531 if rowsAffected == 0 { 467 532 log.Printf("Comment already deleted: %s (idempotent)", comment.URI) 468 - if commitErr := tx.Commit(); commitErr != nil { 469 - return fmt.Errorf("failed to commit transaction: %w", commitErr) 533 + if err := tx.Commit(); err != nil { 534 + return fmt.Errorf("failed to commit transaction: %w", err) 470 535 } 471 536 return nil 472 537 } 473 538 474 - // 2. Decrement parent counts atomically 475 - // Parent could be a post or comment - parse collection to determine target table 476 - collection := utils.ExtractCollectionFromURI(comment.ParentURI) 477 - 478 - var updateQuery string 479 - var result sql.Result 480 - switch collection { 481 - case "social.coves.community.post": 482 - // Comment on post - decrement posts.comment_count 483 - updateQuery = ` 484 - UPDATE posts 485 - SET comment_count = GREATEST(0, comment_count - 1) 486 - WHERE uri = $1 AND deleted_at IS NULL 487 - ` 488 - 489 - case "social.coves.community.comment": 490 - // Reply to comment - decrement comments.reply_count 491 - updateQuery = ` 492 - UPDATE comments 493 - SET reply_count = GREATEST(0, reply_count - 1) 494 - WHERE uri = $1 AND deleted_at IS NULL 495 - ` 496 - 497 - default: 498 - // Unknown or unsupported parent collection 499 - // Comment is still deleted, we just don't update parent counts 500 - log.Printf("Comment parent has unsupported collection: %s (comment deleted, parent count not updated)", collection) 501 - if commitErr := tx.Commit(); commitErr != nil { 502 - return fmt.Errorf("failed to commit transaction: %w", commitErr) 503 - } 504 - return nil 505 - } 506 - 507 - result, err = tx.ExecContext(ctx, updateQuery, comment.ParentURI) 508 - if err != nil { 509 - return fmt.Errorf("failed to update parent count: %w", err) 510 - } 511 - 512 - rowsAffected, err = result.RowsAffected() 513 - if err != nil { 514 - return fmt.Errorf("failed to check update result: %w", err) 515 - } 516 - 517 - // If parent not found, that's OK (parent might be deleted) 518 - if rowsAffected == 0 { 519 - log.Printf("Warning: Parent not found or deleted: %s (comment deleted anyway)", comment.ParentURI) 520 - } 539 + // NOTE: We intentionally do NOT decrement parent counts (comment_count/reply_count) 540 + // Deleted comments are shown as "[deleted]" placeholders to preserve thread structure, 541 + // so they should still count toward the displayed total. 521 542 522 543 // Commit transaction 523 544 if err := tx.Commit(); err != nil { ··· 656 677 657 678 // serializeOptionalFields serializes facets, embed, and labels from a comment record to JSON strings 658 679 // Returns nil pointers for empty/nil fields (DRY helper to avoid duplication) 659 - func serializeOptionalFields(commentRecord *CommentRecordFromJetstream) (facetsJSON, embedJSON, labelsJSON *string) { 680 + // Returns an error if any non-empty field fails to serialize (prevents silent data loss) 681 + func serializeOptionalFields(commentRecord *CommentRecordFromJetstream) (facetsJSON, embedJSON, labelsJSON *string, err error) { 660 682 // Serialize facets if present 661 683 if len(commentRecord.Facets) > 0 { 662 - if facetsBytes, err := json.Marshal(commentRecord.Facets); err == nil { 663 - facetsStr := string(facetsBytes) 664 - facetsJSON = &facetsStr 684 + facetsBytes, marshalErr := json.Marshal(commentRecord.Facets) 685 + if marshalErr != nil { 686 + return nil, nil, nil, fmt.Errorf("failed to serialize facets: %w", marshalErr) 665 687 } 688 + facetsStr := string(facetsBytes) 689 + facetsJSON = &facetsStr 666 690 } 667 691 668 692 // Serialize embed if present 669 693 if len(commentRecord.Embed) > 0 { 670 - if embedBytes, err := json.Marshal(commentRecord.Embed); err == nil { 671 - embedStr := string(embedBytes) 672 - embedJSON = &embedStr 694 + embedBytes, marshalErr := json.Marshal(commentRecord.Embed) 695 + if marshalErr != nil { 696 + return nil, nil, nil, fmt.Errorf("failed to serialize embed: %w", marshalErr) 673 697 } 698 + embedStr := string(embedBytes) 699 + embedJSON = &embedStr 674 700 } 675 701 676 702 // Serialize labels if present 677 703 if commentRecord.Labels != nil { 678 - if labelsBytes, err := json.Marshal(commentRecord.Labels); err == nil { 679 - labelsStr := string(labelsBytes) 680 - labelsJSON = &labelsStr 704 + labelsBytes, marshalErr := json.Marshal(commentRecord.Labels) 705 + if marshalErr != nil { 706 + return nil, nil, nil, fmt.Errorf("failed to serialize labels: %w", marshalErr) 681 707 } 708 + labelsStr := string(labelsBytes) 709 + labelsJSON = &labelsStr 682 710 } 683 711 684 - return facetsJSON, embedJSON, labelsJSON 712 + return facetsJSON, embedJSON, labelsJSON, nil 685 713 }
+30 -16
internal/atproto/jetstream/post_consumer.go
··· 7 7 "context" 8 8 "database/sql" 9 9 "encoding/json" 10 + "errors" 10 11 "fmt" 11 12 "log" 12 - "strings" 13 13 "time" 14 14 ) 15 15 ··· 111 111 } 112 112 113 113 // Serialize JSON fields (facets, embed, labels) 114 + // Return error if any non-empty field fails to serialize (prevents silent data loss) 114 115 if postRecord.Facets != nil { 115 116 facetsJSON, marshalErr := json.Marshal(postRecord.Facets) 116 - if marshalErr == nil { 117 - facetsStr := string(facetsJSON) 118 - post.ContentFacets = &facetsStr 117 + if marshalErr != nil { 118 + return fmt.Errorf("failed to serialize facets: %w", marshalErr) 119 119 } 120 + facetsStr := string(facetsJSON) 121 + post.ContentFacets = &facetsStr 120 122 } 121 123 122 124 if postRecord.Embed != nil { 123 125 embedJSON, marshalErr := json.Marshal(postRecord.Embed) 124 - if marshalErr == nil { 125 - embedStr := string(embedJSON) 126 - post.Embed = &embedStr 126 + if marshalErr != nil { 127 + return fmt.Errorf("failed to serialize embed: %w", marshalErr) 127 128 } 129 + embedStr := string(embedJSON) 130 + post.Embed = &embedStr 128 131 } 129 132 130 133 if postRecord.Labels != nil { 131 134 labelsJSON, marshalErr := json.Marshal(postRecord.Labels) 132 - if marshalErr == nil { 133 - labelsStr := string(labelsJSON) 134 - post.ContentLabels = &labelsStr 135 + if marshalErr != nil { 136 + return fmt.Errorf("failed to serialize labels: %w", marshalErr) 135 137 } 138 + labelsStr := string(labelsJSON) 139 + post.ContentLabels = &labelsStr 136 140 } 137 141 138 142 // Atomically: Index post + Reconcile comment count for out-of-order arrivals ··· 230 234 // 2. Reconcile comment_count for this newly inserted post 231 235 // In case any comments arrived out-of-order before this post was indexed 232 236 // This is the CRITICAL FIX for the race condition identified in the PR review 237 + // NOTE: Uses root_uri to count ALL comments in thread (including nested replies) 238 + // NOTE: Counts include deleted comments since they're shown as "[deleted]" placeholders 239 + // 240 + // IMPORTANT: This reconciliation logic and the increment logic in CommentEventConsumer 241 + // must stay in sync. Both use the same counting semantics: 242 + // - Count ALL comments (including deleted) since deleted comments appear as "[deleted]" placeholders 243 + // - This ensures comment_count matches the actual visible thread structure 244 + // If you modify one, you must review and potentially modify the other. 245 + // See: comment_consumer.go indexCommentAndUpdateCounts() 233 246 reconcileQuery := ` 234 247 UPDATE posts 235 248 SET comment_count = ( 236 249 SELECT COUNT(*) 237 250 FROM comments c 238 - WHERE c.parent_uri = $1 AND c.deleted_at IS NULL 251 + WHERE c.root_uri = $1 239 252 ) 240 253 WHERE id = $2 241 254 ` 242 255 _, reconcileErr := tx.ExecContext(ctx, reconcileQuery, post.URI, postID) 243 256 if reconcileErr != nil { 244 - log.Printf("Warning: Failed to reconcile comment_count for %s: %v", post.URI, reconcileErr) 245 - // Continue anyway - this is a best-effort reconciliation 257 + // Reconciliation failure is a critical error - it means comment_count will be incorrect 258 + // This could cause data inconsistency where the displayed count doesn't match reality 259 + // Roll back the transaction to maintain consistency 260 + return fmt.Errorf("failed to reconcile comment_count for %s: %w", post.URI, reconcileErr) 246 261 } 247 262 248 263 // Commit transaction ··· 294 309 // If author isn't indexed yet, we must reject the post 295 310 _, err = c.userService.GetUserByDID(ctx, post.Author) 296 311 if err != nil { 297 - // Check if it's a "not found" error using string matching 298 - // (users package doesn't export IsNotFound) 299 - if err.Error() == "user not found" || strings.Contains(err.Error(), "not found") { 312 + // Use proper error type checking with errors.Is() 313 + if errors.Is(err, users.ErrUserNotFound) { 300 314 // Reject - author must be indexed before posts 301 315 // This maintains referential integrity and prevents orphaned posts 302 316 return fmt.Errorf("author not found: %s - cannot index post before author", post.Author)
+41
internal/core/adminreports/errors.go
··· 1 + package adminreports 2 + 3 + import "errors" 4 + 5 + var ( 6 + // ErrInvalidReason indicates the report reason is not a valid category 7 + ErrInvalidReason = errors.New("invalid report reason: must be one of csam, doxing, harassment, spam, illegal, other") 8 + 9 + // ErrInvalidStatus indicates the report status is not a valid value 10 + ErrInvalidStatus = errors.New("invalid report status: must be one of open, reviewing, resolved, dismissed") 11 + 12 + // ErrInvalidTarget indicates the target URI is malformed or invalid 13 + ErrInvalidTarget = errors.New("invalid target URI: must be a valid AT Protocol URI starting with at://") 14 + 15 + // ErrExplanationTooLong indicates the explanation exceeds the maximum length 16 + ErrExplanationTooLong = errors.New("explanation exceeds maximum length of 1000 characters") 17 + 18 + // ErrReporterRequired indicates the reporter DID was not provided 19 + ErrReporterRequired = errors.New("reporter DID is required") 20 + 21 + // ErrReportNotFound indicates the requested report does not exist 22 + ErrReportNotFound = errors.New("report not found") 23 + 24 + // ErrInvalidTargetType indicates the target type is not a valid value 25 + ErrInvalidTargetType = errors.New("invalid target type: must be one of post, comment") 26 + ) 27 + 28 + // IsValidationError checks if an error is a validation error 29 + func IsValidationError(err error) bool { 30 + return errors.Is(err, ErrInvalidReason) || 31 + errors.Is(err, ErrInvalidStatus) || 32 + errors.Is(err, ErrInvalidTarget) || 33 + errors.Is(err, ErrExplanationTooLong) || 34 + errors.Is(err, ErrReporterRequired) || 35 + errors.Is(err, ErrInvalidTargetType) 36 + } 37 + 38 + // IsNotFound checks if an error is a "not found" error 39 + func IsNotFound(err error) bool { 40 + return errors.Is(err, ErrReportNotFound) 41 + }
+29
internal/core/adminreports/interfaces.go
··· 1 + package adminreports 2 + 3 + import "context" 4 + 5 + // Repository defines the data access layer for admin reports 6 + type Repository interface { 7 + // Create stores a new report in the database 8 + // Returns the report with ID populated after successful creation 9 + Create(ctx context.Context, report *Report) error 10 + 11 + // ListByStatus returns reports filtered by status with pagination 12 + ListByStatus(ctx context.Context, status string, limit, offset int) ([]*Report, error) 13 + 14 + // UpdateStatus updates a report's status and resolution details 15 + UpdateStatus(ctx context.Context, id int64, status, resolvedBy, notes string) error 16 + } 17 + 18 + // SubmitReportResult contains the result of submitting a report 19 + type SubmitReportResult struct { 20 + // ReportID is the ID of the created report 21 + ReportID int64 22 + } 23 + 24 + // Service defines the business logic layer for admin reports 25 + type Service interface { 26 + // SubmitReport validates and creates a new report 27 + // Returns the report ID on success 28 + SubmitReport(ctx context.Context, req SubmitReportRequest) (*SubmitReportResult, error) 29 + }
+204
internal/core/adminreports/report.go
··· 1 + package adminreports 2 + 3 + import ( 4 + "log" 5 + "regexp" 6 + "strings" 7 + "time" 8 + "unicode/utf8" 9 + ) 10 + 11 + // Report represents an admin report in the AppView database 12 + // Reports are created by users to flag serious content for admin review 13 + type Report struct { 14 + ID int64 `json:"id" db:"id"` 15 + ReporterDID string `json:"reporterDid" db:"reporter_did"` 16 + TargetURI string `json:"targetUri" db:"target_uri"` 17 + TargetType TargetType `json:"targetType" db:"target_type"` 18 + Reason Reason `json:"reason" db:"reason"` 19 + Explanation string `json:"explanation,omitempty" db:"explanation"` 20 + Status Status `json:"status" db:"status"` 21 + ResolvedBy *string `json:"resolvedBy,omitempty" db:"resolved_by"` 22 + ResolutionNotes *string `json:"resolutionNotes,omitempty" db:"resolution_notes"` 23 + CreatedAt time.Time `json:"createdAt" db:"created_at"` 24 + ResolvedAt *time.Time `json:"resolvedAt,omitempty" db:"resolved_at"` 25 + } 26 + 27 + // Reason represents the category of an admin report 28 + type Reason string 29 + 30 + // Valid reason values for admin reports 31 + const ( 32 + ReasonCSAM Reason = "csam" 33 + ReasonDoxing Reason = "doxing" 34 + ReasonHarassment Reason = "harassment" 35 + ReasonSpam Reason = "spam" 36 + ReasonIllegal Reason = "illegal" 37 + ReasonOther Reason = "other" 38 + ) 39 + 40 + // Status represents the processing status of an admin report 41 + type Status string 42 + 43 + // Valid status values for admin reports 44 + const ( 45 + StatusOpen Status = "open" 46 + StatusReviewing Status = "reviewing" 47 + StatusResolved Status = "resolved" 48 + StatusDismissed Status = "dismissed" 49 + ) 50 + 51 + // TargetType represents the type of content being reported 52 + type TargetType string 53 + 54 + // Valid target types for admin reports 55 + const ( 56 + TargetTypePost TargetType = "post" 57 + TargetTypeComment TargetType = "comment" 58 + ) 59 + 60 + // ValidReasons returns all valid reason values 61 + func ValidReasons() []Reason { 62 + return []Reason{ReasonCSAM, ReasonDoxing, ReasonHarassment, ReasonSpam, ReasonIllegal, ReasonOther} 63 + } 64 + 65 + // ValidStatuses returns all valid status values 66 + func ValidStatuses() []Status { 67 + return []Status{StatusOpen, StatusReviewing, StatusResolved, StatusDismissed} 68 + } 69 + 70 + // ValidTargetTypes returns all valid target type values 71 + func ValidTargetTypes() []TargetType { 72 + return []TargetType{TargetTypePost, TargetTypeComment} 73 + } 74 + 75 + // IsValidReason checks if a reason value is valid 76 + func IsValidReason(reason string) bool { 77 + for _, r := range ValidReasons() { 78 + if string(r) == reason { 79 + return true 80 + } 81 + } 82 + return false 83 + } 84 + 85 + // IsValidStatus checks if a status value is valid 86 + func IsValidStatus(status string) bool { 87 + for _, s := range ValidStatuses() { 88 + if string(s) == status { 89 + return true 90 + } 91 + } 92 + return false 93 + } 94 + 95 + // IsValidTargetType checks if a target type value is valid 96 + func IsValidTargetType(targetType string) bool { 97 + for _, t := range ValidTargetTypes() { 98 + if string(t) == targetType { 99 + return true 100 + } 101 + } 102 + return false 103 + } 104 + 105 + // MaxExplanationLength is the maximum number of characters allowed in an explanation 106 + const MaxExplanationLength = 1000 107 + 108 + // SubmitReportRequest contains the data needed to submit a new report 109 + type SubmitReportRequest struct { 110 + // ReporterDID is the DID of the user submitting the report 111 + ReporterDID string 112 + 113 + // TargetURI is the AT Protocol URI of the content being reported 114 + TargetURI string 115 + 116 + // Reason is the category of the report 117 + Reason string 118 + 119 + // Explanation is an optional description of the issue 120 + Explanation string 121 + } 122 + 123 + // atURIPattern validates AT Protocol URIs with proper structure: 124 + // at://did:plc:xxx/collection/rkey or at://did:web:xxx/collection/rkey 125 + // Note: This validation focuses on structure rather than strict DID format validation, 126 + // which is the responsibility of the PDS. The pattern allows alphanumeric DID identifiers. 127 + var atURIPattern = regexp.MustCompile(`^at://did:(plc:[a-zA-Z0-9]+|web:[a-zA-Z0-9.-]+)/[a-zA-Z0-9.]+/[a-zA-Z0-9_-]+$`) 128 + 129 + // Validate validates the SubmitReportRequest and returns an error if invalid 130 + func (r *SubmitReportRequest) Validate() error { 131 + // Validate reporter DID 132 + if r.ReporterDID == "" { 133 + return ErrReporterRequired 134 + } 135 + 136 + // Validate reason is one of the allowed values 137 + if !IsValidReason(r.Reason) { 138 + return ErrInvalidReason 139 + } 140 + 141 + // Validate target URI is a proper AT Protocol URI 142 + if !isValidATURI(r.TargetURI) { 143 + return ErrInvalidTarget 144 + } 145 + 146 + // Validate explanation length (max 1000 characters, using proper character counting) 147 + if utf8.RuneCountInString(r.Explanation) > MaxExplanationLength { 148 + return ErrExplanationTooLong 149 + } 150 + 151 + return nil 152 + } 153 + 154 + // isValidATURI validates that the URI is a proper AT Protocol URI 155 + // AT Protocol URIs have the format: at://did:plc:xxx/collection/rkey 156 + // or at://did:web:xxx/collection/rkey 157 + func isValidATURI(uri string) bool { 158 + // Check basic prefix 159 + if !strings.HasPrefix(uri, "at://") { 160 + return false 161 + } 162 + 163 + // Validate the full URI pattern 164 + return atURIPattern.MatchString(uri) 165 + } 166 + 167 + // determineTargetType determines whether the target is a post or comment based on the URI 168 + // AT Protocol URIs have the format: at://did:plc:xxx/collection/rkey 169 + // For Coves, the collection will contain "post" or "comment" 170 + func determineTargetType(uri string) TargetType { 171 + // Check if the URI contains common post or comment collection patterns 172 + lowerURI := strings.ToLower(uri) 173 + 174 + if strings.Contains(lowerURI, "comment") { 175 + return TargetTypeComment 176 + } 177 + 178 + // Log when defaulting to post for unknown target types 179 + if !strings.Contains(lowerURI, "post") { 180 + log.Printf("[ADMIN_REPORT] Unknown target type in URI, defaulting to post: %s", uri) 181 + } 182 + 183 + return TargetTypePost 184 + } 185 + 186 + // NewReport creates a new Report from a validated SubmitReportRequest 187 + // This constructor ensures that reports are created with proper defaults and validation 188 + // The request must be validated before calling this function 189 + func NewReport(req SubmitReportRequest) (*Report, error) { 190 + // Validate the request first 191 + if err := req.Validate(); err != nil { 192 + return nil, err 193 + } 194 + 195 + return &Report{ 196 + ReporterDID: req.ReporterDID, 197 + TargetURI: req.TargetURI, 198 + TargetType: determineTargetType(req.TargetURI), 199 + Reason: Reason(req.Reason), 200 + Explanation: req.Explanation, 201 + Status: StatusOpen, 202 + CreatedAt: time.Now().UTC(), 203 + }, nil 204 + }
+36
internal/core/adminreports/service.go
··· 1 + package adminreports 2 + 3 + import ( 4 + "context" 5 + ) 6 + 7 + // service implements the Service interface for admin reports 8 + type service struct { 9 + repo Repository 10 + } 11 + 12 + // NewService creates a new admin reports service 13 + func NewService(repo Repository) Service { 14 + return &service{ 15 + repo: repo, 16 + } 17 + } 18 + 19 + // SubmitReport validates the report request and creates a new report 20 + // Returns the report ID on success 21 + func (s *service) SubmitReport(ctx context.Context, req SubmitReportRequest) (*SubmitReportResult, error) { 22 + // Use the constructor which handles validation and target type determination 23 + report, err := NewReport(req) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + // Create the report in the database 29 + if err := s.repo.Create(ctx, report); err != nil { 30 + return nil, err 31 + } 32 + 33 + return &SubmitReportResult{ 34 + ReportID: report.ID, 35 + }, nil 36 + }
+622
internal/core/adminreports/service_test.go
··· 1 + package adminreports 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "strings" 7 + "testing" 8 + ) 9 + 10 + // mockRepository implements Repository for testing 11 + type mockRepository struct { 12 + createFunc func(ctx context.Context, report *Report) error 13 + listByStatusFunc func(ctx context.Context, status string, limit, offset int) ([]*Report, error) 14 + updateStatusFunc func(ctx context.Context, id int64, status, resolvedBy, notes string) error 15 + createdReports []*Report 16 + } 17 + 18 + func (m *mockRepository) Create(ctx context.Context, report *Report) error { 19 + if m.createFunc != nil { 20 + return m.createFunc(ctx, report) 21 + } 22 + // Default behavior: assign ID and store report 23 + report.ID = int64(len(m.createdReports) + 1) 24 + m.createdReports = append(m.createdReports, report) 25 + return nil 26 + } 27 + 28 + func (m *mockRepository) ListByStatus(ctx context.Context, status string, limit, offset int) ([]*Report, error) { 29 + if m.listByStatusFunc != nil { 30 + return m.listByStatusFunc(ctx, status, limit, offset) 31 + } 32 + return []*Report{}, nil 33 + } 34 + 35 + func (m *mockRepository) UpdateStatus(ctx context.Context, id int64, status, resolvedBy, notes string) error { 36 + if m.updateStatusFunc != nil { 37 + return m.updateStatusFunc(ctx, id, status, resolvedBy, notes) 38 + } 39 + return nil 40 + } 41 + 42 + func TestSubmitReport_Success(t *testing.T) { 43 + repo := &mockRepository{} 44 + svc := NewService(repo) 45 + 46 + req := SubmitReportRequest{ 47 + ReporterDID: "did:plc:testuser123", 48 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 49 + Reason: "spam", 50 + Explanation: "This is spam content", 51 + } 52 + 53 + result, err := svc.SubmitReport(context.Background(), req) 54 + if err != nil { 55 + t.Fatalf("expected no error, got: %v", err) 56 + } 57 + 58 + if result == nil { 59 + t.Fatal("expected result, got nil") 60 + } 61 + 62 + if result.ReportID != 1 { 63 + t.Errorf("expected ReportID 1, got %d", result.ReportID) 64 + } 65 + 66 + if len(repo.createdReports) != 1 { 67 + t.Fatalf("expected 1 created report, got %d", len(repo.createdReports)) 68 + } 69 + 70 + created := repo.createdReports[0] 71 + if created.ReporterDID != req.ReporterDID { 72 + t.Errorf("expected ReporterDID %q, got %q", req.ReporterDID, created.ReporterDID) 73 + } 74 + if created.TargetURI != req.TargetURI { 75 + t.Errorf("expected TargetURI %q, got %q", req.TargetURI, created.TargetURI) 76 + } 77 + if created.Reason != Reason(req.Reason) { 78 + t.Errorf("expected Reason %q, got %q", req.Reason, created.Reason) 79 + } 80 + if created.Explanation != req.Explanation { 81 + t.Errorf("expected Explanation %q, got %q", req.Explanation, created.Explanation) 82 + } 83 + if created.Status != StatusOpen { 84 + t.Errorf("expected Status %q, got %q", StatusOpen, created.Status) 85 + } 86 + if created.TargetType != TargetTypePost { 87 + t.Errorf("expected TargetType %q, got %q", TargetTypePost, created.TargetType) 88 + } 89 + } 90 + 91 + func TestSubmitReport_CommentTargetType(t *testing.T) { 92 + repo := &mockRepository{} 93 + svc := NewService(repo) 94 + 95 + req := SubmitReportRequest{ 96 + ReporterDID: "did:plc:testuser123", 97 + TargetURI: "at://did:plc:author123/social.coves.comment/xyz789", 98 + Reason: "harassment", 99 + Explanation: "Harassing comment", 100 + } 101 + 102 + result, err := svc.SubmitReport(context.Background(), req) 103 + if err != nil { 104 + t.Fatalf("expected no error, got: %v", err) 105 + } 106 + 107 + if result == nil { 108 + t.Fatal("expected result, got nil") 109 + } 110 + 111 + created := repo.createdReports[0] 112 + if created.TargetType != TargetTypeComment { 113 + t.Errorf("expected TargetType %q, got %q", TargetTypeComment, created.TargetType) 114 + } 115 + } 116 + 117 + func TestSubmitReport_ValidationErrors(t *testing.T) { 118 + repo := &mockRepository{} 119 + svc := NewService(repo) 120 + 121 + tests := []struct { 122 + name string 123 + req SubmitReportRequest 124 + expectedErr error 125 + }{ 126 + { 127 + name: "missing reporter DID", 128 + req: SubmitReportRequest{ 129 + ReporterDID: "", 130 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 131 + Reason: "spam", 132 + }, 133 + expectedErr: ErrReporterRequired, 134 + }, 135 + { 136 + name: "invalid reason", 137 + req: SubmitReportRequest{ 138 + ReporterDID: "did:plc:testuser123", 139 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 140 + Reason: "invalid_reason", 141 + }, 142 + expectedErr: ErrInvalidReason, 143 + }, 144 + { 145 + name: "missing target URI prefix", 146 + req: SubmitReportRequest{ 147 + ReporterDID: "did:plc:testuser123", 148 + TargetURI: "https://example.com/post/123", 149 + Reason: "spam", 150 + }, 151 + expectedErr: ErrInvalidTarget, 152 + }, 153 + { 154 + name: "incomplete AT URI - only prefix", 155 + req: SubmitReportRequest{ 156 + ReporterDID: "did:plc:testuser123", 157 + TargetURI: "at://", 158 + Reason: "spam", 159 + }, 160 + expectedErr: ErrInvalidTarget, 161 + }, 162 + { 163 + name: "malformed AT URI - missing collection", 164 + req: SubmitReportRequest{ 165 + ReporterDID: "did:plc:testuser123", 166 + TargetURI: "at://did:plc:author123", 167 + Reason: "spam", 168 + }, 169 + expectedErr: ErrInvalidTarget, 170 + }, 171 + { 172 + name: "malformed AT URI - missing rkey", 173 + req: SubmitReportRequest{ 174 + ReporterDID: "did:plc:testuser123", 175 + TargetURI: "at://did:plc:author123/social.coves.post", 176 + Reason: "spam", 177 + }, 178 + expectedErr: ErrInvalidTarget, 179 + }, 180 + } 181 + 182 + for _, tt := range tests { 183 + t.Run(tt.name, func(t *testing.T) { 184 + _, err := svc.SubmitReport(context.Background(), tt.req) 185 + if !errors.Is(err, tt.expectedErr) { 186 + t.Errorf("expected error %v, got %v", tt.expectedErr, err) 187 + } 188 + }) 189 + } 190 + } 191 + 192 + func TestSubmitReport_ExplanationTooLong(t *testing.T) { 193 + repo := &mockRepository{} 194 + svc := NewService(repo) 195 + 196 + // Create explanation longer than 1000 characters 197 + longExplanation := strings.Repeat("a", MaxExplanationLength+1) 198 + 199 + req := SubmitReportRequest{ 200 + ReporterDID: "did:plc:testuser123", 201 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 202 + Reason: "spam", 203 + Explanation: longExplanation, 204 + } 205 + 206 + _, err := svc.SubmitReport(context.Background(), req) 207 + if !errors.Is(err, ErrExplanationTooLong) { 208 + t.Errorf("expected ErrExplanationTooLong, got %v", err) 209 + } 210 + } 211 + 212 + func TestSubmitReport_ExplanationExactlyAtLimit(t *testing.T) { 213 + repo := &mockRepository{} 214 + svc := NewService(repo) 215 + 216 + // Create explanation at exactly 1000 characters 217 + exactExplanation := strings.Repeat("a", MaxExplanationLength) 218 + 219 + req := SubmitReportRequest{ 220 + ReporterDID: "did:plc:testuser123", 221 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 222 + Reason: "spam", 223 + Explanation: exactExplanation, 224 + } 225 + 226 + _, err := svc.SubmitReport(context.Background(), req) 227 + if err != nil { 228 + t.Fatalf("expected no error for explanation at limit, got: %v", err) 229 + } 230 + } 231 + 232 + func TestSubmitReport_ExplanationWithMultibyteCharacters(t *testing.T) { 233 + repo := &mockRepository{} 234 + svc := NewService(repo) 235 + 236 + // Create explanation with 1001 multibyte characters (should fail) 237 + // Each emoji is 1 character but multiple bytes 238 + multibyteExplanation := strings.Repeat("🔥", MaxExplanationLength+1) 239 + 240 + req := SubmitReportRequest{ 241 + ReporterDID: "did:plc:testuser123", 242 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 243 + Reason: "spam", 244 + Explanation: multibyteExplanation, 245 + } 246 + 247 + _, err := svc.SubmitReport(context.Background(), req) 248 + if !errors.Is(err, ErrExplanationTooLong) { 249 + t.Errorf("expected ErrExplanationTooLong for multibyte characters exceeding limit, got %v", err) 250 + } 251 + } 252 + 253 + func TestSubmitReport_ExplanationWithMultibyteCharactersAtLimit(t *testing.T) { 254 + repo := &mockRepository{} 255 + svc := NewService(repo) 256 + 257 + // Create explanation with exactly 1000 multibyte characters (should pass) 258 + multibyteExplanation := strings.Repeat("🔥", MaxExplanationLength) 259 + 260 + req := SubmitReportRequest{ 261 + ReporterDID: "did:plc:testuser123", 262 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 263 + Reason: "spam", 264 + Explanation: multibyteExplanation, 265 + } 266 + 267 + _, err := svc.SubmitReport(context.Background(), req) 268 + if err != nil { 269 + t.Fatalf("expected no error for multibyte explanation at limit, got: %v", err) 270 + } 271 + } 272 + 273 + func TestSubmitReport_RepositoryError(t *testing.T) { 274 + expectedErr := errors.New("database connection failed") 275 + repo := &mockRepository{ 276 + createFunc: func(ctx context.Context, report *Report) error { 277 + return expectedErr 278 + }, 279 + } 280 + svc := NewService(repo) 281 + 282 + req := SubmitReportRequest{ 283 + ReporterDID: "did:plc:testuser123", 284 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 285 + Reason: "spam", 286 + } 287 + 288 + _, err := svc.SubmitReport(context.Background(), req) 289 + if !errors.Is(err, expectedErr) { 290 + t.Errorf("expected repository error, got %v", err) 291 + } 292 + } 293 + 294 + func TestSubmitReport_AllValidReasons(t *testing.T) { 295 + validReasons := []string{"csam", "doxing", "harassment", "spam", "illegal", "other"} 296 + 297 + for _, reason := range validReasons { 298 + t.Run("reason_"+reason, func(t *testing.T) { 299 + repo := &mockRepository{} 300 + svc := NewService(repo) 301 + 302 + req := SubmitReportRequest{ 303 + ReporterDID: "did:plc:testuser123", 304 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 305 + Reason: reason, 306 + } 307 + 308 + result, err := svc.SubmitReport(context.Background(), req) 309 + if err != nil { 310 + t.Fatalf("expected no error for reason %q, got: %v", reason, err) 311 + } 312 + if result == nil { 313 + t.Fatalf("expected result for reason %q, got nil", reason) 314 + } 315 + }) 316 + } 317 + } 318 + 319 + func TestSubmitReport_DidWebURI(t *testing.T) { 320 + repo := &mockRepository{} 321 + svc := NewService(repo) 322 + 323 + req := SubmitReportRequest{ 324 + ReporterDID: "did:plc:testuser123", 325 + TargetURI: "at://did:web:example.com/social.coves.post/abc123", 326 + Reason: "spam", 327 + } 328 + 329 + result, err := svc.SubmitReport(context.Background(), req) 330 + if err != nil { 331 + t.Fatalf("expected no error for did:web URI, got: %v", err) 332 + } 333 + if result == nil { 334 + t.Fatal("expected result, got nil") 335 + } 336 + } 337 + 338 + func TestIsValidReason(t *testing.T) { 339 + tests := []struct { 340 + reason string 341 + expected bool 342 + }{ 343 + {"csam", true}, 344 + {"doxing", true}, 345 + {"harassment", true}, 346 + {"spam", true}, 347 + {"illegal", true}, 348 + {"other", true}, 349 + {"invalid", false}, 350 + {"CSAM", false}, // case-sensitive 351 + {"Spam", false}, // case-sensitive 352 + {"", false}, 353 + {" spam", false}, // with space 354 + } 355 + 356 + for _, tt := range tests { 357 + t.Run(tt.reason, func(t *testing.T) { 358 + if got := IsValidReason(tt.reason); got != tt.expected { 359 + t.Errorf("IsValidReason(%q) = %v, want %v", tt.reason, got, tt.expected) 360 + } 361 + }) 362 + } 363 + } 364 + 365 + func TestIsValidStatus(t *testing.T) { 366 + tests := []struct { 367 + status string 368 + expected bool 369 + }{ 370 + {"open", true}, 371 + {"reviewing", true}, 372 + {"resolved", true}, 373 + {"dismissed", true}, 374 + {"invalid", false}, 375 + {"OPEN", false}, // case-sensitive 376 + {"Resolved", false}, // case-sensitive 377 + {"", false}, 378 + {" open", false}, // with space 379 + } 380 + 381 + for _, tt := range tests { 382 + t.Run(tt.status, func(t *testing.T) { 383 + if got := IsValidStatus(tt.status); got != tt.expected { 384 + t.Errorf("IsValidStatus(%q) = %v, want %v", tt.status, got, tt.expected) 385 + } 386 + }) 387 + } 388 + } 389 + 390 + func TestIsValidTargetType(t *testing.T) { 391 + tests := []struct { 392 + targetType string 393 + expected bool 394 + }{ 395 + {"post", true}, 396 + {"comment", true}, 397 + {"invalid", false}, 398 + {"POST", false}, // case-sensitive 399 + {"Comment", false}, // case-sensitive 400 + {"", false}, 401 + {" post", false}, // with space 402 + } 403 + 404 + for _, tt := range tests { 405 + t.Run(tt.targetType, func(t *testing.T) { 406 + if got := IsValidTargetType(tt.targetType); got != tt.expected { 407 + t.Errorf("IsValidTargetType(%q) = %v, want %v", tt.targetType, got, tt.expected) 408 + } 409 + }) 410 + } 411 + } 412 + 413 + func TestNewReport_Success(t *testing.T) { 414 + req := SubmitReportRequest{ 415 + ReporterDID: "did:plc:testuser123", 416 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 417 + Reason: "spam", 418 + Explanation: "This is spam", 419 + } 420 + 421 + report, err := NewReport(req) 422 + if err != nil { 423 + t.Fatalf("expected no error, got: %v", err) 424 + } 425 + 426 + if report.ReporterDID != req.ReporterDID { 427 + t.Errorf("expected ReporterDID %q, got %q", req.ReporterDID, report.ReporterDID) 428 + } 429 + if report.TargetURI != req.TargetURI { 430 + t.Errorf("expected TargetURI %q, got %q", req.TargetURI, report.TargetURI) 431 + } 432 + if report.Reason != Reason(req.Reason) { 433 + t.Errorf("expected Reason %q, got %q", req.Reason, report.Reason) 434 + } 435 + if report.Explanation != req.Explanation { 436 + t.Errorf("expected Explanation %q, got %q", req.Explanation, report.Explanation) 437 + } 438 + if report.Status != StatusOpen { 439 + t.Errorf("expected Status %q, got %q", StatusOpen, report.Status) 440 + } 441 + if report.TargetType != TargetTypePost { 442 + t.Errorf("expected TargetType %q, got %q", TargetTypePost, report.TargetType) 443 + } 444 + if report.CreatedAt.IsZero() { 445 + t.Error("expected CreatedAt to be set") 446 + } 447 + } 448 + 449 + func TestNewReport_ValidationError(t *testing.T) { 450 + req := SubmitReportRequest{ 451 + ReporterDID: "", 452 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 453 + Reason: "spam", 454 + } 455 + 456 + _, err := NewReport(req) 457 + if !errors.Is(err, ErrReporterRequired) { 458 + t.Errorf("expected ErrReporterRequired, got %v", err) 459 + } 460 + } 461 + 462 + func TestSubmitReportRequest_Validate(t *testing.T) { 463 + tests := []struct { 464 + name string 465 + req SubmitReportRequest 466 + expectedErr error 467 + }{ 468 + { 469 + name: "valid request", 470 + req: SubmitReportRequest{ 471 + ReporterDID: "did:plc:testuser123", 472 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 473 + Reason: "spam", 474 + }, 475 + expectedErr: nil, 476 + }, 477 + { 478 + name: "empty reporter", 479 + req: SubmitReportRequest{ 480 + ReporterDID: "", 481 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 482 + Reason: "spam", 483 + }, 484 + expectedErr: ErrReporterRequired, 485 + }, 486 + { 487 + name: "invalid reason", 488 + req: SubmitReportRequest{ 489 + ReporterDID: "did:plc:testuser123", 490 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 491 + Reason: "bad_reason", 492 + }, 493 + expectedErr: ErrInvalidReason, 494 + }, 495 + { 496 + name: "invalid target URI", 497 + req: SubmitReportRequest{ 498 + ReporterDID: "did:plc:testuser123", 499 + TargetURI: "https://example.com", 500 + Reason: "spam", 501 + }, 502 + expectedErr: ErrInvalidTarget, 503 + }, 504 + { 505 + name: "explanation too long", 506 + req: SubmitReportRequest{ 507 + ReporterDID: "did:plc:testuser123", 508 + TargetURI: "at://did:plc:author123/social.coves.post/abc123", 509 + Reason: "spam", 510 + Explanation: strings.Repeat("x", MaxExplanationLength+1), 511 + }, 512 + expectedErr: ErrExplanationTooLong, 513 + }, 514 + } 515 + 516 + for _, tt := range tests { 517 + t.Run(tt.name, func(t *testing.T) { 518 + err := tt.req.Validate() 519 + if tt.expectedErr == nil { 520 + if err != nil { 521 + t.Errorf("expected no error, got: %v", err) 522 + } 523 + } else { 524 + if !errors.Is(err, tt.expectedErr) { 525 + t.Errorf("expected error %v, got %v", tt.expectedErr, err) 526 + } 527 + } 528 + }) 529 + } 530 + } 531 + 532 + func TestValidReasons(t *testing.T) { 533 + reasons := ValidReasons() 534 + expected := []Reason{ReasonCSAM, ReasonDoxing, ReasonHarassment, ReasonSpam, ReasonIllegal, ReasonOther} 535 + 536 + if len(reasons) != len(expected) { 537 + t.Fatalf("expected %d reasons, got %d", len(expected), len(reasons)) 538 + } 539 + 540 + for i, r := range expected { 541 + if reasons[i] != r { 542 + t.Errorf("expected reason[%d] = %q, got %q", i, r, reasons[i]) 543 + } 544 + } 545 + } 546 + 547 + func TestValidStatuses(t *testing.T) { 548 + statuses := ValidStatuses() 549 + expected := []Status{StatusOpen, StatusReviewing, StatusResolved, StatusDismissed} 550 + 551 + if len(statuses) != len(expected) { 552 + t.Fatalf("expected %d statuses, got %d", len(expected), len(statuses)) 553 + } 554 + 555 + for i, s := range expected { 556 + if statuses[i] != s { 557 + t.Errorf("expected status[%d] = %q, got %q", i, s, statuses[i]) 558 + } 559 + } 560 + } 561 + 562 + func TestValidTargetTypes(t *testing.T) { 563 + types := ValidTargetTypes() 564 + expected := []TargetType{TargetTypePost, TargetTypeComment} 565 + 566 + if len(types) != len(expected) { 567 + t.Fatalf("expected %d target types, got %d", len(expected), len(types)) 568 + } 569 + 570 + for i, tt := range expected { 571 + if types[i] != tt { 572 + t.Errorf("expected targetType[%d] = %q, got %q", i, tt, types[i]) 573 + } 574 + } 575 + } 576 + 577 + func TestIsValidationError(t *testing.T) { 578 + tests := []struct { 579 + name string 580 + err error 581 + expected bool 582 + }{ 583 + {"ErrInvalidReason", ErrInvalidReason, true}, 584 + {"ErrInvalidStatus", ErrInvalidStatus, true}, 585 + {"ErrInvalidTarget", ErrInvalidTarget, true}, 586 + {"ErrExplanationTooLong", ErrExplanationTooLong, true}, 587 + {"ErrReporterRequired", ErrReporterRequired, true}, 588 + {"ErrInvalidTargetType", ErrInvalidTargetType, true}, 589 + {"ErrReportNotFound", ErrReportNotFound, false}, 590 + {"generic error", errors.New("some error"), false}, 591 + {"nil", nil, false}, 592 + } 593 + 594 + for _, tt := range tests { 595 + t.Run(tt.name, func(t *testing.T) { 596 + if got := IsValidationError(tt.err); got != tt.expected { 597 + t.Errorf("IsValidationError(%v) = %v, want %v", tt.err, got, tt.expected) 598 + } 599 + }) 600 + } 601 + } 602 + 603 + func TestIsNotFound(t *testing.T) { 604 + tests := []struct { 605 + name string 606 + err error 607 + expected bool 608 + }{ 609 + {"ErrReportNotFound", ErrReportNotFound, true}, 610 + {"ErrInvalidReason", ErrInvalidReason, false}, 611 + {"generic error", errors.New("some error"), false}, 612 + {"nil", nil, false}, 613 + } 614 + 615 + for _, tt := range tests { 616 + t.Run(tt.name, func(t *testing.T) { 617 + if got := IsNotFound(tt.err); got != tt.expected { 618 + t.Errorf("IsNotFound(%v) = %v, want %v", tt.err, got, tt.expected) 619 + } 620 + }) 621 + } 622 + }
+23
internal/db/migrations/028_create_admin_reports_table.sql
··· 1 + -- +goose Up 2 + CREATE TABLE admin_reports ( 3 + id BIGSERIAL PRIMARY KEY, 4 + reporter_did TEXT NOT NULL, 5 + target_uri TEXT NOT NULL, -- AT-URI of post/comment 6 + target_type TEXT NOT NULL, -- 'post' or 'comment' 7 + reason TEXT NOT NULL, -- csam, doxing, harassment, spam, illegal, other 8 + explanation TEXT, -- optional details (max 1000 chars) 9 + status TEXT NOT NULL DEFAULT 'open', 10 + resolved_by TEXT, 11 + resolution_notes TEXT, 12 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 13 + resolved_at TIMESTAMPTZ, 14 + 15 + CONSTRAINT valid_reason CHECK (reason IN ('csam', 'doxing', 'harassment', 'spam', 'illegal', 'other')), 16 + CONSTRAINT valid_status CHECK (status IN ('open', 'reviewing', 'resolved', 'dismissed')) 17 + ); 18 + 19 + CREATE INDEX idx_admin_reports_status_created ON admin_reports(status, created_at DESC); 20 + CREATE INDEX idx_admin_reports_target ON admin_reports(target_uri); 21 + 22 + -- +goose Down 23 + DROP TABLE IF EXISTS admin_reports;
+222
internal/db/postgres/admin_report_repo.go
··· 1 + package postgres 2 + 3 + import ( 4 + "Coves/internal/core/adminreports" 5 + "context" 6 + "database/sql" 7 + "errors" 8 + "fmt" 9 + "log/slog" 10 + "strings" 11 + "time" 12 + 13 + "github.com/lib/pq" 14 + ) 15 + 16 + type postgresAdminReportRepo struct { 17 + db *sql.DB 18 + } 19 + 20 + // NewAdminReportRepository creates a new PostgreSQL admin report repository 21 + func NewAdminReportRepository(db *sql.DB) adminreports.Repository { 22 + return &postgresAdminReportRepo{db: db} 23 + } 24 + 25 + // Create inserts a new admin report into the database 26 + // Returns the created report with ID and CreatedAt populated 27 + func (r *postgresAdminReportRepo) Create(ctx context.Context, report *adminreports.Report) error { 28 + query := ` 29 + INSERT INTO admin_reports ( 30 + reporter_did, target_uri, target_type, 31 + reason, explanation, status 32 + ) VALUES ( 33 + $1, $2, $3, 34 + $4, $5, $6 35 + ) 36 + RETURNING id, created_at 37 + ` 38 + 39 + // Default status to 'open' if not set 40 + status := report.Status 41 + if status == "" { 42 + status = adminreports.StatusOpen 43 + } 44 + 45 + // Handle empty explanation as NULL 46 + var explanation *string 47 + if report.Explanation != "" { 48 + explanation = &report.Explanation 49 + } 50 + 51 + err := r.db.QueryRowContext( 52 + ctx, query, 53 + report.ReporterDID, report.TargetURI, string(report.TargetType), 54 + string(report.Reason), explanation, string(status), 55 + ).Scan(&report.ID, &report.CreatedAt) 56 + 57 + if err != nil { 58 + // Check for constraint violations using pq.Error type 59 + if pqErr := extractPQError(err); pqErr != nil { 60 + if strings.Contains(pqErr.Constraint, "valid_reason") { 61 + return adminreports.ErrInvalidReason 62 + } 63 + if strings.Contains(pqErr.Constraint, "valid_status") { 64 + return adminreports.ErrInvalidStatus 65 + } 66 + if strings.Contains(pqErr.Constraint, "valid_target_type") { 67 + return adminreports.ErrInvalidTargetType 68 + } 69 + } 70 + return fmt.Errorf("failed to create admin report: %w", err) 71 + } 72 + 73 + report.Status = status 74 + return nil 75 + } 76 + 77 + // ListByStatus retrieves reports filtered by status with pagination 78 + // Results are ordered by created_at DESC (newest first) 79 + func (r *postgresAdminReportRepo) ListByStatus(ctx context.Context, status string, limit, offset int) ([]*adminreports.Report, error) { 80 + query := ` 81 + SELECT 82 + id, reporter_did, target_uri, target_type, 83 + reason, explanation, status, 84 + resolved_by, resolution_notes, 85 + created_at, resolved_at 86 + FROM admin_reports 87 + WHERE status = $1 88 + ORDER BY created_at DESC 89 + LIMIT $2 OFFSET $3 90 + ` 91 + 92 + rows, err := r.db.QueryContext(ctx, query, status, limit, offset) 93 + if err != nil { 94 + return nil, fmt.Errorf("failed to list admin reports by status: %w", err) 95 + } 96 + defer func() { 97 + if closeErr := rows.Close(); closeErr != nil { 98 + slog.Warn("failed to close rows in ListByStatus", 99 + slog.String("error", closeErr.Error()), 100 + ) 101 + } 102 + }() 103 + 104 + var reports []*adminreports.Report 105 + for rows.Next() { 106 + report, err := scanReport(rows) 107 + if err != nil { 108 + return nil, err 109 + } 110 + reports = append(reports, report) 111 + } 112 + 113 + if err = rows.Err(); err != nil { 114 + return nil, fmt.Errorf("error iterating admin reports: %w", err) 115 + } 116 + 117 + return reports, nil 118 + } 119 + 120 + // UpdateStatus updates a report's status and resolution details 121 + // Sets resolved_by, resolution_notes, and resolved_at when resolving or dismissing 122 + func (r *postgresAdminReportRepo) UpdateStatus(ctx context.Context, id int64, status, resolvedBy, notes string) error { 123 + var query string 124 + var args []interface{} 125 + 126 + // When resolving or dismissing, set resolved_at and resolution fields 127 + if status == string(adminreports.StatusResolved) || status == string(adminreports.StatusDismissed) { 128 + query = ` 129 + UPDATE admin_reports 130 + SET status = $1, 131 + resolved_by = $2, 132 + resolution_notes = $3, 133 + resolved_at = $4 134 + WHERE id = $5 135 + ` 136 + args = []interface{}{status, resolvedBy, notes, time.Now(), id} 137 + } else { 138 + // For other status changes (e.g., open -> reviewing), don't set resolution fields 139 + query = ` 140 + UPDATE admin_reports 141 + SET status = $1 142 + WHERE id = $2 143 + ` 144 + args = []interface{}{status, id} 145 + } 146 + 147 + result, err := r.db.ExecContext(ctx, query, args...) 148 + if err != nil { 149 + // Check for constraint violations using pq.Error type 150 + if pqErr := extractPQError(err); pqErr != nil { 151 + if strings.Contains(pqErr.Constraint, "valid_status") { 152 + return adminreports.ErrInvalidStatus 153 + } 154 + if strings.Contains(pqErr.Constraint, "valid_reason") { 155 + return adminreports.ErrInvalidReason 156 + } 157 + } 158 + return fmt.Errorf("failed to update admin report status: %w", err) 159 + } 160 + 161 + rowsAffected, err := result.RowsAffected() 162 + if err != nil { 163 + return fmt.Errorf("failed to check update result: %w", err) 164 + } 165 + 166 + if rowsAffected == 0 { 167 + return adminreports.ErrReportNotFound 168 + } 169 + 170 + return nil 171 + } 172 + 173 + // scanReport scans a single report from a database row 174 + func scanReport(rows *sql.Rows) (*adminreports.Report, error) { 175 + var report adminreports.Report 176 + var targetType, reason, status string 177 + var explanation sql.NullString 178 + var resolvedBy sql.NullString 179 + var resolutionNotes sql.NullString 180 + var resolvedAt sql.NullTime 181 + 182 + err := rows.Scan( 183 + &report.ID, &report.ReporterDID, &report.TargetURI, &targetType, 184 + &reason, &explanation, &status, 185 + &resolvedBy, &resolutionNotes, 186 + &report.CreatedAt, &resolvedAt, 187 + ) 188 + if err != nil { 189 + return nil, fmt.Errorf("failed to scan admin report: %w", err) 190 + } 191 + 192 + // Convert string values to typed enums 193 + report.TargetType = adminreports.TargetType(targetType) 194 + report.Reason = adminreports.Reason(reason) 195 + report.Status = adminreports.Status(status) 196 + 197 + // Convert nullable fields 198 + if explanation.Valid { 199 + report.Explanation = explanation.String 200 + } 201 + if resolvedBy.Valid { 202 + report.ResolvedBy = &resolvedBy.String 203 + } 204 + if resolutionNotes.Valid { 205 + report.ResolutionNotes = &resolutionNotes.String 206 + } 207 + if resolvedAt.Valid { 208 + report.ResolvedAt = &resolvedAt.Time 209 + } 210 + 211 + return &report, nil 212 + } 213 + 214 + // extractPQError extracts a pq.Error from an error if present 215 + // Returns nil if the error is not a pq.Error 216 + func extractPQError(err error) *pq.Error { 217 + var pqErr *pq.Error 218 + if errors.As(err, &pqErr) { 219 + return pqErr 220 + } 221 + return nil 222 + }
+3 -3
internal/db/postgres/comment_repo.go
··· 341 341 342 342 // CountByParent counts direct replies to a post or comment 343 343 // Used for showing reply counts in threading UI 344 + // NOTE: Includes deleted comments since they're shown as "[deleted]" placeholders 344 345 func (r *postgresCommentRepo) CountByParent(ctx context.Context, parentURI string) (int, error) { 345 346 query := ` 346 347 SELECT COUNT(*) 347 348 FROM comments 348 - WHERE parent_uri = $1 AND deleted_at IS NULL 349 + WHERE parent_uri = $1 349 350 ` 350 351 351 352 var count int ··· 617 618 618 619 // Build complete query with JOINs and filters 619 620 // LEFT JOIN prevents data loss when user record hasn't been indexed yet (out-of-order Jetstream events) 620 - // Excludes deleted top-level comments - deleted nested comments are preserved via ListByParentsBatch 621 + // Includes deleted comments to preserve thread structure (shown as "[deleted]" placeholders) 621 622 query := fmt.Sprintf(` 622 623 %s 623 624 LEFT JOIN users u ON c.commenter_did = u.did 624 625 WHERE c.parent_uri = $1 625 - AND c.deleted_at IS NULL 626 626 %s 627 627 %s 628 628 ORDER BY %s
+13 -10
tests/integration/comment_consumer_test.go
··· 470 470 } 471 471 testPostURI := createTestPost(t, db, testCommunity, testUser.DID, "Delete Test", 0, time.Now()) 472 472 473 - t.Run("Delete comment decrements parent count", func(t *testing.T) { 473 + t.Run("Delete comment preserves parent count (deleted shown as placeholder)", func(t *testing.T) { 474 474 rkey := generateTID() 475 475 uri := fmt.Sprintf("at://%s/social.coves.community.comment/%s", testUser.DID, rkey) 476 476 ··· 538 538 t.Error("Expected deleted_at to be set, got nil") 539 539 } 540 540 541 - // Verify post comment count decremented 541 + // Verify post comment count is PRESERVED (not decremented) 542 + // Deleted comments are shown as "[deleted]" placeholders to preserve thread structure, 543 + // so they should still count toward the displayed total. 542 544 var finalCount int 543 545 err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", testPostURI).Scan(&finalCount) 544 546 if err != nil { 545 547 t.Fatalf("Failed to get final comment count: %v", err) 546 548 } 547 549 548 - if finalCount != initialCount-1 { 549 - t.Errorf("Expected comment count to decrease by 1. Initial: %d, Final: %d", initialCount, finalCount) 550 + if finalCount != initialCount { 551 + t.Errorf("Expected comment count to be PRESERVED (deleted = placeholder). Initial: %d, Final: %d", initialCount, finalCount) 550 552 } 551 553 }) 552 554 ··· 1541 1543 t.Fatalf("Failed to delete comment: %v", err) 1542 1544 } 1543 1545 1544 - // Verify Post 1 count decremented to 0 1546 + // Verify Post 1 count is PRESERVED at 1 (deleted comments shown as "[deleted]" placeholders) 1545 1547 err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", post1URI).Scan(&post1Count) 1546 1548 if err != nil { 1547 1549 t.Fatalf("Failed to check post 1 count after delete: %v", err) 1548 1550 } 1549 - if post1Count != 0 { 1550 - t.Errorf("Expected Post 1 comment_count = 0 after delete, got %d", post1Count) 1551 + if post1Count != 1 { 1552 + t.Errorf("Expected Post 1 comment_count = 1 after delete (preserved for placeholder), got %d", post1Count) 1551 1553 } 1552 1554 1553 1555 // Step 3: Recreate comment with same rkey but on Post 2 (different parent!) ··· 1610 1612 t.Errorf("Expected Post 2 comment_count = 1, got %d", post2Count) 1611 1613 } 1612 1614 1613 - // Verify Post 1 count still 0 (not incremented by resurrection on Post 2) 1615 + // Verify Post 1 count still 1 (preserved from before resurrection on Post 2) 1616 + // The resurrection on Post 2 does not affect Post 1's count 1614 1617 err = db.QueryRowContext(ctx, "SELECT comment_count FROM posts WHERE uri = $1", post1URI).Scan(&post1Count) 1615 1618 if err != nil { 1616 1619 t.Fatalf("Failed to check post 1 count after resurrection: %v", err) 1617 1620 } 1618 - if post1Count != 0 { 1619 - t.Errorf("Expected Post 1 comment_count = 0 (unchanged), got %d", post1Count) 1621 + if post1Count != 1 { 1622 + t.Errorf("Expected Post 1 comment_count = 1 (preserved, unchanged by Post 2 resurrection), got %d", post1Count) 1620 1623 } 1621 1624 }) 1622 1625 }
+29 -10
tests/integration/comment_query_test.go
··· 605 605 resp, err := service.GetComments(ctx, req) 606 606 require.NoError(t, err) 607 607 608 - // Verify only 3 comments returned (2 were deleted) 609 - assert.Len(t, resp.Comments, 3, "Should only return non-deleted comments") 608 + // All 5 comments should be returned (deleted comments shown as placeholders) 609 + assert.Len(t, resp.Comments, 5, "Should return all comments including deleted ones as placeholders") 610 610 611 - // Verify deleted comments are not in results 612 - returnedURIs := make(map[string]bool) 611 + // Build a map of URI -> CommentView for verification 612 + commentViews := make(map[string]*comments.CommentView) 613 613 for _, tv := range resp.Comments { 614 - returnedURIs[tv.Comment.URI] = true 614 + commentViews[tv.Comment.URI] = tv.Comment 615 615 } 616 616 617 - assert.False(t, returnedURIs[commentURIs[1]], "Deleted comment 1 should not be in results") 618 - assert.False(t, returnedURIs[commentURIs[3]], "Deleted comment 3 should not be in results") 619 - assert.True(t, returnedURIs[commentURIs[0]], "Non-deleted comment 0 should be in results") 620 - assert.True(t, returnedURIs[commentURIs[2]], "Non-deleted comment 2 should be in results") 621 - assert.True(t, returnedURIs[commentURIs[4]], "Non-deleted comment 4 should be in results") 617 + // Verify all comments are present 618 + assert.Contains(t, commentViews, commentURIs[0], "Comment 0 should be in results") 619 + assert.Contains(t, commentViews, commentURIs[1], "Comment 1 (deleted) should be in results as placeholder") 620 + assert.Contains(t, commentViews, commentURIs[2], "Comment 2 should be in results") 621 + assert.Contains(t, commentViews, commentURIs[3], "Comment 3 (deleted) should be in results as placeholder") 622 + assert.Contains(t, commentViews, commentURIs[4], "Comment 4 should be in results") 623 + 624 + // Verify deleted comments are marked as deleted 625 + assert.True(t, commentViews[commentURIs[1]].IsDeleted, "Deleted comment 1 should have IsDeleted=true") 626 + assert.True(t, commentViews[commentURIs[3]].IsDeleted, "Deleted comment 3 should have IsDeleted=true") 627 + 628 + // Verify non-deleted comments are NOT marked as deleted 629 + assert.False(t, commentViews[commentURIs[0]].IsDeleted, "Non-deleted comment 0 should have IsDeleted=false") 630 + assert.False(t, commentViews[commentURIs[2]].IsDeleted, "Non-deleted comment 2 should have IsDeleted=false") 631 + assert.False(t, commentViews[commentURIs[4]].IsDeleted, "Non-deleted comment 4 should have IsDeleted=false") 632 + 633 + // Verify deleted comments have nil Record (content cleared) 634 + assert.Nil(t, commentViews[commentURIs[1]].Record, "Deleted comment 1 should have nil Record") 635 + assert.Nil(t, commentViews[commentURIs[3]].Record, "Deleted comment 3 should have nil Record") 636 + 637 + // Verify non-deleted comments have content 638 + assert.NotNil(t, commentViews[commentURIs[0]].Record, "Non-deleted comment 0 should have Record") 639 + assert.NotNil(t, commentViews[commentURIs[2]].Record, "Non-deleted comment 2 should have Record") 640 + assert.NotNil(t, commentViews[commentURIs[4]].Record, "Non-deleted comment 4 should have Record") 622 641 } 623 642 624 643 // TestCommentQuery_InvalidInputs tests error handling for invalid inputs