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\Controllers\Multiplayer;
7
8use App\Models\Beatmap;
9use App\Models\Beatmapset;
10use App\Models\Chat\UserChannel;
11use App\Models\Multiplayer\PlaylistItem;
12use App\Models\Multiplayer\PlaylistItemUserHighScore;
13use App\Models\Multiplayer\Room;
14use App\Models\Multiplayer\ScoreLink;
15use App\Models\Multiplayer\UserScoreAggregate;
16use App\Models\OAuth\Token;
17use App\Models\User;
18use Illuminate\Support\Arr;
19use Tests\TestCase;
20
21class RoomsControllerTest extends TestCase
22{
23 public function testIndex()
24 {
25 $room = Room::factory()->create();
26 $user = User::factory()->create();
27
28 $this->actAsScopedUser($user, ['*']);
29
30 $this->json('GET', route('api.rooms.index'))->assertSuccessful();
31 }
32
33 public function testShow()
34 {
35 $room = Room::factory()->create();
36 $user = User::factory()->create();
37 $playlistItem = PlaylistItem::factory()->create(['room_id' => $room]);
38 $scoreLink = ScoreLink
39 ::factory()
40 ->state([
41 'playlist_item_id' => $playlistItem,
42 'user_id' => $user,
43 ])->completed([], ['passed' => true, 'total_score' => 20])
44 ->create();
45 PlaylistItemUserHighScore::new($scoreLink->user_id, $scoreLink->playlist_item_id)->update(['attempts' => 1]);
46 UserScoreAggregate::lookupOrDefault($scoreLink->user, $scoreLink->playlistItem->room)->recalculate();
47
48 $this->actAsScopedUser($user, ['*']);
49
50 $this
51 ->json('GET', route('api.rooms.show', $room))
52 ->assertSuccessful()
53 ->assertJsonPath('current_user_score.playlist_item_attempts.0.attempts', 1)
54 ->assertJsonPath('current_user_score.playlist_item_attempts.0.id', $playlistItem->getKey());
55 }
56
57 public function testStore()
58 {
59 $token = Token::factory()->create(['scopes' => ['*']]);
60
61 $roomsCountInitial = Room::count();
62 $playlistItemsCountInitial = PlaylistItem::count();
63
64 $this
65 ->actingWithToken($token)
66 ->post(route('api.rooms.store'), array_merge(
67 $this->createBasicStoreParams(),
68 ['ends_at' => now()->addHours()],
69 ))->assertSuccessful();
70
71 $this->assertSame($roomsCountInitial + 1, Room::count());
72 $this->assertSame($playlistItemsCountInitial + 1, PlaylistItem::count());
73 }
74
75 /**
76 * @dataProvider dataProviderForTestStoreWithInvalidPlayableMods
77 */
78 public function testStoreWithInvalidPlayableMods(string $type, string $modType): void
79 {
80 $token = Token::factory()->create(['scopes' => ['*']]);
81
82 $this->expectCountChange(fn () => Room::count(), 0);
83 $this->expectCountChange(fn () => PlaylistItem::count(), 0);
84
85 $params = array_merge($this->createBasicStoreParams(), [
86 'ends_at' => now()->addHours(),
87 'type' => $type,
88 ]);
89
90 $params['playlist'][0]['allowed_mods'] = [];
91 $params['playlist'][0]['required_mods'] = [];
92 $params['playlist'][0]["{$modType}_mods"][] = ['acronym' => 'AT', 'settings' => []];
93
94 $response = $this
95 ->actingWithToken($token)
96 ->post(route('api.rooms.store'), $params)
97 ->assertStatus(422);
98
99 $responseJson = json_decode($response->getContent(), true);
100 $this->assertSame("mod cannot be set as {$modType}: AT", $responseJson['error']);
101 }
102
103 /**
104 * @dataProvider dataProviderForTestStoreWithInvalidRealtimeAllowedMods
105 */
106 public function testStoreWithInvalidRealtimeAllowedMods(string $type, bool $ok): void
107 {
108 $token = Token::factory()->create(['scopes' => ['*']]);
109
110 $this->expectCountChange(fn () => Room::count(), $ok ? 1 : 0);
111 $this->expectCountChange(fn () => PlaylistItem::count(), $ok ? 1 : 0);
112
113 $params = array_merge($this->createBasicStoreParams(), [
114 'ends_at' => now()->addHours(),
115 'type' => $type,
116 ]);
117 $params['playlist'][0]['required_mods'] = [];
118 $params['playlist'][0]['allowed_mods'] = [['acronym' => 'DT', 'settings' => []]];
119
120 $response = $this
121 ->actingWithToken($token)
122 ->post(route('api.rooms.store'), $params)
123 ->assertStatus($ok ? 200 : 422);
124
125 if (!$ok) {
126 $response->assertJson(['error' => 'mod cannot be set as allowed: DT']);
127 }
128 }
129
130 /**
131 * @dataProvider dataProviderForTestStoreWithInvalidRealtimeMods
132 */
133 public function testStoreWithInvalidRealtimeMods(string $type, bool $ok): void
134 {
135 $token = Token::factory()->create(['scopes' => ['*']]);
136
137 $this->expectCountChange(fn () => Room::count(), $ok ? 1 : 0);
138 $this->expectCountChange(fn () => PlaylistItem::count(), $ok ? 1 : 0);
139
140 // explicit ruleset required because AS isn't available for all modes
141 $params = array_merge($this->createBasicStoreParams('osu'), [
142 'ends_at' => now()->addHours(),
143 'type' => $type,
144 ]);
145 $params['playlist'][0]['allowed_mods'] = [];
146 $params['playlist'][0]['required_mods'] = [['acronym' => 'AS', 'settings' => []]];
147
148 $response = $this
149 ->actingWithToken($token)
150 ->post(route('api.rooms.store'), $params)
151 ->assertStatus($ok ? 200 : 422);
152
153 if (!$ok) {
154 $response->assertJson(['error' => 'mod cannot be set as required: AS']);
155 }
156 }
157
158 public function testStoreWithPassword()
159 {
160 $token = Token::factory()->create(['scopes' => ['*']]);
161
162 $response = $this
163 ->actingWithToken($token)
164 ->post(route('api.rooms.store'), array_merge(
165 $this->createBasicStoreParams(),
166 [
167 'ends_at' => now()->addHours(),
168 'password' => 'hunter2',
169 ],
170 ))->assertSuccessful();
171
172 $responseJson = json_decode($response->getContent(), true);
173 $this->assertNull(Room::find($responseJson['id'])->password);
174 }
175
176 public function testStoreRealtime()
177 {
178 $token = Token::factory()->create(['scopes' => ['*']]);
179 $type = array_rand_val(Room::REALTIME_TYPES);
180
181 $roomsCountInitial = Room::count();
182 $playlistItemsCountInitial = PlaylistItem::count();
183
184 $response = $this
185 ->actingWithToken($token)
186 ->post(route('api.rooms.store'), array_merge(
187 $this->createBasicStoreParams(),
188 [
189 'category' => 'realtime',
190 'type' => $type,
191 ],
192 ))->assertSuccessful();
193
194 $this->assertSame($roomsCountInitial + 1, Room::count());
195 $this->assertSame($playlistItemsCountInitial + 1, PlaylistItem::count());
196
197 $responseJson = json_decode($response->getContent(), true);
198 $room = Room::find($responseJson['id']);
199 $this->assertNotNull($room);
200 $this->assertTrue($room->isRealtime());
201 $this->assertSame($type, $room->type);
202 $this->assertSame($token->user->getKey(), $room->playlist()->first()->owner_id);
203 }
204
205 public function testStoreRealtimeByType()
206 {
207 $token = Token::factory()->create(['scopes' => ['*']]);
208 $type = array_rand_val(Room::REALTIME_TYPES);
209
210 $response = $this
211 ->actingWithToken($token)
212 ->post(route('api.rooms.store'), array_merge(
213 $this->createBasicStoreParams(),
214 ['type' => $type],
215 ))->assertSuccessful();
216
217 $responseJson = json_decode($response->getContent(), true);
218 $room = Room::find($responseJson['id']);
219 $this->assertNotNull($room);
220 $this->assertTrue($room->isRealtime());
221 $this->assertSame($type, $room->type);
222 }
223
224 public function testStoreRealtimeByQueueMode()
225 {
226 $token = Token::factory()->create(['scopes' => ['*']]);
227 $queueMode = array_rand_val(Room::REALTIME_QUEUE_MODES);
228
229 $response = $this
230 ->actingWithToken($token)
231 ->post(route('api.rooms.store'), array_merge(
232 $this->createBasicStoreParams(),
233 [
234 'type' => Room::REALTIME_DEFAULT_TYPE,
235 'queue_mode' => $queueMode,
236 ],
237 ))->assertSuccessful();
238
239 $responseJson = json_decode($response->getContent(), true);
240 $room = Room::find($responseJson['id']);
241 $this->assertNotNull($room);
242 $this->assertTrue($room->isRealtime());
243 $this->assertSame($queueMode, $room->queue_mode);
244 }
245
246 // TODO: remove once client sends type instead of category
247 public function testStoreRealtimeByCategory()
248 {
249 $token = Token::factory()->create(['scopes' => ['*']]);
250
251 $response = $this
252 ->actingWithToken($token)
253 ->post(route('api.rooms.store'), array_merge(
254 $this->createBasicStoreParams(),
255 ['category' => 'realtime'],
256 ))->assertSuccessful();
257
258 $responseJson = json_decode($response->getContent(), true);
259 $room = Room::find($responseJson['id']);
260 $this->assertNotNull($room);
261 $this->assertTrue($room->isRealtime());
262 $this->assertSame(Room::REALTIME_DEFAULT_TYPE, $room->type);
263 }
264
265 public function testStoreRealtimeWithPassword()
266 {
267 $token = Token::factory()->create(['scopes' => ['*']]);
268 $password = 'hunter2';
269
270 $response = $this
271 ->actingWithToken($token)
272 ->post(route('api.rooms.store'), array_merge(
273 $this->createBasicStoreParams(),
274 [
275 'password' => $password,
276 'type' => array_rand_val(Room::REALTIME_TYPES),
277 ],
278 ))->assertSuccessful();
279
280 $responseJson = json_decode($response->getContent(), true);
281 $this->assertSame($password, Room::find($responseJson['id'])->password);
282 }
283
284 public function testStoreRealtimeFailWithTwoPlaylistItems()
285 {
286 $token = Token::factory()->create(['scopes' => ['*']]);
287 $beatmapset = Beatmapset::factory()->create();
288 $beatmap = Beatmap::factory()->create(['beatmapset_id' => $beatmapset]);
289
290 $roomsCountInitial = Room::count();
291 $playlistItemsCountInitial = PlaylistItem::count();
292
293 $params = $this->createBasicStoreParams();
294 $params['playlist'][] = [
295 'beatmap_id' => $beatmap->getKey(),
296 'ruleset_id' => $beatmap->playmode,
297 ];
298 $params['type'] = array_rand_val(Room::REALTIME_TYPES);
299
300 $this
301 ->actingWithToken($token)
302 ->post(route('api.rooms.store'), $params)
303 ->assertStatus(422);
304
305 $this->assertSame($roomsCountInitial, Room::count());
306 $this->assertSame($playlistItemsCountInitial, PlaylistItem::count());
307 }
308
309 public function testStorePlaylistsAllowance()
310 {
311 $token = Token::factory()->create(['scopes' => ['*']]);
312 $user = $token->user;
313
314 for ($i = 0; $i < $user->maxMultiplayerRooms(); $i++) {
315 Room::factory()->create(['user_id' => $user]);
316 }
317
318 $roomsCountInitial = Room::count();
319 $playlistItemsCountInitial = PlaylistItem::count();
320
321 $this
322 ->actingWithToken($token)
323 ->post(route('api.rooms.store'), array_merge(
324 $this->createBasicStoreParams(),
325 ['ends_at' => now()->addHours()],
326 ))->assertStatus(422);
327
328 $this->assertSame($roomsCountInitial, Room::count());
329 $this->assertSame($playlistItemsCountInitial, PlaylistItem::count());
330 }
331
332 public function testStorePlaylistsAllowanceSeparateFromRealtime()
333 {
334 $token = Token::factory()->create(['scopes' => ['*']]);
335 $user = $token->user;
336 Room::factory()->create(['user_id' => $user, 'type' => Room::REALTIME_DEFAULT_TYPE]);
337
338 $roomsCountInitial = Room::count();
339 $playlistItemsCountInitial = PlaylistItem::count();
340
341 $this
342 ->actingWithToken($token)
343 ->post(route('api.rooms.store'), array_merge(
344 $this->createBasicStoreParams(),
345 ['ends_at' => now()->addHours()],
346 ))->assertSuccessful();
347
348 $this->assertSame($roomsCountInitial + 1, Room::count());
349 $this->assertSame($playlistItemsCountInitial + 1, PlaylistItem::count());
350 }
351
352 public function testStoreRealtimeAllowance()
353 {
354 $token = Token::factory()->create(['scopes' => ['*']]);
355
356 $user = $token->user;
357
358 Room::factory()->create(['user_id' => $user, 'type' => Room::REALTIME_DEFAULT_TYPE]);
359
360 $roomsCountInitial = Room::count();
361 $playlistItemsCountInitial = PlaylistItem::count();
362
363 $this
364 ->actingWithToken($token)
365 ->post(route('api.rooms.store'), array_merge(
366 $this->createBasicStoreParams(),
367 ['type' => array_rand_val(Room::REALTIME_TYPES)],
368 ))->assertStatus(422);
369
370 $this->assertSame($roomsCountInitial, Room::count());
371 $this->assertSame($playlistItemsCountInitial, PlaylistItem::count());
372 }
373
374 public function testStoreRealtimeAllowanceSeparateFromPlaylists()
375 {
376 $token = Token::factory()->create(['scopes' => ['*']]);
377
378 $user = $token->user;
379
380 for ($i = 0; $i < $user->maxMultiplayerRooms(); $i++) {
381 Room::factory()->create(['user_id' => $user]);
382 }
383
384 $roomsCountInitial = Room::count();
385 $playlistItemsCountInitial = PlaylistItem::count();
386
387 $this
388 ->actingWithToken($token)
389 ->post(route('api.rooms.store'), array_merge(
390 $this->createBasicStoreParams(),
391 ['type' => array_rand_val(Room::REALTIME_TYPES)],
392 ))->assertSuccessful();
393
394 $this->assertSame($roomsCountInitial + 1, Room::count());
395 $this->assertSame($playlistItemsCountInitial + 1, PlaylistItem::count());
396 }
397
398 public function testJoinWithPassword()
399 {
400 $token = Token::factory()->create(['scopes' => ['*']]);
401 $password = 'hunter2';
402 $room = Room::factory()->create(compact('password'));
403
404 $initialUserChannelCount = UserChannel::count();
405 $url = route('api.rooms.join', ['room' => $room, 'user' => $token->user]);
406
407 // no password
408 $this
409 ->actingWithToken($token)
410 ->put($url)
411 ->assertStatus(403);
412
413 $this->assertSame($initialUserChannelCount, UserChannel::count());
414
415 // wrong password
416 $this
417 ->actingWithToken($token)
418 ->put($url, ['password' => "x{$password}"])
419 ->assertStatus(403);
420
421 $this->assertSame($initialUserChannelCount, UserChannel::count());
422
423 // correct password
424 $this
425 ->actingWithToken($token)
426 ->put($url, compact('password'))
427 ->assertSuccessful();
428
429 $this->assertSame($initialUserChannelCount + 1, UserChannel::count());
430 }
431
432 public function testDestroy()
433 {
434 $start = now();
435 $end = $start->clone()->addMinutes(60);
436 $room = Room::factory()->create([
437 'starts_at' => $start,
438 'ends_at' => $end,
439 'type' => Room::PLAYLIST_TYPE,
440 ]);
441 $end = $room->ends_at; // assignment truncates fractional second part, so refetch here
442 $url = route('api.rooms.destroy', ['room' => $room]);
443
444 $this->actAsScopedUser($room->host);
445 $this
446 ->delete($url)
447 ->assertSuccessful();
448
449 $room->refresh();
450 $this->assertLessThan($end, $room->ends_at);
451 }
452
453 public function testDestroyCannotBeCalledOnRealtimeRoom()
454 {
455 $start = now();
456 $end = $start->clone()->addMinutes(60);
457 $room = Room::factory()->create([
458 'starts_at' => $start,
459 'ends_at' => $end,
460 'type' => Room::REALTIME_DEFAULT_TYPE,
461 ]);
462 $end = $room->ends_at; // assignment truncates fractional second part, so refetch here
463 $url = route('api.rooms.destroy', ['room' => $room]);
464
465 $this->actAsScopedUser($room->host);
466 $this
467 ->delete($url)
468 ->assertStatus(422);
469
470 $room->refresh();
471 $this->assertEquals($end, $room->ends_at);
472 }
473
474 public function testDestroyCannotBeCalledByAnotherUser()
475 {
476 $requester = User::factory()->create();
477 $owner = User::factory()->create();
478 $start = now();
479 $end = $start->clone()->addMinutes(60);
480 $room = Room::factory()->create([
481 'user_id' => $owner->getKey(),
482 'starts_at' => $start,
483 'ends_at' => $end,
484 'type' => Room::PLAYLIST_TYPE,
485 ]);
486 $url = route('api.rooms.destroy', ['room' => $room]);
487 $end = $room->ends_at; // assignment truncates fractional second part, so refetch here
488
489 $this->actAsScopedUser($requester);
490 $this
491 ->delete($url)
492 ->assertStatus(403);
493
494 $room->refresh();
495 $this->assertEquals($end, $room->ends_at);
496 }
497
498 public function testDestroyCannotBeCalledAfterGracePeriod()
499 {
500 $start = now();
501 $end = $start->clone()->addMinutes(60);
502 $room = Room::factory()->create([
503 'starts_at' => $start,
504 'ends_at' => $end,
505 'type' => Room::PLAYLIST_TYPE,
506 ]);
507 $url = route('api.rooms.destroy', ['room' => $room]);
508 $end = $room->ends_at; // assignment truncates fractional second part, so refetch here
509
510 $this->actAsScopedUser($room->host);
511 $this->travelTo($start->addMinutes(6));
512 $this
513 ->delete($url)
514 ->assertStatus(422);
515
516 $room->refresh();
517 $this->assertEquals($end, $room->ends_at);
518 }
519
520 public static function dataProviderForTestStoreWithInvalidPlayableMods(): array
521 {
522 $ret = [];
523 foreach ([Arr::random(Room::REALTIME_TYPES), Room::PLAYLIST_TYPE] as $type) {
524 foreach (['allowed', 'required'] as $modType) {
525 $ret[] = [$type, $modType];
526 }
527 }
528
529 return $ret;
530 }
531
532 public static function dataProviderForTestStoreWithInvalidRealtimeAllowedMods(): array
533 {
534 return [
535 [Arr::random(Room::REALTIME_TYPES), false],
536 [Room::PLAYLIST_TYPE, true],
537 ];
538 }
539
540 public static function dataProviderForTestStoreWithInvalidRealtimeMods(): array
541 {
542 return [
543 [Arr::random(Room::REALTIME_TYPES), false],
544 [Room::PLAYLIST_TYPE, true],
545 ];
546 }
547
548 /**
549 * If making playlist, add `ends_at`.
550 * If making realtime, add `type`.
551 */
552 private function createBasicStoreParams($ruleset = null)
553 {
554 $beatmapset = Beatmapset::factory()->create();
555 $beatmapParams = ['beatmapset_id' => $beatmapset];
556 if ($ruleset !== null) {
557 $beatmapParams['playmode'] = Beatmap::MODES[$ruleset];
558 }
559 $beatmap = Beatmap::factory()->create($beatmapParams);
560
561 return [
562 'name' => 'test room '.rand(),
563 'playlist' => [
564 [
565 'allowed_mods' => [
566 [
567 'acronym' => 'PF',
568 'settings' => [],
569 ],
570 ],
571 'beatmap_id' => $beatmap->getKey(),
572 'required_mods' => [
573 [
574 'acronym' => 'DT',
575 'settings' => [],
576 ],
577 ],
578 'ruleset_id' => $beatmap->playmode,
579 ],
580 ],
581 ];
582 }
583}