1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace Tests\Libraries\BeatmapsetDiscussion;
7
8use App\Events\NewPrivateNotificationEvent;
9use App\Exceptions\InvariantException;
10use App\Jobs\Notifications\BeatmapsetDiscussionQualifiedProblem;
11use App\Jobs\Notifications\BeatmapsetDisqualify;
12use App\Jobs\Notifications\BeatmapsetResetNominations;
13use App\Libraries\BeatmapsetDiscussion\Review;
14use App\Models\Beatmap;
15use App\Models\BeatmapDiscussion;
16use App\Models\BeatmapDiscussionPost;
17use App\Models\Beatmapset;
18use App\Models\Notification;
19use App\Models\User;
20use Faker;
21use Illuminate\Support\Facades\Event;
22use Queue;
23use Tests\TestCase;
24
25class ReviewTest extends TestCase
26{
27 protected static $faker;
28 protected $beatmap;
29 protected $beatmapset;
30 protected $user;
31
32 public static function setUpBeforeClass(): void
33 {
34 self::$faker = Faker\Factory::create();
35 }
36
37 //region Review::create()
38
39 //region Failure Scenarios
40
41 // empty document
42 public function testCreateDocumentEmpty()
43 {
44 $this->expectException(InvariantException::class);
45 Review::create($this->beatmapset, [], $this->user);
46 }
47
48 // missing block type
49 public function testCreateDocumentMissingBlockType()
50 {
51 $this->expectException(InvariantException::class);
52 Review::create(
53 $this->beatmapset,
54 [
55 [
56 'text' => 'invalid lol',
57 ],
58 ],
59 $this->user
60 );
61 }
62
63 // invalid block type
64 public function testCreateDocumentInvalidBlockType()
65 {
66 $this->expectException(InvariantException::class);
67 Review::create(
68 $this->beatmapset,
69 [
70 [
71 'type' => 'invalid lol',
72 ],
73 ],
74 $this->user
75 );
76 }
77
78 // invalid paragraph block
79 public function testCreateDocumentInvalidParagraphBlockContent()
80 {
81 $this->expectException(InvariantException::class);
82 Review::create(
83 $this->beatmapset,
84 [
85 [
86 'type' => 'paragraph',
87 ],
88 ],
89 $this->user
90 );
91 }
92
93 // invalid embed block
94 public function testCreateDocumentInvalidEmbedBlockContent()
95 {
96 $this->expectException(InvariantException::class);
97 Review::create(
98 $this->beatmapset,
99 [
100 [
101 'type' => 'embed',
102 ],
103 ],
104 $this->user
105 );
106 }
107
108 // valid document containing zero issue embeds
109 public function testCreateDocumentValidParagraphWithNoIssues()
110 {
111 $this->expectException(InvariantException::class);
112 Review::create(
113 $this->beatmapset,
114 [
115 [
116 'type' => 'paragraph',
117 'text' => 'this is a text',
118 ],
119 ],
120 $this->user
121 );
122 }
123
124 // valid paragraph but text is JSON
125 public function testCreateDocumentValidParagraphButJSON()
126 {
127 $this->expectException(InvariantException::class);
128 Review::create(
129 $this->beatmapset,
130 [
131 [
132 'type' => 'paragraph',
133 'text' => ['y', 'tho'],
134 ],
135 ],
136 $this->user
137 );
138 }
139
140 // valid review but text is JSON
141 public function testCreateDocumentValidIssueButJSON()
142 {
143 $this->expectException(InvariantException::class);
144 Review::create(
145 $this->beatmapset,
146 [
147 [
148 'type' => 'embed',
149 'discussion_type' => 'problem',
150 'text' => ['y', 'tho'],
151 'timestamp' => true,
152 'beatmap_id' => $this->beatmap->getKey(),
153 ],
154 [
155 'type' => 'embed',
156 'discussion_type' => 'problem',
157 'text' => self::$faker->sentence(),
158 ],
159 ],
160 $this->user
161 );
162 }
163
164 // document with too many blocks
165 public function testCreateDocumentValidWithTooManyBlocks()
166 {
167 $this->expectException(InvariantException::class);
168 Review::create(
169 $this->beatmapset,
170 [
171 [
172 'type' => 'embed',
173 'discussion_type' => 'problem',
174 'text' => self::$faker->sentence(),
175 ],
176 [
177 'type' => 'paragraph',
178 'text' => self::$faker->sentence(),
179 ],
180 [
181 'type' => 'paragraph',
182 'text' => self::$faker->sentence(),
183 ],
184 [
185 'type' => 'paragraph',
186 'text' => self::$faker->sentence(),
187 ],
188 [
189 'type' => 'paragraph',
190 'text' => self::$faker->sentence(),
191 ],
192 ],
193 $this->user
194 );
195 }
196
197 //endregion
198
199 //region Success Scenarios
200
201 // valid document containing issue embeds
202 public function testCreateDocumentDocumentValidWithIssues()
203 {
204 $discussionCount = BeatmapDiscussion::count();
205 $discussionPostCount = BeatmapDiscussionPost::count();
206 $timestampedIssueText = '00:01:234 '.self::$faker->sentence();
207 $issueText = self::$faker->sentence();
208
209 Review::create(
210 $this->beatmapset,
211 [
212 [
213 'type' => 'embed',
214 'discussion_type' => 'problem',
215 'text' => $timestampedIssueText,
216 'timestamp' => true,
217 'beatmap_id' => $this->beatmap->getKey(),
218 ],
219 [
220 'type' => 'embed',
221 'discussion_type' => 'problem',
222 'text' => $issueText,
223 ],
224 [
225 'type' => 'paragraph',
226 'text' => 'this is some paragraph text',
227 ],
228 ],
229 $this->user
230 );
231
232 $discussionJson = json_encode($this->beatmapset->defaultDiscussionJson());
233 $this->assertStringContainsString("\"message\":\"{$timestampedIssueText}\"", $discussionJson);
234 $this->assertStringContainsString('"timestamp":1234', $discussionJson);
235 $this->assertStringContainsString("\"message\":\"{$issueText}\"", $discussionJson);
236
237 // ensure 3 discussions/posts are created - one for the review and one for each embedded problem
238 $this->assertSame($discussionCount + 3, BeatmapDiscussion::count());
239 $this->assertSame($discussionPostCount + 3, BeatmapDiscussionPost::count());
240 }
241
242 // valid document containing issue embeds should trigger disqualification (for GMT)
243 public function testCreateDocumentDocumentValidWithIssuesShouldDisqualify()
244 {
245 $gmtUser = User::factory()->withGroup('gmt')->create();
246 $beatmapset = Beatmapset::factory()->qualified()->create();
247 $beatmapset->beatmaps()->save(Beatmap::factory()->make());
248 $watchingUser = User::factory()->create();
249 $beatmapset->watches()->create(['user_id' => $watchingUser->getKey()]);
250
251 Review::create(
252 $beatmapset,
253 [
254 [
255 'type' => 'embed',
256 'discussion_type' => 'problem',
257 'text' => self::$faker->sentence(),
258 ],
259 [
260 'type' => 'paragraph',
261 'text' => 'this is some paragraph text',
262 ],
263 ],
264 $gmtUser
265 );
266
267 $beatmapset->refresh();
268
269 // ensure qualified beatmap has been reset to pending
270 $this->assertSame($beatmapset->approved, Beatmapset::STATES['pending']);
271
272 // ensure a disqualification notification is dispatched
273 Queue::assertPushed(BeatmapsetDisqualify::class);
274 $this->runFakeQueue();
275 Event::assertDispatched(NewPrivateNotificationEvent::class);
276 }
277
278 // valid document containing issue embeds should reset nominations (for GMT)
279 public function testCreateDocumentDocumentValidWithIssuesShouldResetNominations()
280 {
281 $beatmapset = Beatmapset::factory()->create([
282 'approved' => Beatmapset::STATES['pending'],
283 ]);
284 $beatmapset->beatmaps()->save(Beatmap::factory()->make());
285
286 $playmode = $beatmapset->playmodesStr()[0];
287 $natUser = User::factory()->withGroup('nat', [$playmode])->create();
288 $watchingUser = User::factory()->create();
289 $beatmapset->watches()->create(['user_id' => $watchingUser->getKey()]);
290
291 // ensure beatmapset has a nomination
292 $beatmapset->nominate($natUser, [$playmode]);
293 $this->assertSame($beatmapset->currentNominationCount()[$playmode], 1);
294
295 Review::create(
296 $beatmapset,
297 [
298 [
299 'type' => 'embed',
300 'discussion_type' => 'problem',
301 'text' => self::$faker->sentence(),
302 ],
303 [
304 'type' => 'paragraph',
305 'text' => 'this is some paragraph text',
306 ],
307 ],
308 $natUser
309 );
310
311 $beatmapset->refresh();
312
313 // ensure beatmap is still pending
314 $this->assertSame($beatmapset->approved, Beatmapset::STATES['pending']);
315 // ensure nomination count has been reset
316 $this->assertSame($beatmapset->currentNominationCount()[$playmode], 0);
317
318 // ensure a nomination reset notification is dispatched
319 Queue::assertPushed(BeatmapsetResetNominations::class);
320 $this->runFakeQueue();
321 Event::assertDispatched(NewPrivateNotificationEvent::class);
322 }
323
324 // valid document containing issue embeds should reset nominations (for GMT)
325 /**
326 * @dataProvider dataProviderForQualifiedProblem
327 */
328 public function testCreateDocumentDocumentValidWithNewIssuesShouldNotify($state, $shouldNotify)
329 {
330 $gmtUser = User::factory()->withGroup('gmt')->create();
331 $beatmapset = Beatmapset::factory()->$state()->create();
332 $beatmapset->beatmaps()->save(Beatmap::factory()->make(['playmode' => 0]));
333
334 $notificationOption = $gmtUser->notificationOptions()->firstOrCreate([
335 'name' => Notification::BEATMAPSET_DISCUSSION_QUALIFIED_PROBLEM,
336 ]);
337 $notificationOption->update(['details' => ['modes' => ['osu']]]);
338
339 Review::create(
340 $beatmapset,
341 [
342 [
343 'type' => 'embed',
344 'discussion_type' => 'problem',
345 'text' => self::$faker->sentence(),
346 ],
347 [
348 'type' => 'paragraph',
349 'text' => 'this is some paragraph text',
350 ],
351 ],
352 $this->user
353 );
354
355 $beatmapset->refresh();
356
357 // ensure beatmap status hasn't changed.
358 $this->assertSame($beatmapset->status(), $state);
359
360 if ($shouldNotify) {
361 // ensure a new problem notification is dispatched
362 Queue::assertPushed(BeatmapsetDiscussionQualifiedProblem::class);
363 $this->runFakeQueue();
364 Event::assertDispatched(NewPrivateNotificationEvent::class);
365 } else {
366 Queue::assertNotPushed(BeatmapsetDiscussionQualifiedProblem::class);
367 $this->runFakeQueue();
368 Event::assertNotDispatched(NewPrivateNotificationEvent::class);
369 }
370 }
371
372 //endregion
373
374 //endregion
375
376 //region Review::update()
377
378 //region Failure Scenarios
379
380 // empty document
381 public function testUpdateDocumentEmpty()
382 {
383 $this->expectException(InvariantException::class);
384 $this->updateReview([]);
385 }
386
387 // missing block type
388 public function testUpdateDocumentMissingBlockType()
389 {
390 $this->expectException(InvariantException::class);
391 $this->updateReview([
392 [
393 'text' => 'invalid lol',
394 ],
395 ]);
396 }
397
398 // invalid block type
399 public function testUpdateDocumentInvalidBlockType()
400 {
401 $this->expectException(InvariantException::class);
402 $this->updateReview([
403 [
404 'type' => 'invalid lol',
405 ],
406 ]);
407 }
408
409 // invalid paragraph block
410 public function testUpdateDocumentInvalidParagraphBlockContent()
411 {
412 $this->expectException(InvariantException::class);
413 $this->updateReview([
414 [
415 'type' => 'paragraph',
416 ],
417 ]);
418 }
419
420 // invalid embed block
421 public function testUpdateDocumentInvalidEmbedBlockContent()
422 {
423 $this->expectException(InvariantException::class);
424 $this->updateReview([
425 [
426 'type' => 'embed',
427 ],
428 ]);
429 }
430
431 // valid document containing zero issue embeds
432 public function testUpdateDocumentValidParagraphWithNoIssues()
433 {
434 $this->expectException(InvariantException::class);
435 $this->updateReview([
436 [
437 'type' => 'paragraph',
438 'text' => 'this is a text',
439 ],
440 ]);
441 }
442
443 // valid paragraph but text is JSON
444 public function testUpdateDocumentValidParagraphButJSON()
445 {
446 $this->expectException(InvariantException::class);
447 $this->updateReview([
448 [
449 'type' => 'paragraph',
450 'text' => ['y', 'tho'],
451 ],
452 ]);
453 }
454
455 // valid review but text is JSON
456 public function testUpdateDocumentValidIssueButJSON()
457 {
458 $this->expectException(InvariantException::class);
459 $this->updateReview([
460 [
461 'type' => 'embed',
462 'discussion_type' => 'problem',
463 'text' => ['y', 'tho'],
464 'timestamp' => true,
465 'beatmap_id' => $this->beatmap->getKey(),
466 ],
467 [
468 'type' => 'embed',
469 'discussion_type' => 'problem',
470 'text' => self::$faker->sentence(),
471 ],
472 ]);
473 }
474
475 // document with too many blocks
476 public function testUpdateDocumentValidWithTooManyBlocks()
477 {
478 $this->expectException(InvariantException::class);
479 $this->updateReview([
480 [
481 'type' => 'embed',
482 'discussion_type' => 'problem',
483 'text' => self::$faker->sentence(),
484 ],
485 [
486 'type' => 'paragraph',
487 'text' => self::$faker->sentence(),
488 ],
489 [
490 'type' => 'paragraph',
491 'text' => self::$faker->sentence(),
492 ],
493 [
494 'type' => 'paragraph',
495 'text' => self::$faker->sentence(),
496 ],
497 [
498 'type' => 'paragraph',
499 'text' => self::$faker->sentence(),
500 ],
501 ]);
502 }
503
504 // document referencing issues belonging to another review
505 public function testUpdateDocumentValidWithExternalReference()
506 {
507 $review = $this->setUpReview();
508
509 $differentReview = $this->setUpReview();
510 $document = json_decode($differentReview->startingPost->message, true);
511
512 $this->expectException(InvariantException::class);
513 Review::update($review, $document, $this->user);
514 }
515
516 //endregion
517
518 //region Success Scenarios
519
520 // valid document containing issue embeds
521 public function testUpdateDocumentValidWithIssues()
522 {
523 $review = $this->setUpReview();
524 $linkedIssue = BeatmapDiscussion::where('parent_id', $review->id)->first();
525
526 $discussionCount = BeatmapDiscussion::count();
527 $discussionPostCount = BeatmapDiscussionPost::count();
528
529 $document = json_decode($review->startingPost->message, true);
530
531 Review::update($review, $document, $this->user);
532
533 // ensure number of discussions/issues hasn't changed
534 $this->assertSame($discussionCount, BeatmapDiscussion::count());
535 $this->assertSame($discussionPostCount, BeatmapDiscussionPost::count());
536
537 // ensure issue is still linked correctly
538 $this->assertSame($review->id, $linkedIssue->refresh()->parent_id);
539 }
540
541 // adding a new embed to an existing issue
542 public function testUpdateDocumentWithNewIssue()
543 {
544 $review = $this->setUpReview();
545
546 $discussionCount = BeatmapDiscussion::count();
547 $discussionPostCount = BeatmapDiscussionPost::count();
548 $linkedIssueCount = BeatmapDiscussion::where('parent_id', $review->id)->count();
549
550 $document = json_decode($review->startingPost->message, true);
551 $document[] = [
552 'type' => 'embed',
553 'discussion_type' => 'problem',
554 'text' => 'whee',
555 ];
556
557 Review::update($review, $document, $this->user);
558
559 // ensure new issue was created
560 $this->assertSame($discussionCount + 1, BeatmapDiscussion::count());
561 $this->assertSame($discussionPostCount + 1, BeatmapDiscussionPost::count());
562
563 // ensure new issue is linked correctly
564 $this->assertSame($linkedIssueCount + 1, BeatmapDiscussion::where('parent_id', $review->id)->count());
565 }
566
567 public function testUpdateDocumentWithNewIssueShouldDisqualify()
568 {
569 $gmtUser = User::factory()->withGroup('gmt')->create();
570 $beatmapset = Beatmapset::factory()->qualified()->create();
571 $beatmapset->beatmaps()->save(Beatmap::factory()->make());
572 $review = $this->setUpPraiseOnlyReview($beatmapset, $gmtUser);
573
574 // ensure qualified beatmap is qualified
575 $this->assertSame($beatmapset->approved, Beatmapset::STATES['qualified']);
576
577 // ensure we have a user watching, otherwise no notifications will be sent
578 $watchingUser = User::factory()->create();
579 $beatmapset->watches()->create(['user_id' => $watchingUser->getKey()]);
580
581 $document = json_decode($review->startingPost->message, true);
582 $document[] = [
583 'type' => 'embed',
584 'discussion_type' => 'problem',
585 'text' => 'whee',
586 ];
587
588 Review::update($review, $document, $gmtUser);
589
590 $beatmapset->refresh();
591
592 // ensure qualified beatmap has been reset to pending
593 $this->assertSame($beatmapset->approved, Beatmapset::STATES['pending']);
594
595 // ensure a disqualification notification is dispatched
596 Queue::assertPushed(BeatmapsetDisqualify::class);
597 $this->runFakeQueue();
598 Event::assertDispatched(NewPrivateNotificationEvent::class);
599 }
600
601 public function testUpdateDocumentWithNewIssueShouldResetNominations()
602 {
603 $beatmapset = Beatmapset::factory()->create([
604 'approved' => Beatmapset::STATES['pending'],
605 ]);
606 $beatmapset->beatmaps()->save(Beatmap::factory()->make());
607
608 $playmode = $beatmapset->playmodesStr()[0];
609 $natUser = User::factory()->withGroup('nat', [$playmode])->create();
610 $review = $this->setUpPraiseOnlyReview($beatmapset, $natUser);
611
612 // ensure qualified beatmap is pending
613 $this->assertSame($beatmapset->approved, Beatmapset::STATES['pending']);
614
615 // ensure beatmapset has a nominationBeatmapsetCompactTransformer.php
616 $beatmapset->nominate($natUser, [$playmode]);
617 $this->assertSame($beatmapset->currentNominationCount()[$playmode], 1);
618
619 // ensure we have a user watching, otherwise no notifications will be sent
620 $watchingUser = User::factory()->create();
621 $beatmapset->watches()->create(['user_id' => $watchingUser->getKey()]);
622
623 $document = json_decode($review->startingPost->message, true);
624 $document[] = [
625 'type' => 'embed',
626 'discussion_type' => 'problem',
627 'text' => 'whee',
628 ];
629
630 Review::update($review, $document, $natUser);
631
632 $beatmapset->refresh();
633
634 // ensure beatmap is still pending
635 $this->assertSame($beatmapset->approved, Beatmapset::STATES['pending']);
636 // ensure nomination count has been reset
637 $this->assertSame($beatmapset->currentNominationCount()[$playmode], 0);
638
639 // ensure a nomination reset notification is dispatched
640 Queue::assertPushed(BeatmapsetResetNominations::class);
641 $this->runFakeQueue();
642 Event::assertDispatched(NewPrivateNotificationEvent::class);
643 }
644
645 /**
646 * @dataProvider dataProviderForQualifiedProblem
647 */
648 public function testUpdateDocumentWithNewIssueShouldNotifyIfQualified($state, $shouldNotify)
649 {
650 $gmtUser = User::factory()->withGroup('gmt')->create();
651 $beatmapset = Beatmapset::factory()->$state()->create();
652 $beatmapset->beatmaps()->save(Beatmap::factory()->make(['playmode' => 0]));
653
654 $notificationOption = $gmtUser->notificationOptions()->firstOrCreate([
655 'name' => Notification::BEATMAPSET_DISCUSSION_QUALIFIED_PROBLEM,
656 ]);
657 $notificationOption->update(['details' => ['modes' => ['osu']]]);
658
659 $review = $this->setUpPraiseOnlyReview($beatmapset, $gmtUser);
660
661 // ensure qualified beatmap is qualified
662 $this->assertSame($beatmapset->status(), $state);
663
664 $document = json_decode($review->startingPost->message, true);
665 $document[] = [
666 'type' => 'embed',
667 'discussion_type' => 'problem',
668 'text' => 'whee',
669 ];
670
671 Review::update($review, $document, $this->user);
672
673 $beatmapset->refresh();
674
675 // ensure beatmap status hasn't changed.
676 $this->assertSame($beatmapset->status(), $state);
677
678 if ($shouldNotify) {
679 // ensure a new problem notification is dispatched
680 Queue::assertPushed(BeatmapsetDiscussionQualifiedProblem::class);
681 $this->runFakeQueue();
682 Event::assertDispatched(NewPrivateNotificationEvent::class);
683 } else {
684 Queue::assertNotPushed(BeatmapsetDiscussionQualifiedProblem::class);
685 $this->runFakeQueue();
686 Event::assertNotDispatched(NewPrivateNotificationEvent::class);
687 }
688 }
689
690 // removing/unlinking an embed from an existing issue
691 public function testUpdateDocumentRemoveIssue()
692 {
693 $review = $this->setUpReview();
694
695 $discussionCount = BeatmapDiscussion::count();
696 $discussionPostCount = BeatmapDiscussionPost::count();
697
698 $document = json_decode($review->startingPost->message, true);
699 $issue = array_shift($document); // drop the first issue
700
701 Review::update($review, $document, $this->user);
702
703 // ensure number of discussions/issues hasn't changed
704 $this->assertSame($discussionCount, BeatmapDiscussion::count());
705 $this->assertSame($discussionPostCount, BeatmapDiscussionPost::count());
706
707 $unlinked = BeatmapDiscussion::find($issue['discussion_id']);
708
709 // ensure embed is no longer in message
710 $this->assertStringNotContainsString((string) $unlinked->id, $review->startingPost->message);
711
712 // ensure parent_id is removed from child issue
713 $this->assertNull($unlinked->parent_id);
714 }
715
716 //endregion
717
718 //endregion
719
720 public static function dataProviderForQualifiedProblem()
721 {
722 return [
723 ['qualified', true],
724 ['pending', false],
725 ];
726 }
727
728 protected function setUp(): void
729 {
730 parent::setUp();
731
732 Queue::fake();
733 Event::fake();
734
735 config_set('osu.beatmapset.discussion_review_max_blocks', 4);
736
737 $this->user = User::factory()->create();
738 $this->beatmapset = Beatmapset::factory()->create([
739 'approved' => Beatmapset::STATES['pending'],
740 ]);
741 $this->beatmap = $this->beatmapset->beatmaps()->save(Beatmap::factory()->make());
742 }
743
744 protected function setUpReview($beatmapset = null): BeatmapDiscussion
745 {
746 $timestampedIssueText = '00:01:234 '.self::$faker->sentence();
747 $issueText = self::$faker->sentence();
748
749 return Review::create(
750 $beatmapset ?? $this->beatmapset,
751 [
752 [
753 'type' => 'embed',
754 'discussion_type' => 'problem',
755 'text' => $timestampedIssueText,
756 'timestamp' => true,
757 'beatmap_id' => $this->beatmap->getKey(),
758 ],
759 [
760 'type' => 'embed',
761 'discussion_type' => 'problem',
762 'text' => $issueText,
763 ],
764 [
765 'type' => 'paragraph',
766 'text' => 'this is some paragraph text',
767 ],
768 ],
769 $this->user
770 );
771 }
772
773 protected function setUpPraiseOnlyReview($beatmapset = null, $user = null): BeatmapDiscussion
774 {
775 return Review::create(
776 $beatmapset ?? $this->beatmapset,
777 [
778 [
779 'type' => 'embed',
780 'discussion_type' => 'praise',
781 'text' => self::$faker->sentence(),
782 ],
783 [
784 'type' => 'paragraph',
785 'text' => 'this is some paragraph text',
786 ],
787 ],
788 $user ?? $this->user
789 );
790 }
791
792 protected function updateReview($document)
793 {
794 $review = $this->setUpReview();
795 Review::update($review, $document, $this->user);
796 }
797}