A community based topic aggregation platform built on atproto
at main 622 lines 17 kB view raw
1package adminreports 2 3import ( 4 "context" 5 "errors" 6 "strings" 7 "testing" 8) 9 10// mockRepository implements Repository for testing 11type 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 18func (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 28func (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 35func (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 42func 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 91func 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 117func 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 192func 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 212func 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 232func 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 253func 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 273func 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 294func 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 319func 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 338func 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 365func 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 390func 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 413func 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 449func 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 462func 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 532func 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 547func 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 562func 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 577func 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 603func 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}