A community based topic aggregation platform built on atproto
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}