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\Libraries\BeatmapsetDiscussion;
9
10use App\Events\NewPrivateNotificationEvent;
11use App\Exceptions\AuthorizationException;
12use App\Exceptions\VerificationRequiredException;
13use App\Jobs\Notifications\BeatmapsetDiscussionPostNew;
14use App\Jobs\Notifications\BeatmapsetDiscussionQualifiedProblem;
15use App\Jobs\Notifications\BeatmapsetDisqualify;
16use App\Jobs\Notifications\BeatmapsetResetNominations;
17use App\Libraries\BeatmapsetDiscussion\Discussion;
18use App\Models\Beatmap;
19use App\Models\BeatmapDiscussion;
20use App\Models\BeatmapDiscussionPost;
21use App\Models\Beatmapset;
22use App\Models\Notification;
23use App\Models\User;
24use App\Models\UserNotification;
25use Event;
26use Queue;
27use Tests\TestCase;
28
29class DiscussionTest extends TestCase
30{
31 private const TEST_MESSAGE = 'not important';
32
33 private User $mapper;
34
35 /**
36 * @dataProvider minPlaysVerificationDataProvider
37 */
38 public function testMinPlaysVerification(\Closure $minPlays, bool $verified, bool $success)
39 {
40 config_set('osu.user.post_action_verification', false);
41
42 $user = User::factory()->withPlays($minPlays())->create();
43 $beatmapset = $this->beatmapsetFactory()->create();
44 $beatmapset->watches()->create(['user_id' => User::factory()->create()->getKey()]);
45
46 $change = $success ? 1 : 0;
47 $this->expectCountChange(fn () => BeatmapDiscussion::count(), $change, BeatmapDiscussion::class);
48 $this->expectCountChange(fn () => BeatmapDiscussionPost::count(), $change, BeatmapDiscussionPost::class);
49 $this->expectCountChange(fn () => Notification::count(), $change, Notification::class);
50 $this->expectCountChange(fn () => UserNotification::count(), $change, UserNotification::class);
51
52 if ($verified) {
53 $user->markSessionVerified();
54 }
55
56 if (!$success) {
57 $this->expectException(VerificationRequiredException::class);
58 }
59
60 (new Discussion($user, $beatmapset, $this->makeParams('praise'), static::TEST_MESSAGE))->handle();
61 }
62
63 /**
64 * See testReopeningProblemDoesNotDisqualifyOrResetNominations for assertions
65 * jobs are not queued when reopening a resolved discussion.
66 *
67 * @dataProvider newDiscussionQueuesJobsDataProvider
68 */
69 public function testNewDiscussionQueuesJobs(string $state, ?string $group, array $queued, array $notQueued)
70 {
71 $user = User::factory()->withGroup($group)->create()->markSessionVerified();
72
73 $beatmapset = $this->beatmapsetFactory()
74 ->withNominations()
75 ->$state()
76 ->create();
77
78 Queue::fake();
79
80 (new Discussion($user, $beatmapset, $this->makeParams('problem'), static::TEST_MESSAGE))->handle();
81
82 foreach ($queued as $class) {
83 Queue::assertPushed($class);
84 }
85
86 foreach ($notQueued as $class) {
87 Queue::assertNotPushed($class);
88 }
89 }
90
91 /**
92 * @dataProvider shouldDisqualifyOrResetNominationsDataProvider
93 */
94 public function testShouldDisqualifyOrResetNominations(string $state, ?string $group, string $messageType, bool $expects)
95 {
96 $user = User::factory()->withGroup($group)->create()->markSessionVerified();
97
98 $beatmapset = $this->beatmapsetFactory()
99 ->withNominations()
100 ->$state()
101 ->create();
102
103 $subject = new Discussion($user, $beatmapset, $this->makeParams($messageType), static::TEST_MESSAGE);
104
105 $value = $this->invokeMethod($subject, 'shouldDisqualifyOrResetNominations');
106 $this->assertSame($expects, $value);
107 }
108
109 public function testWatchersGetNotification()
110 {
111 $user = User::factory()->create()->markSessionVerified();
112 $watcher = User::factory()->create();
113 $beatmapset = $this->beatmapsetFactory()->create();
114 $beatmapset->watches()->create(['user_id' => $watcher->getKey()]);
115
116 Queue::fake();
117
118 (new Discussion($user, $beatmapset, $this->makeParams('praise'), static::TEST_MESSAGE))->handle();
119
120 Queue::assertPushed(
121 BeatmapsetDiscussionPostNew::class,
122 fn (BeatmapsetDiscussionPostNew $job) => (
123 $this->inReceivers($watcher, $job)
124 && !$this->inReceivers($user, $job)
125 )
126 );
127
128 $this->runFakeQueue();
129
130 // TODO: this should probably be changed to asserting "if job queued, then event is broadcast to receivers with option set"
131 Event::assertDispatched(
132 NewPrivateNotificationEvent::class,
133 fn (NewPrivateNotificationEvent $event) => (
134 $this->inReceivers($watcher, $event)
135 && !$this->inReceivers($user, $event)
136 )
137 );
138 }
139
140 //region Posting mapper notes
141
142 public function testNewMapperNote()
143 {
144 $beatmapset = $this->beatmapsetFactory()->create();
145
146 $this->expectCountChange(fn () => BeatmapDiscussion::count(), 1, BeatmapDiscussion::class);
147 $this->expectCountChange(fn () => BeatmapDiscussionPost::count(), 1, BeatmapDiscussionPost::class);
148
149 (new Discussion($this->mapper, $beatmapset, $this->makeParams('mapper_note'), static::TEST_MESSAGE))->handle();
150 }
151
152 /**
153 * @dataProvider newMapperNoteByOtherUsersDataProvider
154 */
155 public function testNewMapperNoteByOtherUsers(?string $group, bool $expected)
156 {
157 $user = User::factory()->withGroup($group)->create()->markSessionVerified();
158 $beatmapset = $this->beatmapsetFactory()->create();
159
160 $change = $expected ? 1 : 0;
161 $this->expectCountChange(fn () => BeatmapDiscussion::count(), $change, BeatmapDiscussion::class);
162 $this->expectCountChange(fn () => BeatmapDiscussionPost::count(), $change, BeatmapDiscussionPost::class);
163
164 if (!$expected) {
165 $this->expectException(AuthorizationException::class);
166 }
167
168 (new Discussion($user, $beatmapset, $this->makeParams('mapper_note'), static::TEST_MESSAGE))->handle();
169 }
170
171 public function testNewMapperNoteNoteByGuestOnGuestBeatmap()
172 {
173 $user = User::factory()->create()->markSessionVerified();
174 $beatmapset = $this->beatmapsetFactory(['user_id' => $user])->create();
175 $beatmap = $beatmapset->beatmaps->first();
176
177 $this->expectCountChange(fn () => BeatmapDiscussion::count(), 1, BeatmapDiscussion::class);
178 $this->expectCountChange(fn () => BeatmapDiscussionPost::count(), 1, BeatmapDiscussionPost::class);
179
180 (new Discussion(
181 $user,
182 $beatmapset,
183 $this->makeParams('mapper_note', $beatmap),
184 static::TEST_MESSAGE
185 ))->handle();
186 }
187
188 public function testNewMapperNoteNoteByMapperOnGuestBeatmap()
189 {
190 $user = User::factory()->create()->markSessionVerified();
191 $beatmapset = $this->beatmapsetFactory(['user_id' => $user])->create();
192 $beatmap = $beatmapset->beatmaps->first();
193
194 $this->expectCountChange(fn () => BeatmapDiscussion::count(), 1, BeatmapDiscussion::class);
195 $this->expectCountChange(fn () => BeatmapDiscussionPost::count(), 1, BeatmapDiscussionPost::class);
196
197 (new Discussion(
198 $this->mapper,
199 $beatmapset,
200 $this->makeParams('mapper_note', $beatmap),
201 static::TEST_MESSAGE
202 ))->handle();
203 }
204
205 //endregion
206
207 //region Reporting problem on a beatmap
208
209 /**
210 * @dataProvider problemOnQualifiedBeatmapsetDataProvider
211 */
212 public function testProblemOnQualifiedBeatmapset(string $state, string $assertMethod)
213 {
214 $user = User::factory()->create()->markSessionVerified();
215
216 $beatmapset = $this->beatmapsetFactory()
217 ->$state()
218 ->create();
219
220 User::factory()->create()->notificationOptions()->create([
221 'name' => Notification::BEATMAPSET_DISCUSSION_QUALIFIED_PROBLEM,
222 'details' => ['modes' => array_keys(Beatmap::MODES)],
223 ]);
224
225 (new Discussion($user, $beatmapset, $this->makeParams('problem'), static::TEST_MESSAGE))->handle();
226
227 $assertMethod(NewPrivateNotificationEvent::class);
228 }
229
230 public function testSecondProblemOnQualifiedBeatmapset()
231 {
232 // TODO: add test for hasPriorOpenProblems?
233
234 $user = User::factory()->create()->markSessionVerified();
235
236 $beatmapset = $this->beatmapsetFactory()
237 ->qualified()
238 ->has(BeatmapDiscussion::factory()->general()->problem()->state([
239 'user_id' => $user,
240 ]))
241 ->create();
242
243 User::factory()->create()->notificationOptions()->create([
244 'name' => Notification::BEATMAPSET_DISCUSSION_QUALIFIED_PROBLEM,
245 'details' => ['modes' => array_keys(Beatmap::MODES)],
246 ]);
247
248 (new Discussion($user, $beatmapset, $this->makeParams('problem'), static::TEST_MESSAGE))->handle();
249
250 Event::assertNotDispatched(NewPrivateNotificationEvent::class);
251 }
252
253 /**
254 * @dataProvider problemOnQualifiedBeatmapsetModesNotificationDataProvider
255 *
256 * @return void
257 */
258 public function testProblemOnQualifiedBeatmapsetModesNotification(string $mode, array $notificationModes, bool $expectsNotification)
259 {
260 $user = User::factory()->create()->markSessionVerified();
261
262 $beatmapset = $this->beatmapsetFactory(['playmode' => Beatmap::MODES[$mode]])
263 ->qualified()
264 ->create();
265
266 $watcher = User::factory()->create();
267 $watcher->notificationOptions()->create([
268 'name' => Notification::BEATMAPSET_DISCUSSION_QUALIFIED_PROBLEM,
269 'details' => ['modes' => $notificationModes],
270 ]);
271
272 // TODO: only test the handleProblemDiscussion() part?
273 (new Discussion($user, $beatmapset, $this->makeParams('problem'), static::TEST_MESSAGE))->handle();
274
275 if ($expectsNotification) {
276 Event::assertDispatched(
277 NewPrivateNotificationEvent::class,
278 fn (NewPrivateNotificationEvent $event) => $this->inReceivers($watcher, $event)
279 );
280 } else {
281 Event::assertNotDispatched(NewPrivateNotificationEvent::class);
282 }
283 }
284
285 //endregion
286
287 public static function minPlaysVerificationDataProvider()
288 {
289 return [
290 [fn () => $GLOBALS['cfg']['osu']['user']['min_plays_for_posting'] - 1, false, false],
291 [fn () => $GLOBALS['cfg']['osu']['user']['min_plays_for_posting'] - 1, true, true],
292 [fn () => null, false, true],
293 [fn () => null, true, true],
294 ];
295 }
296
297 public static function problemOnQualifiedBeatmapsetDataProvider()
298 {
299 return [
300 ['pending', 'Event::assertNotDispatched'],
301 ['qualified', 'Event::assertDispatched'],
302 ];
303 }
304
305 public static function problemOnQualifiedBeatmapsetModesNotificationDataProvider()
306 {
307 return [
308 'with matching notification mode' => ['osu', ['osu'], true],
309 'wihtout matching notification mode' => ['osu', ['taiko'], false],
310 ];
311 }
312
313 public static function newDiscussionQueuesJobsDataProvider()
314 {
315 return [
316 [
317 'qualified',
318 'bng',
319 [BeatmapsetDisqualify::class, BeatmapsetDiscussionPostNew::class],
320 [BeatmapsetDiscussionQualifiedProblem::class, BeatmapsetResetNominations::class],
321 ],
322 [
323 'qualified',
324 'bng_limited',
325 [BeatmapsetDiscussionPostNew::class, BeatmapsetDiscussionQualifiedProblem::class],
326 [BeatmapsetDisqualify::class, BeatmapsetResetNominations::class],
327 ],
328 [
329 'qualified',
330 null,
331 [BeatmapsetDiscussionPostNew::class, BeatmapsetDiscussionQualifiedProblem::class],
332 [BeatmapsetDisqualify::class, BeatmapsetResetNominations::class],
333 ],
334 [
335 'pending',
336 'bng',
337 [BeatmapsetResetNominations::class, BeatmapsetDiscussionPostNew::class],
338 [BeatmapsetDiscussionQualifiedProblem::class, BeatmapsetDisqualify::class],
339 ],
340 [
341 'pending',
342 'bng_limited',
343 [BeatmapsetResetNominations::class, BeatmapsetDiscussionPostNew::class],
344 [BeatmapsetDiscussionQualifiedProblem::class, BeatmapsetDisqualify::class],
345 ],
346 [
347 'pending',
348 null,
349 [BeatmapsetDiscussionPostNew::class],
350 [BeatmapsetDiscussionQualifiedProblem::class, BeatmapsetDisqualify::class, BeatmapsetResetNominations::class],
351 ],
352 ];
353 }
354
355 public static function newMapperNoteByOtherUsersDataProvider()
356 {
357 return [
358 ['bng', true],
359 ['bng_limited', true],
360 ['gmt', true],
361 ['nat', true],
362 [null, false],
363 ];
364 }
365
366 public static function shouldDisqualifyOrResetNominationsDataProvider()
367 {
368 return [
369 ['pending', 'bng', 'problem', true],
370 ['pending', 'bng', 'suggestion', false],
371 ['pending', 'bng_limited', 'problem', true],
372 ['pending', 'bng_limited', 'suggestion', false],
373 ['pending', null, 'problem', false],
374 ['pending', null, 'suggestion', false],
375
376 // similar to pending except bng_limited cannot disqualify
377 ['qualified', 'bng', 'problem', true],
378 ['qualified', 'bng', 'suggestion', false],
379 ['qualified', 'bng_limited', 'problem', false], // cannot disqualify
380 ['qualified', 'bng_limited', 'suggestion', false],
381 ['qualified', null, 'problem', false],
382 ['qualified', null, 'suggestion', false],
383 ];
384 }
385
386 public static function userGroupsDataProvider()
387 {
388 return [
389 ['admin'],
390 ['bng'],
391 ['bng_limited'],
392 ['gmt'],
393 ['nat'],
394 [null],
395 ];
396 }
397
398 protected function setUp(): void
399 {
400 parent::setUp();
401
402 Event::fake();
403
404 config_set('osu.beatmapset.required_nominations', 1);
405
406 $this->mapper = User::factory()->create()->markSessionVerified();
407 }
408
409 private function beatmapsetFactory(array $beatmapState = [])
410 {
411 $factory = Beatmapset::factory()
412 ->owner($this->mapper)
413 ->has(Beatmap::factory()->state(array_merge([
414 'user_id' => $this->mapper,
415 ], $beatmapState)));
416
417 return $factory;
418 }
419
420 private function makeParams(string $messageType, ?Beatmap $beatmap = null)
421 {
422 return [
423 'beatmap_id' => $beatmap !== null ? $beatmap->getKey() : null,
424 'message_type' => $messageType,
425 ];
426 }
427}