the browser-facing portion of osu!
at master 20 kB view raw
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}