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\Controllers;
9
10use App\Libraries\Search\ScoreSearch;
11use App\Models\Beatmap;
12use App\Models\Beatmapset;
13use App\Models\Country;
14use App\Models\Genre;
15use App\Models\Group;
16use App\Models\Language;
17use App\Models\OAuth;
18use App\Models\Solo\Score as SoloScore;
19use App\Models\User;
20use App\Models\UserGroup;
21use App\Models\UserGroupEvent;
22use App\Models\UserRelation;
23use Tests\TestCase;
24
25class BeatmapsControllerSoloScoresTest extends TestCase
26{
27 protected $connectionsToTransact = [];
28
29 private static Beatmap $beatmap;
30 private static User $otherUser;
31 private static array $scores;
32 private static User $user;
33
34 public static function setUpBeforeClass(): void
35 {
36 static::withDbAccess(function () {
37 static::$user = User::factory()->create(['osu_subscriber' => true]);
38 static::$otherUser = User::factory()->create(['country_acronym' => Country::factory()]);
39 $friend = User::factory()->create(['country_acronym' => Country::factory()]);
40 static::$beatmap = Beatmap::factory()->qualified()->create();
41
42 $countryAcronym = static::$user->country_acronym;
43
44 $otherUser2 = User::factory()->create(['country_acronym' => Country::factory()]);
45 $otherUser3SameCountry = User::factory()->create(['country_acronym' => $countryAcronym]);
46
47 static::$scores = [];
48 $scoreFactory = SoloScore::factory()->state(['build_id' => 0]);
49 foreach (['solo' => false, 'legacy' => true] as $type => $isLegacy) {
50 $scoreFactory = $scoreFactory->state([
51 'legacy_score_id' => $isLegacy ? rand() : null,
52 ]);
53 $makeMods = fn (array $modNames): array => array_map(
54 fn (string $modName): array => [
55 'acronym' => $modName,
56 'settings' => [],
57 ],
58 [...$modNames, ...($isLegacy ? ['CL'] : [])],
59 );
60
61 $makeTotalScores = fn (int $totalScore): array => [
62 'legacy_total_score' => $totalScore * ($isLegacy ? 1 : 0),
63 'total_score' => $totalScore + ($isLegacy ? -1 : 0),
64 ];
65
66 static::$scores = [
67 ...static::$scores,
68 "{$type}:userModsLowerScore" => $scoreFactory->withData([
69 'mods' => $makeMods(['DT', 'HD']),
70 ])->create([
71 ...$makeTotalScores(1000),
72 'beatmap_id' => static::$beatmap,
73 'preserve' => true,
74 'user_id' => static::$user,
75 ]),
76 "{$type}:otherUserModsNCPFHigherScore" => $scoreFactory->withData([
77 'mods' => $makeMods(['NC', 'PF']),
78 ])->create([
79 ...$makeTotalScores(1010),
80 'beatmap_id' => static::$beatmap,
81 'preserve' => true,
82 'user_id' => static::$otherUser,
83 ]),
84 "{$type}:userMods" => $scoreFactory->withData([
85 'mods' => $makeMods(['DT', 'HD']),
86 ])->create([
87 ...$makeTotalScores(1050),
88 'beatmap_id' => static::$beatmap,
89 'preserve' => true,
90 'user_id' => static::$user,
91 ]),
92 "{$type}:userModsNC" => $scoreFactory->withData([
93 'mods' => $makeMods(['NC']),
94 ])->create([
95 ...$makeTotalScores(1050),
96 'beatmap_id' => static::$beatmap,
97 'preserve' => true,
98 'user_id' => static::$user,
99 ]),
100 "{$type}:user" => $scoreFactory->create([
101 ...$makeTotalScores(1100),
102 'beatmap_id' => static::$beatmap,
103 'preserve' => true,
104 'user_id' => static::$user,
105 ]),
106 "{$type}:friend" => $scoreFactory->create([
107 ...$makeTotalScores(1000),
108 'beatmap_id' => static::$beatmap,
109 'preserve' => true,
110 'user_id' => $friend,
111 ]),
112 // With preference mods
113 "{$type}:otherUser" => $scoreFactory->withData([
114 'mods' => $makeMods(['PF']),
115 ])->create([
116 ...$makeTotalScores(1000),
117 'beatmap_id' => static::$beatmap,
118 'preserve' => true,
119 'user_id' => static::$otherUser,
120 ]),
121 "{$type}:otherUserMods" => $scoreFactory->withData([
122 'mods' => $makeMods(['HD', 'PF', 'NC']),
123 ])->create([
124 ...$makeTotalScores(1000),
125 'beatmap_id' => static::$beatmap,
126 'preserve' => true,
127 'user_id' => static::$otherUser,
128 ]),
129 "{$type}:otherUserModsExtraNonPreferences" => $scoreFactory->withData([
130 'mods' => $makeMods(['DT', 'HD', 'HR']),
131 ])->create([
132 ...$makeTotalScores(1000),
133 'beatmap_id' => static::$beatmap,
134 'preserve' => true,
135 'user_id' => static::$otherUser,
136 ]),
137 "{$type}:otherUserModsUnrelated" => $scoreFactory->withData([
138 'mods' => $makeMods(['FL']),
139 ])->create([
140 ...$makeTotalScores(1000),
141 'beatmap_id' => static::$beatmap,
142 'preserve' => true,
143 'user_id' => static::$otherUser,
144 ]),
145 // Same total score but achieved later so it should come up after earlier score
146 "{$type}:otherUser2Later" => $scoreFactory->create([
147 ...$makeTotalScores(1000),
148 'beatmap_id' => static::$beatmap,
149 'preserve' => true,
150 'user_id' => $otherUser2,
151 ]),
152 "{$type}:otherUser3SameCountry" => $scoreFactory->create([
153 ...$makeTotalScores(1000),
154 'beatmap_id' => static::$beatmap,
155 'preserve' => true,
156 'user_id' => $otherUser3SameCountry,
157 ]),
158 // Non-preserved score should be filtered out
159 "{$type}:nonPreserved" => $scoreFactory->create([
160 'beatmap_id' => static::$beatmap,
161 'preserve' => false,
162 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]),
163 ]),
164 // Unrelated score
165 "{$type}:unrelated" => $scoreFactory->create([
166 'user_id' => User::factory()->state(['country_acronym' => Country::factory()]),
167 ]),
168 ];
169 }
170
171 UserRelation::create([
172 'friend' => true,
173 'user_id' => static::$user->getKey(),
174 'zebra_id' => $friend->getKey(),
175 ]);
176
177 static::reindexScores();
178 });
179 }
180
181 public static function tearDownAfterClass(): void
182 {
183 parent::tearDownAfterClass();
184
185 static::withDbAccess(function () {
186 Beatmap::truncate();
187 Beatmapset::truncate();
188 Country::truncate();
189 Genre::truncate();
190 Language::truncate();
191 OAuth\Client::truncate();
192 OAuth\Token::truncate();
193 SoloScore::select()->delete(); // TODO: revert to truncate after the table is actually renamed
194 User::truncate();
195 UserGroup::truncate();
196 UserGroupEvent::truncate();
197 UserRelation::truncate();
198 (new ScoreSearch())->deleteAll();
199 });
200 }
201
202 /**
203 * @dataProvider dataProviderForTestQuery
204 * @group RequiresScoreIndexer
205 */
206 public function testQuery(array $scoreKeys, array $params, string $route)
207 {
208 $resp = $this->actingAs(static::$user)
209 ->json('GET', route("beatmaps.{$route}", static::$beatmap), $params)
210 ->assertSuccessful();
211
212 $json = json_decode($resp->getContent(), true);
213 $this->assertSame(count($scoreKeys), count($json['scores']));
214 foreach ($json['scores'] as $i => $jsonScore) {
215 $this->assertSame(static::$scores[$scoreKeys[$i]]->getKey(), $jsonScore['id']);
216 }
217 }
218
219 /**
220 * @group RequiresScoreIndexer
221 */
222 public function testUserScore()
223 {
224 $url = route('api.beatmaps.user.score', [
225 'beatmap' => static::$beatmap->getKey(),
226 'legacy_only' => 1,
227 'mods' => ['DT', 'HD'],
228 'user' => static::$user->getKey(),
229 ]);
230 $this->actAsScopedUser(static::$user);
231 $this
232 ->json('GET', $url)
233 ->assertJsonPath('score.id', static::$scores['legacy:userMods']->legacy_score_id);
234 }
235
236 /**
237 * @group RequiresScoreIndexer
238 */
239 public function testUserScoreInvalidRulesetName()
240 {
241 $url = route('api.beatmaps.user.score', [
242 'beatmap' => static::$beatmap->getKey(),
243 'legacy_only' => 1,
244 'mode' => '_invalid',
245 'mods' => ['DT', 'HD'],
246 'user' => static::$user->getKey(),
247 ]);
248 $this->actAsScopedUser(static::$user);
249 $this
250 ->json('GET', $url)
251 ->assertStatus(422);
252 }
253
254 /**
255 * @group RequiresScoreIndexer
256 */
257 public function testUserScoreAll()
258 {
259 $url = route('api.beatmaps.user.scores', [
260 'beatmap' => static::$beatmap->getKey(),
261 'legacy_only' => 1,
262 'user' => static::$user->getKey(),
263 ]);
264 $this->actAsScopedUser(static::$user);
265 $this
266 ->json('GET', $url)
267 ->assertJsonCount(4, 'scores')
268 ->assertJsonPath(
269 'scores.*.id',
270 array_map(fn (string $key): int => static::$scores[$key]->legacy_score_id, [
271 'legacy:user',
272 'legacy:userMods',
273 'legacy:userModsNC',
274 'legacy:userModsLowerScore',
275 ])
276 );
277 }
278
279 public static function dataProviderForTestQuery(): array
280 {
281 $ret = [];
282 foreach (['solo' => 'solo-scores', 'legacy' => 'scores'] as $type => $route) {
283 $ret = array_merge($ret, [
284 "{$type}: no parameters" => [[
285 "{$type}:user",
286 "{$type}:otherUserModsNCPFHigherScore",
287 "{$type}:friend",
288 "{$type}:otherUser2Later",
289 "{$type}:otherUser3SameCountry",
290 ], [], $route],
291 "{$type}: by country" => [[
292 "{$type}:user",
293 "{$type}:otherUser3SameCountry",
294 ], ['type' => 'country'], $route],
295 "{$type}: by friend" => [[
296 "{$type}:user",
297 "{$type}:friend",
298 ], ['type' => 'friend'], $route],
299 "{$type}: mods filter" => [[
300 "{$type}:userMods",
301 "{$type}:otherUserMods",
302 ], ['mods' => ['DT', 'HD']], $route],
303 "{$type}: mods with implied filter" => [[
304 "{$type}:userModsNC",
305 "{$type}:otherUserModsNCPFHigherScore",
306 ], ['mods' => ['NC']], $route],
307 "{$type}: mods with nomods" => [[
308 "{$type}:user",
309 "{$type}:otherUserModsNCPFHigherScore",
310 "{$type}:friend",
311 "{$type}:otherUser2Later",
312 "{$type}:otherUser3SameCountry",
313 ], ['mods' => ['NC', 'NM']], $route],
314 "{$type}: nomods filter" => [[
315 "{$type}:user",
316 "{$type}:friend",
317 "{$type}:otherUser",
318 "{$type}:otherUser2Later",
319 "{$type}:otherUser3SameCountry",
320 ], ['mods' => ['NM']], $route],
321 ]);
322 }
323
324 return $ret;
325 }
326}