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;
7
8use App\Models\OAuth\Client;
9use App\Models\Score\Best\Osu;
10use App\Models\ScoreReplayStats;
11use App\Models\Solo\Score as SoloScore;
12use App\Models\User;
13use App\Models\UserStatistics;
14use Illuminate\Filesystem\Filesystem;
15use Storage;
16use Tests\TestCase;
17
18class ScoresControllerTest extends TestCase
19{
20 private Osu $score;
21 private SoloScore $soloScore;
22 private User $user;
23 private User $otherUser;
24
25 private static function getLegacyScoreReplayViewCount(Osu $score): int
26 {
27 return $score->replayViewCount()->first()?->play_count ?? 0;
28 }
29
30 private static function getScoreReplayViewCount(SoloScore $score): int
31 {
32 return ScoreReplayStats::find($score->getKey())?->watch_count ?? 0;
33 }
34
35 private static function getUserReplaysWatchedCount(Osu|SoloScore $score): int
36 {
37 $month = format_month_column(new \DateTime());
38
39 return $score->user->replaysWatchedCounts()->firstWhere('year_month', $month)?->count ?? 0;
40 }
41
42 private static function getUserReplayPopularity(Osu|SoloScore $score): int
43 {
44 return $score->user->statistics($score->getMode(), true)->first()?->replay_popularity ?? 0;
45 }
46
47 public function testDownloadApiSameUser()
48 {
49 $this->expectCountChange(fn () => static::getLegacyScoreReplayViewCount($this->score), 0);
50 $this->expectCountChange(fn () => static::getScoreReplayViewCount($this->soloScore), 0);
51 $this->expectCountChange(fn () => static::getUserReplayPopularity($this->score), 0);
52 $this->expectCountChange(fn () => static::getUserReplaysWatchedCount($this->score), 0);
53
54 $this
55 ->actAsPasswordClientUser($this->user)
56 ->json(
57 'GET',
58 route('api.scores.download-legacy', $this->params())
59 )
60 ->assertSuccessful();
61 }
62
63 public function testDownloadApiSoloScoreSameUser()
64 {
65 $soloScore = SoloScore::factory()
66 ->withReplay()
67 ->create(['user_id' => $this->user->getKey()]);
68
69 $this->expectCountChange(fn () => static::getUserReplayPopularity($soloScore), 0);
70 $this->expectCountChange(fn () => static::getUserReplaysWatchedCount($soloScore), 0);
71
72 $this
73 ->actAsPasswordClientUser($this->user)
74 ->json(
75 'GET',
76 route('api.scores.download', $soloScore)
77 )
78 ->assertSuccessful();
79 }
80
81 public function testDownload()
82 {
83 $this->expectCountChange(fn () => static::getLegacyScoreReplayViewCount($this->score), 0);
84 $this->expectCountChange(fn () => static::getScoreReplayViewCount($this->soloScore), 0);
85 $this->expectCountChange(fn () => static::getUserReplayPopularity($this->score), 0);
86 $this->expectCountChange(fn () => static::getUserReplaysWatchedCount($this->score), 0);
87
88 $this
89 ->actingAs($this->otherUser)
90 ->withHeaders(['HTTP_REFERER' => $GLOBALS['cfg']['app']['url'].'/'])
91 ->json(
92 'GET',
93 route('scores.download-legacy', $this->params())
94 )
95 ->assertSuccessful();
96 }
97
98 public function testDownloadApi(): void
99 {
100 $this->expectCountChange(fn () => static::getLegacyScoreReplayViewCount($this->score), 1);
101 $this->expectCountChange(fn () => static::getScoreReplayViewCount($this->soloScore), 1);
102 $this->expectCountChange(fn () => static::getUserReplayPopularity($this->score), 1);
103 $this->expectCountChange(fn () => static::getUserReplaysWatchedCount($this->score), 1);
104
105 $this
106 ->actAsPasswordClientUser($this->otherUser)
107 ->json(
108 'GET',
109 route('api.scores.download-legacy', $this->params())
110 )
111 ->assertSuccessful();
112 }
113
114 public function testDownloadApiTwiceNoCountChange(): void
115 {
116 $this
117 ->actAsPasswordClientUser($this->otherUser)
118 ->json(
119 'GET',
120 route('api.scores.download-legacy', $this->params())
121 )
122 ->assertSuccessful();
123
124 $this->expectCountChange(fn () => static::getLegacyScoreReplayViewCount($this->score), 0);
125 $this->expectCountChange(fn () => static::getScoreReplayViewCount($this->soloScore), 0);
126 $this->expectCountChange(fn () => static::getUserReplayPopularity($this->score), 0);
127 $this->expectCountChange(fn () => static::getUserReplaysWatchedCount($this->score), 0);
128
129 $this
130 ->actAsPasswordClientUser($this->otherUser)
131 ->json(
132 'GET',
133 route('api.scores.download-legacy', $this->params())
134 )
135 ->assertSuccessful();
136 }
137
138 public function testDownloadSoloScore()
139 {
140 $soloScore = SoloScore::factory()
141 ->withReplay()
142 ->create(['user_id' => $this->user->getKey()]);
143
144 $this->expectCountChange(fn () => static::getUserReplayPopularity($soloScore), 0);
145 $this->expectCountChange(fn () => static::getUserReplaysWatchedCount($soloScore), 0);
146
147 $this
148 ->actingAs($this->otherUser)
149 ->withHeaders(['HTTP_REFERER' => $GLOBALS['cfg']['app']['url'].'/'])
150 ->json(
151 'GET',
152 route('scores.download', $soloScore)
153 )
154 ->assertSuccessful();
155 }
156
157 public function testDownloadDeletedBeatmap()
158 {
159 $this->score->beatmap->delete();
160
161 $this
162 ->actingAs($this->user)
163 ->withHeaders(['HTTP_REFERER' => $GLOBALS['cfg']['app']['url'].'/'])
164 ->get(route('scores.download-legacy', $this->params()))
165 ->assertSuccessful();
166 }
167
168 public function testDownloadMissingBeatmap()
169 {
170 $this->score->beatmap->forceDelete();
171
172 $this
173 ->actingAs($this->user)
174 ->withHeaders(['HTTP_REFERER' => $GLOBALS['cfg']['app']['url'].'/'])
175 ->get(route('scores.download-legacy', $this->params()))
176 ->assertStatus(422);
177 }
178
179 public function testDownloadMissingUser()
180 {
181 $this->score->user->delete();
182
183 $this
184 ->actingAs($this->otherUser)
185 ->withHeaders(['HTTP_REFERER' => $GLOBALS['cfg']['app']['url'].'/'])
186 ->get(route('scores.download-legacy', $this->params()))
187 ->assertStatus(422);
188 }
189
190 public function testDownloadInvalidReferer()
191 {
192 $this
193 ->actingAs($this->user)
194 ->withHeaders(['HTTP_REFERER' => rtrim($GLOBALS['cfg']['app']['url'], '/').'.example.com'])
195 ->json(
196 'GET',
197 route('scores.download-legacy', $this->params())
198 )
199 ->assertRedirect(route('scores.show', $this->params()));
200 }
201
202 public function testDownloadNoReferer()
203 {
204 $this
205 ->actingAs($this->user)
206 ->json(
207 'GET',
208 route('scores.download-legacy', $this->params())
209 )
210 ->assertRedirect(route('scores.show', $this->params()));
211 }
212
213 public function testDownloadInvalidRuleset()
214 {
215 $this
216 ->actingAs($this->user)
217 ->json(
218 'GET',
219 route('scores.download-legacy', ['rulesetOrScore' => 'nope', 'score' => $this->score->getKey()])
220 )
221 ->assertStatus(302);
222 }
223
224 protected function setUp(): void
225 {
226 parent::setUp();
227
228 // fake all the replay disks
229 $disks = [SoloScore::replayFileDiskName()];
230 foreach ($GLOBALS['cfg']['filesystems']['disks']['replays'] as $ruleset => $types) {
231 foreach ($types as $type => $_config) {
232 $disks[] = "replays.{$ruleset}.{$type}";
233 }
234 }
235 foreach ($disks as $disk) {
236 Storage::fake($disk);
237 }
238
239 // Laravel doesn't remove the directory created for fakes and
240 // Storage::fake() removes the files in the directory when called but leaves the directory there.
241 $this->beforeApplicationDestroyed(function () use ($disks) {
242 foreach ($disks as $disk) {
243 $path = storage_path('framework/testing/disks/'.$disk);
244 (new Filesystem())->deleteDirectory($path);
245 }
246 });
247
248 $this->user = User::factory()->create();
249 $this->otherUser = User::factory()->create();
250
251 UserStatistics\Osu::factory()->create(['user_id' => $this->user->user_id]);
252 $this->score = Osu::factory()->withReplay()->create(['user_id' => $this->user->user_id]);
253 $this->soloScore = SoloScore::factory()->create([
254 'beatmap_id' => $this->score->beatmap_id,
255 'data' => $this->score->data,
256 'legacy_score_id' => $this->score->getKey(),
257 'user_id' => $this->score->user_id,
258 ]);
259 }
260
261 private function actAsPasswordClientUser(User $user): static
262 {
263 $this->actAsScopedUser($user, ['*'], Client::factory()->create(['password_client' => true]));
264
265 return $this;
266 }
267
268 private function params()
269 {
270 return [
271 'rulesetOrScore' => $this->score->getMode(),
272 'score' => $this->score->getKey(),
273 ];
274 }
275}