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\AuthorizationException;
11use App\Exceptions\InvariantException;
12use App\Exceptions\UnsupportedNominationException;
13use App\Jobs\CheckBeatmapsetCovers;
14use App\Jobs\Notifications\BeatmapsetDisqualify;
15use App\Jobs\Notifications\BeatmapsetResetNominations;
16use App\Libraries\Beatmapset\ChangeBeatmapOwners;
17use App\Libraries\Beatmapset\NominateBeatmapset;
18use App\Models\Beatmap;
19use App\Models\BeatmapDiscussion;
20use App\Models\Beatmapset;
21use App\Models\BeatmapsetNomination;
22use App\Models\Genre;
23use App\Models\Language;
24use App\Models\Notification;
25use App\Models\User;
26use App\Models\UserNotification;
27use Bus;
28use Database\Factories\BeatmapsetFactory;
29use Queue;
30use Tests\TestCase;
31
32class BeatmapsetTest extends TestCase
33{
34 public static function disqualifyOrResetNominationsDataProvider()
35 {
36 return [
37 ['pending', BeatmapsetResetNominations::class],
38 ['qualified', BeatmapsetDisqualify::class],
39 ];
40 }
41
42 public static function dataProviderForTestRank(): array
43 {
44 return [
45 ['pending', false],
46 ['qualified', true],
47 ];
48 }
49
50 public static function mainRulesetHybridBeatmapsetSameCountDataProvider()
51 {
52 return [
53 [[
54 [['osu', 'taiko'], null],
55 [['taiko'], 'taiko'],
56 ]],
57 [[
58 [['osu', 'taiko'], null],
59 [['taiko', 'fruits'], 'taiko'],
60 ]],
61 [[
62 [['osu', 'taiko'], null],
63 [['fruits', 'mania'], null],
64 ]],
65 [[
66 [['fruits'], 'fruits'],
67 [['osu'], 'fruits'],
68 ]],
69 ];
70 }
71
72 public static function mainRulesetHybridBeatmapsetWithGuestMappersSameCountDataProvider()
73 {
74 return [
75 [[
76 [['osu', 'taiko'], null],
77 [['taiko'], null, 'too_many_non_main_ruleset'],
78 ]],
79 [[
80 [['osu', 'taiko'], null],
81 [['taiko', 'fruits'], null, 'too_many_non_main_ruleset'],
82 ]],
83 [[
84 [['osu', 'taiko'], null],
85 [['fruits', 'mania'], null],
86 [['fruits'], 'fruits'],
87 ]],
88 [[
89 [['fruits'], 'fruits'],
90 [['mania'], 'fruits'],
91 ]],
92 ];
93 }
94
95 public static function nominateDataProvider()
96 {
97 return [
98 'bng nominate same ruleset' => ['bng', ['osu'], 'osu', true],
99 'bng nominate different ruleset' => ['bng', ['osu'], 'taiko', false],
100 'nat defaults to all rulesets' => ['nat', [], 'osu', true],
101 'nat nominate same ruleset' => ['nat', ['osu'], 'osu', true],
102 'nat nominate different ruleset' => ['nat', ['osu'], 'taiko', false],
103 ];
104 }
105
106 public static function qualifyingNominationsDataProvider(): array
107 {
108 // existing nominations, qualifying nomination, expected
109 return [
110 'Nomination requires at least one full nominator' => ['bng_limited', 'bng_limited', false],
111
112 // limited bngs can be the qualifying nomination
113 ['bng', 'bng_limited', true],
114 ['nat', 'bng_limited', true],
115
116 ['bng_limited', 'bng', true],
117 ['bng_limited', 'nat', true],
118 ];
119 }
120
121 public static function qualifyingNominationsHybridDataProvider(): array
122 {
123 // existing nominations, qualifying nomination, expected
124 return [
125 'Nomination requires at least one full nominator' => ['bng_limited', 'bng_limited', false],
126 'Limited BNs cannot nominate the hybrid mode #1' => ['bng', 'bng_limited', false],
127 'Limited BNs cannot nominate the hybrid mode #2' => ['nat', 'bng_limited', false],
128
129 ['bng_limited', 'bng', true],
130 ['bng_limited', 'nat', true],
131 ];
132 }
133
134 public static function rankWithOpenIssueDataProvider()
135 {
136 return [
137 ['problem'],
138 ['suggestion'],
139 ];
140 }
141
142 public function testInvalidStatePending()
143 {
144 $user = User::factory()->withGroup('nat')->create();
145 $beatmapset = $this->beatmapsetFactory()->qualified()->withBeatmaps()->create();
146
147 $this->expectException(InvariantException::class);
148 $this->expectExceptionMessage(osu_trans('beatmaps.nominations.incorrect_state'));
149
150 $beatmapset->fresh()->nominate($user, $beatmapset->playmodesStr());
151 }
152
153 public function testInvalidStateRequiredHype()
154 {
155 config_set('osu.beatmapset.required_hype', 2);
156
157 $user = User::factory()->withGroup('nat')->create();
158 $beatmapset = $this->beatmapsetFactory()->withBeatmaps()->withHypes(1)->create();
159
160 $this->expectException(InvariantException::class);
161 $this->expectExceptionMessage(osu_trans('beatmaps.nominations.not_enough_hype'));
162
163 $beatmapset->fresh()->nominate($user, $beatmapset->playmodesStr());
164 }
165
166 public function testInvalidStateUnresolvedIssues()
167 {
168 $user = User::factory()->withGroup('nat')->create();
169
170 $beatmapset = $this->beatmapsetFactory()
171 ->withBeatmaps()
172 ->has(BeatmapDiscussion::factory()->general()->problem())
173 ->create();
174
175 $this->expectException(InvariantException::class);
176 $this->expectExceptionMessage(osu_trans('beatmaps.nominations.unresolved_issues'));
177
178 $beatmapset->fresh()->nominate($user, $beatmapset->playmodesStr());
179 }
180
181 public function testLove()
182 {
183 $user = User::factory()->create();
184 $beatmapset = $this->beatmapsetFactory()->withBeatmaps()->create();
185 $otherUser = User::factory()->create();
186 $beatmapset->watches()->create(['user_id' => $otherUser->getKey()]);
187
188 $this->expectCountChange(fn () => $beatmapset->bssProcessQueues()->count(), 1);
189 $this->expectCountChange(fn () => Notification::count(), 1);
190 $this->expectCountChange(fn () => UserNotification::count(), 1);
191
192 $beatmapset = $beatmapset->fresh();
193 $beatmapset->love($user);
194
195 $beatmapset = $beatmapset->fresh();
196 $this->assertTrue($beatmapset->isLoved());
197 $this->assertSame('loved', $beatmapset->beatmaps()->first()->status());
198
199 Bus::assertDispatched(CheckBeatmapsetCovers::class);
200 }
201
202 public function testLoveBeatmapApprovedStates(): void
203 {
204 $user = User::factory()->create();
205 $beatmapset = $this->beatmapsetFactory()->withBeatmaps()->create();
206
207 $specifiedBeatmap = $beatmapset->beatmaps()->first();
208 $beatmapset->beatmaps()->saveMany([
209 $graveyardBeatmap = Beatmap::factory()->make(['approved' => Beatmapset::STATES['graveyard']]),
210 $pendingBeatmap = Beatmap::factory()->make(['approved' => Beatmapset::STATES['pending']]),
211 $wipBeatmap = Beatmap::factory()->make(['approved' => Beatmapset::STATES['wip']]),
212 $rankedBeatmap = Beatmap::factory()->make(['approved' => Beatmapset::STATES['ranked']]),
213 ]);
214
215 $beatmapset->fresh()->love($user, [$specifiedBeatmap->getKey()]);
216
217 $this->assertTrue($beatmapset->fresh()->isLoved());
218 $this->assertSame('loved', $specifiedBeatmap->fresh()->status());
219 $this->assertSame('graveyard', $graveyardBeatmap->fresh()->status());
220 $this->assertSame('graveyard', $pendingBeatmap->fresh()->status());
221 $this->assertSame('graveyard', $wipBeatmap->fresh()->status());
222 $this->assertSame('ranked', $rankedBeatmap->fresh()->status());
223
224 Bus::assertDispatched(CheckBeatmapsetCovers::class);
225 }
226
227 public function testMainRulesetSingleBeatmap()
228 {
229 $beatmapset = $this->beatmapsetFactory()->withBeatmaps('taiko')->create();
230
231 $this->assertSame('taiko', $beatmapset->mainRuleset());
232 }
233
234 public function testMainRulesetHybridBeatmapset()
235 {
236 $beatmapset = $this->beatmapsetFactory()
237 ->withBeatmaps('osu', 1)
238 ->withBeatmaps('taiko', 2)
239 ->withBeatmaps('fruits', 3)
240 ->withBeatmaps('mania', 1)
241 ->create();
242
243 $this->assertSame('fruits', $beatmapset->mainRuleset());
244 }
245
246 /**
247 * @dataProvider mainRulesetHybridBeatmapsetSameCountDataProvider
248 */
249 public function testMainRulesetHybridBeatmapsetSameCount(array $steps)
250 {
251 $userFactory = User::factory()->withGroup('bng', ['osu', 'taiko', 'fruits', 'mania']);
252
253 $beatmapset = $this->beatmapsetFactory()
254 ->withBeatmaps('osu')
255 ->withBeatmaps('taiko')
256 ->withBeatmaps('fruits')
257 ->withBeatmaps('mania')
258 ->create();
259
260 $this->assertSame(null, $beatmapset->mainRuleset());
261
262 foreach ($steps as $step) {
263 $nominatedRulesets = $step[0];
264 $expectedMainRuleset = $step[1];
265
266 $beatmapset->fresh()->nominate($userFactory->create(), $nominatedRulesets);
267
268 $this->assertSame($expectedMainRuleset, $beatmapset->fresh()->mainRuleset());
269 }
270 }
271
272 public function testMainRulesetHybridBeatmapsetWithGuestMappers()
273 {
274 $guest = User::factory()->create();
275
276 $beatmapset = $this->beatmapsetFactory()
277 ->withBeatmaps('osu', 1, $guest)
278 ->withBeatmaps('taiko', 3, $guest)
279 ->withBeatmaps('taiko', 1)
280 ->withBeatmaps('fruits', 2, $guest)
281 ->withBeatmaps('fruits', 2)
282 ->withBeatmaps('mania', 1)
283 ->create();
284
285 $this->assertSame('fruits', $beatmapset->mainRuleset());
286 }
287
288 /**
289 * @dataProvider mainRulesetHybridBeatmapsetWithGuestMappersSameCountDataProvider
290 */
291 public function testMainRulesetHybridBeatmapsetWithGuestMappersSameCount(array $steps)
292 {
293 $userFactory = User::factory()->withGroup('bng', ['osu', 'taiko', 'fruits', 'mania']);
294 $guest = User::factory()->create();
295
296 // possible main ruleset will be catch or mania.
297 $beatmapset = $this->beatmapsetFactory()
298 ->withBeatmaps('osu', 1)
299 ->withBeatmaps('taiko', 1, $guest)
300 ->withBeatmaps('taiko', 1)
301 ->withBeatmaps('fruits', 2, $guest)
302 ->withBeatmaps('fruits', 2)
303 ->withBeatmaps('mania', 2, $guest)
304 ->withBeatmaps('mania', 2)
305 ->create();
306
307 $this->assertSame(null, $beatmapset->mainRuleset());
308
309 foreach ($steps as $step) {
310 $nominatedRulesets = $step[0];
311 $expectedMainRuleset = $step[1];
312 $expectedErrorMessage = $step[2] ?? null;
313
314 if ($expectedErrorMessage !== null) {
315 $this->expectException(InvariantException::class);
316 $this->expectExceptionMessage(osu_trans("beatmapsets.nominate.{$expectedErrorMessage}"));
317 }
318
319 $beatmapset->fresh()->nominate($userFactory->create(), $nominatedRulesets);
320
321 $this->assertSame($expectedMainRuleset, $beatmapset->fresh()->mainRuleset());
322 }
323 }
324
325 public function testNominationsByType()
326 {
327 $beatmapset = $this->beatmapsetFactory()
328 ->withBeatmaps('osu')
329 ->withBeatmaps('taiko')
330 ->withBeatmaps('fruits')
331 ->withBeatmaps('mania')
332 ->create();
333
334 $userFactory = User::factory()->withGroup('bng', ['osu', 'taiko', 'fruits', 'mania']);
335 $countFilter = fn ($array, $mode) => array_filter($array, fn ($item) => $item === $mode);
336
337 $beatmapset->fresh()->nominate($userFactory->create(), ['osu']);
338 $this->assertCount(1, $beatmapset->nominationsByType()['full']);
339 $this->assertCount(1, $countFilter($beatmapset->nominationsByType()['full'], 'osu'));
340
341 $beatmapset->fresh()->nominate($userFactory->create(), ['taiko']);
342 $this->assertCount(2, $beatmapset->nominationsByType()['full']);
343 $this->assertCount(1, $countFilter($beatmapset->nominationsByType()['full'], 'taiko'));
344
345 $beatmapset->fresh()->nominate($userFactory->create(), ['fruits']);
346 $this->assertCount(3, $beatmapset->nominationsByType()['full']);
347 $this->assertCount(1, $countFilter($beatmapset->nominationsByType()['full'], 'fruits'));
348
349 $beatmapset->fresh()->nominate($userFactory->create(), ['mania']);
350 $this->assertCount(4, $beatmapset->nominationsByType()['full']);
351 $this->assertCount(1, $countFilter($beatmapset->nominationsByType()['full'], 'mania'));
352
353 $this->assertCount(0, $beatmapset->nominationsByType()['limited']);
354
355 $beatmapset->fresh()->nominate(
356 User::factory()->withGroup('bng_limited', ['osu'])->create(),
357 ['osu']
358 );
359
360 $beatmapset = $beatmapset->fresh();
361 $this->assertCount(4, $beatmapset->nominationsByType()['full']);
362 $this->assertCount(1, $beatmapset->nominationsByType()['limited']);
363 $this->assertCount(1, $countFilter($beatmapset->nominationsByType()['limited'], 'osu'));
364 }
365
366 //region single-playmode beatmap sets
367
368 /**
369 * @dataProvider nominateDataProvider
370 */
371 public function testNominate(string $group, array $groupPlaymodes, string $ruleset, bool $success)
372 {
373 $beatmapset = $this->beatmapsetFactory()->withBeatmaps($ruleset)->create();
374 $user = User::factory()->withGroup($group, $groupPlaymodes)->create();
375 $otherUser = User::factory()->create();
376 $beatmapset->watches()->create(['user_id' => $otherUser->getKey()]);
377 $nominatedModes = [$ruleset];
378
379 $this->assertNotificationChanges($success);
380 $this->assertNominationChanges($beatmapset, $success);
381
382 $this->expectExceptionCallable(
383 fn () => $beatmapset->fresh()->nominate($user, $nominatedModes),
384 $success ? null : InvariantException::class
385 );
386
387 $beatmapset = $beatmapset->fresh();
388
389 if ($success) {
390 $this->assertSame($nominatedModes, $beatmapset->beatmapsetNominations()->current()->first()->modes);
391 }
392
393 // Assertions that nomination doesn't qualify
394 $this->assertTrue($beatmapset->fresh()->isPending());
395 Bus::assertNotDispatched(CheckBeatmapsetCovers::class);
396 }
397
398 public function testNominateMainRulesetInvariant()
399 {
400 $beatmapset = $this->beatmapsetFactory()
401 ->withBeatmaps('osu')
402 ->withBeatmaps('taiko')
403 ->withNominations(['osu', 'taiko'], 1)
404 ->create();
405
406 $user = User::factory()->withGroup('bng', ['osu', 'taiko'])->create();
407
408 $this->assertNominationChanges($beatmapset, false);
409
410 $this->expectExceptionCallable(
411 fn () => $beatmapset->fresh()->nominate($user, ['osu', 'taiko']),
412 InvariantException::class,
413 osu_trans('beatmapsets.nominate.too_many_non_main_ruleset')
414 );
415
416 $this->assertTrue($beatmapset->fresh()->isPending());
417 Bus::assertNotDispatched(CheckBeatmapsetCovers::class);
418 }
419
420 public function testQualify()
421 {
422 $beatmapset = $this->beatmapsetFactory()->withBeatmaps()->create();
423 $user = User::factory()->withGroup('bng', $beatmapset->playmodesStr())->create();
424 $otherUser = User::factory()->create();
425 $beatmapset->watches()->create(['user_id' => $otherUser->getKey()]);
426
427 $this->expectCountChange(fn () => $beatmapset->bssProcessQueues()->count(), 1);
428 $this->assertNotificationChanges();
429
430 $beatmapset->fresh()->qualify($user);
431
432 $this->assertTrue($beatmapset->fresh()->isQualified());
433
434 Bus::assertDispatched(CheckBeatmapsetCovers::class);
435 }
436
437 public function testNominateWithDefaultMetadata()
438 {
439 $beatmapset = $this->beatmapsetFactory()->withBeatmaps()->state([
440 'genre_id' => Genre::UNSPECIFIED,
441 'language_id' => Language::UNSPECIFIED,
442 ])->create();
443 $nominator = User::factory()->withGroup('bng', $beatmapset->playmodesStr())->create();
444
445 $this->expectException(AuthorizationException::class);
446 $this->expectExceptionMessage(osu_trans('authorization.beatmap_discussion.nominate.set_metadata'));
447 priv_check_user($nominator, 'BeatmapsetNominate', $beatmapset)->ensureCan();
448 }
449
450 /**
451 * @dataProvider qualifyingNominationsDataProvider
452 */
453 public function testQualifyingNominations(string $initialGroup, string $qualifyingGroup, bool $success)
454 {
455 $ruleset = array_rand(Beatmap::MODES);
456 $beatmapset = $this->beatmapsetFactory()->withBeatmaps($ruleset)->create();
457 $this->fillNominationsExceptLastForMainRuleset($beatmapset, $initialGroup);
458
459 $nominator = User::factory()->withGroup($qualifyingGroup, [$ruleset])->create();
460
461 priv_check_user($nominator, 'BeatmapsetNominate', $beatmapset)->ensureCan();
462
463 $this->expectCountChange(fn () => $beatmapset->bssProcessQueues()->count(), $success ? 1 : 0);
464
465 $this->expectExceptionCallable(
466 fn () => $beatmapset->fresh()->nominate($nominator, [$ruleset]),
467 $success ? null : InvariantException::class
468 );
469
470 $this->assertSame($success, $beatmapset->fresh()->isQualified());
471
472 if ($success) {
473 Bus::assertDispatched(CheckBeatmapsetCovers::class);
474 } else {
475 Bus::assertNotDispatched(CheckBeatmapsetCovers::class);
476 }
477 }
478
479 /**
480 * @dataProvider dataProviderForTestRank
481 */
482 public function testRank(string $state, bool $success): void
483 {
484 $beatmapset = $this->beatmapsetFactory()->withBeatmaps()->$state()->create();
485 $otherUser = User::factory()->create();
486 $beatmap = $beatmapset->beatmaps()->first();
487 $beatmap->scoresBest()->create([
488 'user_id' => $otherUser->getKey(),
489 ]);
490
491 $beatmapset->watches()->create(['user_id' => $otherUser->getKey()]);
492
493 $this->expectCountChange(fn () => $beatmapset->bssProcessQueues()->count(), $success ? 1 : 0);
494 $this->expectCountChange(fn () => $beatmap->scoresBest()->count(), $success ? -1 : 0);
495 $this->assertNotificationChanges($success);
496
497 $res = $beatmapset->rank();
498
499 $this->assertSame($success, $res);
500 $this->assertSame($success, $beatmapset->fresh()->isRanked());
501
502 if ($success) {
503 Bus::assertDispatched(CheckBeatmapsetCovers::class);
504 } else {
505 Bus::assertNotDispatched(CheckBeatmapsetCovers::class);
506 }
507 }
508
509 /**
510 * @dataProvider rankWithOpenIssueDataProvider
511 */
512 public function testRankWithOpenIssue(string $type): void
513 {
514 $beatmapset = $this->beatmapsetFactory()->withBeatmaps()
515 ->qualified()
516 ->has(BeatmapDiscussion::factory()->general()->messageType($type))->create();
517
518 $this->expectCountChange(fn () => $beatmapset->bssProcessQueues()->count(), 0);
519 $this->assertNotificationChanges(false);
520
521 $this->assertFalse($beatmapset->rank());
522 $this->assertFalse($beatmapset->fresh()->isRanked());
523
524 Bus::assertNotDispatched(CheckBeatmapsetCovers::class);
525 }
526
527 public function testGlobalScopeActive()
528 {
529 $beatmapset = Beatmapset::factory()->inactive()->create();
530 $id = $beatmapset->getKey();
531
532 $this->assertNull(Beatmapset::find($id)); // global scope
533 $this->assertNull(Beatmapset::withoutGlobalScopes()->active()->find($id)); // scope still applies after removing global scope
534 $this->assertTrue($beatmapset->is(Beatmapset::withoutGlobalScopes()->find($id))); // no global scopes
535 }
536
537 public function testGlobalScopeSoftDelete()
538 {
539 $beatmapset = Beatmapset::factory()->inactive()->deleted()->create();
540 $id = $beatmapset->getKey();
541
542 $this->assertNull(Beatmapset::withTrashed()->find($id));
543 }
544 //endregion
545
546 //region multi-playmode beatmap sets (aka hybrid)
547 public function testHybridLegacyNominate(): void
548 {
549 $user = User::factory()->withGroup('bng', ['osu'])->create();
550 $beatmapset = $this->createHybridBeatmapset('taiko');
551
552 // create legacy nomination event to enable legacy nomination mode
553 BeatmapsetNomination::factory()->create([
554 'beatmapset_id' => $beatmapset,
555 'user_id' => User::factory()->withGroup('bng', $beatmapset->playmodesStr()),
556 ]);
557
558 $beatmapset->refreshCache();
559
560 $otherUser = User::factory()->create();
561 $beatmapset->watches()->create(['user_id' => $otherUser->getKey()]);
562
563 $this->assertNotificationChanges(false);
564 $this->assertNominationChanges($beatmapset, false);
565 $this->expectException(UnsupportedNominationException::class);
566
567 $beatmapset->fresh()->nominate($user);
568 }
569
570 public function testHybridLegacyQualify(): void
571 {
572 $beatmapset = $this->createHybridBeatmapset('taiko');
573
574 // create legacy nomination event to enable legacy nomination mode
575 BeatmapsetNomination::factory()->create([
576 'beatmapset_id' => $beatmapset,
577 'user_id' => User::factory()->withGroup('bng', $beatmapset->playmodesStr()),
578 ]);
579
580 $beatmapset->refreshCache();
581
582 $this->expectException(UnsupportedNominationException::class);
583 // fill with legacy nominations
584 $count = $GLOBALS['cfg']['osu']['beatmapset']['required_nominations'] * $beatmapset->playmodeCount() - $beatmapset->currentNominationCount() - 1;
585 for ($i = 0; $i < $count; $i++) {
586 $beatmapset->fresh()->nominate(User::factory()->withGroup('bng', ['osu'])->create());
587 }
588 }
589
590 public function testHybridNominateFullNominationRequired(): void
591 {
592 $user = User::factory()->withGroup('bng_limited', ['osu', 'taiko'])->create();
593 $beatmapset = $this->createHybridBeatmapset('taiko');
594
595 $this->assertNominationChanges($beatmapset, false);
596
597 $this->expectExceptionCallable(
598 fn () => $beatmapset->fresh()->nominate($user, ['osu']),
599 InvariantException::class,
600 osu_trans('beatmapsets.nominate.full_nomination_required')
601 );
602 }
603
604 public function testHybridNominateWithBngLimitedMultipleRulesets(): void
605 {
606 $user = User::factory()->withGroup('bng_limited', ['osu', 'taiko'])->create();
607 $beatmapset = $this->createHybridBeatmapset();
608 $otherUser = User::factory()->create();
609 $beatmapset->watches()->create(['user_id' => $otherUser->getKey()]);
610
611 $this->assertNotificationChanges(false);
612 $this->assertNominationChanges($beatmapset, false);
613
614 $this->expectExceptionCallable(
615 fn () => $beatmapset->fresh()->nominate($user, ['osu', 'taiko']),
616 InvariantException::class,
617 osu_trans('beatmapsets.nominate.bng_limited_too_many_rulesets')
618 );
619
620 $this->assertTrue($beatmapset->fresh()->isPending());
621 Bus::assertNotDispatched(CheckBeatmapsetCovers::class);
622 }
623
624 public function testHybridNominateWithNullPlaymode(): void
625 {
626 $user = User::factory()->withGroup('bng', ['osu'])->create();
627 $beatmapset = $this->createHybridBeatmapset('taiko');
628 $otherUser = User::factory()->create();
629 $beatmapset->watches()->create(['user_id' => $otherUser->getKey()]);
630
631 $this->assertNotificationChanges(false);
632 $this->assertNominationChanges($beatmapset, false);
633
634 $this->expectExceptionCallable(
635 fn () => $beatmapset->fresh()->nominate($user, []),
636 InvariantException::class,
637 osu_trans('beatmapsets.nominate.hybrid_requires_modes')
638 );
639
640 $this->assertTrue($beatmapset->fresh()->isPending());
641 Bus::assertNotDispatched(CheckBeatmapsetCovers::class);
642 }
643
644 public function testHybridNominateWithNoPlaymodePermission(): void
645 {
646 $user = User::factory()->withGroup('bng', ['osu'])->create();
647 $beatmapset = $this->createHybridBeatmapset('taiko');
648 $otherUser = User::factory()->create();
649 $beatmapset->watches()->create(['user_id' => $otherUser->getKey()]);
650
651 $this->assertNotificationChanges(false);
652 $this->assertNominationChanges($beatmapset, false);
653
654 $this->expectExceptionCallable(
655 fn () => $beatmapset->fresh()->nominate($user, ['taiko']),
656 InvariantException::class,
657 osu_trans('beatmapsets.nominate.incorrect_mode', ['mode' => 'taiko'])
658 );
659
660 $this->assertTrue($beatmapset->fresh()->isPending());
661 Bus::assertNotDispatched(CheckBeatmapsetCovers::class);
662 }
663
664 public function testHybridNominateWithPlaymodePermissionSingleMode(): void
665 {
666 $user = User::factory()->withGroup('bng', ['osu'])->create();
667 $beatmapset = $this->createHybridBeatmapset('taiko');
668 $otherUser = User::factory()->create();
669 $beatmapset->watches()->create(['user_id' => $otherUser->getKey()]);
670
671 $this->assertNotificationChanges();
672 $this->assertNominationChanges($beatmapset);
673
674 $beatmapset->fresh()->nominate($user, ['osu']);
675
676 $this->assertTrue($beatmapset->fresh()->isPending());
677 Bus::assertNotDispatched(CheckBeatmapsetCovers::class);
678 }
679
680 public function testHybridNominateWithPlaymodePermissionTooMany(): void
681 {
682 $user = User::factory()->withGroup('bng', ['osu'])->create();
683 $beatmapset = $this->createHybridBeatmapset('taiko');
684
685 $this->fillNominationsExceptLastForMainRuleset($beatmapset, 'bng');
686
687 $beatmapset->fresh()->nominate(
688 User::factory()->withGroup('bng', ['osu'])->create(),
689 ['osu']
690 );
691
692 $this->assertNotificationChanges(false);
693 $this->assertNominationChanges($beatmapset, false);
694
695 $this->expectExceptionCallable(
696 fn () => $beatmapset->fresh()->nominate($user, ['osu']),
697 InvariantException::class,
698 osu_trans('beatmapsets.nominate.too_many_non_main_ruleset')
699 );
700
701 $this->assertTrue($beatmapset->fresh()->isPending());
702 Bus::assertNotDispatched(CheckBeatmapsetCovers::class);
703 }
704
705 public function testHybridNominateWithPlaymodePermissionMultipleModes(): void
706 {
707 $user = User::factory()->withGroup('bng', ['osu', 'taiko'])->create();
708 $beatmapset = $this->createHybridBeatmapset('taiko');
709 $otherUser = User::factory()->create();
710 $beatmapset->watches()->create(['user_id' => $otherUser->getKey()]);
711
712 $this->assertNotificationChanges();
713 $this->assertNominationChanges($beatmapset, ['osu', 'taiko']);
714
715 $beatmapset->fresh()->nominate($user, ['osu', 'taiko']);
716
717 $this->assertTrue($beatmapset->fresh()->isPending());
718 Bus::assertNotDispatched(CheckBeatmapsetCovers::class);
719 }
720
721 public function testQualifyingNominationBngLimited()
722 {
723 $beatmapset = $this->createHybridBeatmapset();
724 $beatmapset->fresh()->nominate(User::factory()->withGroup('bng', ['osu', 'taiko'])->create(), ['osu', 'taiko']);
725 $nominator = User::factory()->withGroup('bng_limited', ['osu', 'taiko'])->create();
726
727 $this->expectCountChange(fn () => $beatmapset->bssProcessQueues()->count(), 1);
728
729 $beatmapset->fresh()->nominate($nominator, ['taiko']);
730
731 $this->assertTrue($beatmapset->fresh()->isQualified());
732 Bus::assertDispatched(CheckBeatmapsetCovers::class);
733 }
734
735 /**
736 * @dataProvider qualifyingNominationsHybridDataProvider
737 */
738 public function testQualifyingNominationsHybrid(string $initialGroup, string $qualifyingGroup, bool $success)
739 {
740 $nominator = User::factory()->withGroup($qualifyingGroup, ['osu', 'taiko'])->create();
741 $beatmapset = $this->createHybridBeatmapset('taiko');
742
743 $this->fillNominationsExceptLastForMainRuleset($beatmapset, $initialGroup);
744
745 priv_check_user($nominator, 'BeatmapsetNominate', $beatmapset)->ensureCan();
746
747 $this->expectCountChange(fn () => $beatmapset->bssProcessQueues()->count(), $success ? 1 : 0);
748
749 $this->expectExceptionCallable(
750 fn () => $beatmapset->fresh()->nominate($nominator, ['osu', 'taiko']),
751 $success ? null : InvariantException::class
752 );
753
754 $this->assertSame($success, $beatmapset->fresh()->isQualified());
755
756 if ($success) {
757 Bus::assertDispatched(CheckBeatmapsetCovers::class);
758 } else {
759 Bus::assertNotDispatched(CheckBeatmapsetCovers::class);
760 }
761 }
762
763 public function testQualifyingNominationSteps()
764 {
765 $bngFactory = User::factory()->withGroup('bng', ['taiko', 'fruits']);
766 $bngLimitedFactory = User::factory()->withGroup('bng_limited', ['taiko', 'fruits']);
767 $beatmapset = $this->createHybridBeatmapset(null, ['taiko', 'fruits']);
768
769 $beatmapset->fresh()->nominate($bngFactory->create(), ['fruits']);
770 $beatmapset->fresh()->nominate($bngLimitedFactory->create(), ['fruits']);
771
772 $this->assertTrue($beatmapset->fresh()->isPending());
773
774 $beatmapset->fresh()->nominate($bngFactory->create(), ['taiko']);
775
776 $this->assertTrue($beatmapset->fresh()->isQualified());
777 }
778
779 //endregion
780
781 //region disqualification
782
783 /**
784 * @dataProvider disqualifyOrResetNominationsDataProvider
785 */
786 public function testDisqualifyOrResetNominations(string $state, string $pushed)
787 {
788 $user = User::factory()->withGroup('bng')->create();
789 $beatmapset = Beatmapset::factory()->owner()->withDiscussion()->$state()->create();
790 $discussion = $beatmapset->beatmapDiscussions()->first(); // contents only needed for logging.
791
792 Queue::fake();
793
794 $beatmapset->disqualifyOrResetNominations($user, $discussion);
795
796 Queue::assertPushed($pushed);
797 }
798
799 //endregion
800
801 public function testChangingOwnerDoesNotQualify()
802 {
803 $guest = User::factory()->create();
804 $bngUser1 = User::factory()->withGroup('bng', ['osu', 'taiko'])->create();
805 $bngUser2 = User::factory()->withGroup('bng', ['osu', 'taiko'])->create();
806 $bngLimitedUser = User::factory()->withGroup('bng_limited', ['osu', 'taiko'])->create();
807 $natUser = User::factory()->withGroup('nat')->create();
808
809 // make taiko tha main ruleset
810 $beatmapset = $this->beatmapsetFactory()
811 ->withBeatmaps('taiko', 1)
812 ->withBeatmaps('taiko', 1)
813 ->withBeatmaps('osu', 1)
814 ->withBeatmaps('osu', 1, $guest)
815 ->create();
816
817 $this->assertSame('taiko', $beatmapset->mainRuleset());
818
819 // valid nomination for taiko and osu
820 $beatmapset->fresh()->nominate($bngLimitedUser, ['taiko']);
821 $beatmapset->fresh()->nominate($bngUser1, ['osu']);
822
823 // main ruleset should now be osu
824 (new ChangeBeatmapOwners(
825 $beatmapset->beatmaps()->where('playmode', 1)->first(),
826 [$guest->getKey()],
827 $natUser
828 ))->handle();
829
830 (new ChangeBeatmapOwners(
831 $beatmapset->beatmaps()->where('playmode', 0)->last(),
832 [$beatmapset->user_id],
833 $natUser
834 ))->handle();
835
836 $beatmapset->refresh();
837
838 $this->assertSame('osu', $beatmapset->mainRuleset());
839
840 // nomination should not trigger qualification
841 $this->expectExceptionCallable(
842 fn () => $beatmapset->fresh()->nominate($bngUser2, ['osu']),
843 InvariantException::class,
844 osu_trans('beatmapsets.nominate.invalid_limited_nomination')
845 );
846
847 $this->assertFalse($beatmapset->fresh()->isQualified());
848 Bus::assertNotDispatched(CheckBeatmapsetCovers::class);
849 }
850
851 protected function setUp(): void
852 {
853 parent::setUp();
854
855 Genre::factory()->create(['genre_id' => Genre::UNSPECIFIED]);
856 Language::factory()->create(['language_id' => Language::UNSPECIFIED]);
857
858 Bus::fake([CheckBeatmapsetCovers::class]);
859 }
860
861 private function beatmapsetFactory(): BeatmapsetFactory
862 {
863 return Beatmapset::factory()->owner()->pending();
864 }
865
866 private function createHybridBeatmapset(string $mainRuleset = null, array $rulesets = ['osu', 'taiko']): Beatmapset
867 {
868 $factory = $this->beatmapsetFactory();
869
870 foreach ($rulesets as $ruleset) {
871 $factory = $factory->withBeatmaps($ruleset, $mainRuleset === $ruleset ? 2 : 1);
872 }
873
874 return $factory->create();
875 }
876
877 private function fillNominationsExceptLastForMainRuleset(Beatmapset $beatmapset, string $group): void
878 {
879 $ruleset = $beatmapset->mainRuleset();
880 if ($ruleset === null) {
881 throw new \Exception('Cannot fill nominations without main ruleset.');
882 }
883
884 $count = NominateBeatmapset::requiredNominationsConfig()['main_ruleset'] - 1;
885 for ($i = 0; $i < $count; $i++) {
886 $beatmapset->nominate(User::factory()->withGroup($group, [$ruleset])->create(), [$ruleset]);
887 }
888 }
889
890 private function assertNominationChanges(Beatmapset $beatmapset, bool|array $success = true)
891 {
892 $count = is_array($success)
893 ? count($success)
894 : ($success ? 1 : 0);
895
896 $this->expectCountChange(fn () => $beatmapset->fresh()->nominations, $count, 'nominations');
897 $this->expectCountChange(fn () => $beatmapset->fresh()->beatmapsetNominations()->current()->count(), $success ? 1 : 0, 'nominations count');
898 }
899
900 private function assertNotificationChanges(bool $success = true)
901 {
902 $this->expectCountChange(fn () => Notification::count(), $success ? 1 : 0, 'Notification count');
903 $this->expectCountChange(fn () => UserNotification::count(), $success ? 1 : 0, 'UserNotification count');
904 }
905}