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
6declare(strict_types=1);
7
8namespace Tests\Models;
9
10use App\Exceptions\InvariantException;
11use App\Exceptions\ValidationException;
12use App\Libraries\MorphMap;
13use App\Models\BeatmapDiscussion;
14use App\Models\BeatmapDiscussionPost;
15use App\Models\Beatmapset;
16use App\Models\Chat\Channel;
17use App\Models\Chat\Message;
18use App\Models\Forum;
19use App\Models\Traits\ReportableInterface;
20use App\Models\User;
21use App\Models\UserReport;
22use Carbon\Carbon;
23use Exception;
24use Tests\TestCase;
25
26class UserReportTest extends TestCase
27{
28 public static function reportableClasses(): array
29 {
30 $reportables = [];
31
32 foreach (MorphMap::MAP as $class => $_name) {
33 if (isset(class_implements($class)[ReportableInterface::class])) {
34 $reportables[] = [$class];
35 }
36 }
37
38 // Sanity check to make sure there are models to test.
39 if (count($reportables) === 0) {
40 throw new Exception('No reportables found');
41 }
42
43 return $reportables;
44 }
45
46 private static function getReportableUser(ReportableInterface $reportable)
47 {
48 return match ($reportable::class) {
49 Message::class => $reportable->sender,
50 User::class => $reportable,
51 default => $reportable->user,
52 };
53 }
54
55 private static function makeReportable(string $class): ReportableInterface
56 {
57 $modelFactory = $class::factory();
58 $userColumn = 'user_id';
59
60 if ($class === Beatmapset::class) {
61 $modelFactory = $modelFactory->pending();
62 }
63
64 if ($class === BeatmapDiscussionPost::class) {
65 $modelFactory = $modelFactory->state([
66 'beatmap_discussion_id' => BeatmapDiscussion::factory()->general()->state([
67 'beatmapset_id' => Beatmapset::factory(),
68 ]),
69 ]);
70 }
71
72 if ($class === Forum\Post::class) {
73 $userColumn = 'poster_id';
74 }
75
76 if ($class === Message::class) {
77 $modelFactory = $modelFactory->state([
78 'channel_id' => Channel::factory()->type('public'),
79 ]);
80 }
81
82 return $class === User::class
83 ? $modelFactory->create()
84 : $modelFactory->create([$userColumn => User::factory()]);
85 }
86
87 private static function reportParams(array $additionalParams = []): array
88 {
89 return array_merge([
90 'comments' => 'some comment',
91 ], $additionalParams);
92 }
93
94 private User $reporter;
95
96 /**
97 * @dataProvider reportableClasses
98 */
99 public function testCannotReportOwnThing(string $class)
100 {
101 $reportable = static::makeReportable($class);
102
103 $this->expectException(ValidationException::class);
104 $reportable->reportBy(static::getReportableUser($reportable), static::reportParams());
105 }
106
107 public function testCannotReportScoreableBeatmapset()
108 {
109 $beatmapset = Beatmapset::factory()->qualified()->create();
110 $reporter = User::factory()->create();
111
112 $this->expectException(ValidationException::class);
113 $beatmapset->reportBy($reporter, static::reportParams());
114 }
115
116 public function testCannotReportIfNotInChannel()
117 {
118 $channel = Channel::factory()->type('pm')->create();
119 $message = Message::factory()->create(['channel_id' => $channel, 'user_id' => $channel->users()->first()]);
120 $reporter = User::factory()->create();
121
122 $this->expectException(ValidationException::class);
123 $message->reportBy($reporter, static::reportParams());
124 }
125
126 /**
127 * @dataProvider reportableClasses
128 */
129 public function testInvalidReason(string $class)
130 {
131 $reportable = static::makeReportable($class);
132 $reporter = User::factory()->create();
133
134 $this->expectException(ValidationException::class);
135
136 $reportable->reportBy($reporter, static::reportParams([
137 'reason' => 'NotAValidReason',
138 ]));
139 }
140
141 /**
142 * @dataProvider reportableClasses
143 */
144 public function testNoComments(string $class): void
145 {
146 $reportable = static::makeReportable($class);
147 $reporter = User::factory()->create();
148
149 if ($class === Message::class) {
150 $this->expectCountChange(fn () => UserReport::count(), 1);
151 } else {
152 $this->expectException(ValidationException::class);
153 }
154 $reportable->reportBy($reporter, static::reportParams([
155 'comments' => null,
156 ]));
157 }
158
159 /**
160 * @dataProvider reportableClasses
161 */
162 public function testNoCommentsReasonOther(string $class): void
163 {
164 $reportable = static::makeReportable($class);
165 $reporter = User::factory()->create();
166
167 $this->expectException(ValidationException::class);
168 $reportable->reportBy($reporter, static::reportParams([
169 'comments' => null,
170 'reason' => 'Other',
171 ]));
172 }
173
174 /**
175 * @dataProvider reportableClasses
176 */
177 public function testReportableInstance(string $class)
178 {
179 $reportable = static::makeReportable($class);
180 $reporter = User::factory()->create();
181
182 $query = UserReport::whereMorphedTo('reportable', $reportable);
183 $this->expectCountChange(fn () => $query->count(), 1, 'reportable query');
184 $this->expectCountChange(fn () => $reporter->fresh()->reportsMade->count(), 1, 'reportsMade accessor');
185 $this->expectCountChange(fn () => $reporter->reportsMade()->count(), 1, 'reportsMade query');
186 $this->expectCountChange(fn () => $reportable->fresh()->reportedIn->count(), 1, 'reportedIn accessor');
187 $this->expectCountChange(fn () => $reportable->reportedIn()->count(), 1, 'reportedIn query');
188
189 $report = $reportable->reportBy($reporter, static::reportParams());
190 if ($reportable instanceof BestModel) {
191 $this->assertSame($reportable->getKey(), $report->score_id);
192 }
193 $reportableUserId = $reportable instanceof Forum\Post
194 ? $reportable->poster_id
195 : $reportable->user_id;
196 $this->assertSame($reportableUserId, $report->user_id);
197 $this->assertTrue($report->reportable->is($reportable));
198 }
199
200 /**
201 * @dataProvider reportableClasses
202 */
203 public function testReportableNotificationEndpoint(string $class): void
204 {
205 $reportable = static::makeReportable($class);
206 $reporter = User::factory()->create();
207
208 $report = $reportable->reportBy($reporter, static::reportParams());
209
210 $report->routeNotificationForSlack(null);
211
212 $this->assertTrue(true, 'should not fail getting notification routing url');
213 }
214
215 public function testReportingAgainAfterAWhile(): void
216 {
217 $reportable = static::makeReportable(User::class);
218 $reporter = User::factory()->create();
219
220 $oldReport = $reportable->reportBy($reporter, static::reportParams([
221 'comments' => 'test',
222 ]));
223 $oldReport->update(['timestamp' => Carbon::now()->subYears(1)]);
224
225 $this->expectCountChange(fn () => $reportable->fresh()->reportedIn()->count(), 1);
226
227 $reportable->reportBy($reporter, static::reportParams([
228 'comments' => 'test',
229 ]));
230 }
231
232 public function testReportingAgainImmediate(): void
233 {
234 $reportable = static::makeReportable(User::class);
235 $reporter = User::factory()->create();
236
237 $oldReport = $reportable->reportBy($reporter, static::reportParams([
238 'comments' => 'test',
239 ]));
240 $oldReport->update(['timestamp' => Carbon::now()->subMinute(1)]);
241
242 $this->expectCountChange(fn () => $reportable->fresh()->reportedIn()->count(), 0);
243
244 $this->expectExceptionCallable(function () use ($reportable, $reporter) {
245 $reportable->reportBy($reporter, static::reportParams([
246 'comments' => 'test',
247 ]));
248 }, InvariantException::class, osu_trans('errors.user_report.recently_reported'));
249 }
250}