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\Libraries;
9
10use App\Libraries\UsernameValidation;
11use App\Models\Beatmap;
12use App\Models\Beatmapset;
13use App\Models\RankHighest;
14use App\Models\User;
15use Carbon\Carbon;
16use Tests\TestCase;
17
18class UsernameValidationTest extends TestCase
19{
20 public function testValidateAvailabilityWhenNotInUse(): void
21 {
22 $this->assertTrue(UsernameValidation::validateAvailability('Free username')->isEmpty());
23 }
24
25 public function testValidateAvailabilityWithActiveUser(): void
26 {
27 $user = User::factory()->create(['user_lastvisit' => Carbon::now()]);
28
29 $this->assertFalse(UsernameValidation::validateAvailability($user->username)->isEmpty());
30 }
31
32 public function testValidateAvailabilityWithInactiveUser(): void
33 {
34 $user = User::factory()->create(['user_lastvisit' => Carbon::now()->subDecade()]);
35
36 $this->assertTrue(UsernameValidation::validateAvailability($user->username)->isEmpty());
37 }
38
39 public function testValidateAvailabilityWithRecentlyUsedUsername(): void
40 {
41 User
42 ::factory()
43 ->create([
44 'user_lastvisit' => Carbon::now(),
45 'username' => 'New username',
46 'username_clean' => 'new username',
47 ])
48 ->usernameChangeHistory()
49 ->create([
50 'timestamp' => Carbon::now(),
51 'username' => 'New username',
52 'username_last' => 'Old username',
53 ]);
54
55 $this->assertFalse(UsernameValidation::validateAvailability('Old username')->isEmpty());
56 }
57
58 /**
59 * @dataProvider usernameValidationDataProvider
60 */
61 public function testValidateUsername(string $username, bool $expectValid): void
62 {
63 $this->assertSame(
64 $expectValid,
65 UsernameValidation::validateUsername($username)->isEmpty(),
66 );
67 }
68
69 /**
70 * @dataProvider usersOfUsernameLookupDataProvider
71 */
72 public function testValidateUsersOfUsername(
73 bool $throughUsernameHistory,
74 bool $underscoresReplaced,
75 bool $expectLookupSuccess,
76 ): void {
77 $username = 'username_1';
78 $user = User::factory()->create([
79 'username' => $username,
80 'username_clean' => $username,
81 ]);
82
83 if ($throughUsernameHistory) {
84 $username = "Old_{$username}";
85 $user->usernameChangeHistory()->create([
86 'username' => $user->username,
87 'username_last' => $username,
88 ]);
89 }
90
91 if ($underscoresReplaced) {
92 $username = str_replace('_', ' ', $username);
93 }
94
95 // Make the user fail at least one of the checks
96 RankHighest::factory()->create([
97 'rank' => 100,
98 'user_id' => $user,
99 ]);
100
101 // The validation should succeed only if the lookup does not
102 $this->assertNotSame(
103 $expectLookupSuccess,
104 UsernameValidation::validateUsersOfUsername($username)->isEmpty(),
105 );
106 }
107
108 public function testValidateUsersOfUsernameFormerlyAlmostTopRanked(): void
109 {
110 $user = User
111 ::factory()
112 ->has(RankHighest::factory()->state(['rank' => 101]))
113 ->create();
114
115 $this->assertTrue(UsernameValidation::validateUsersOfUsername($user->username)->isEmpty());
116 }
117
118 public function testValidateUsersOfUsernameFormerlyTopRanked(): void
119 {
120 $user = User
121 ::factory()
122 ->has(RankHighest::factory()->state(['rank' => 100]))
123 ->create();
124
125 $this->assertFalse(UsernameValidation::validateUsersOfUsername($user->username)->isEmpty());
126 }
127
128 public function testValidateUsersOfUsernameHasBadges(): void
129 {
130 $user = User::factory()->create();
131
132 $user->badges()->create([
133 'description' => '',
134 'image' => '',
135 ]);
136
137 $this->assertFalse(UsernameValidation::validateUsersOfUsername($user->username)->isEmpty());
138 }
139
140 /**
141 * @dataProvider usernameAvailabilityWithBeatmapStateDataProvider
142 */
143 public function testValidateUsersOfUsernameHasBeatmapsets(string $state, bool $expectValid): void
144 {
145 $user = User
146 ::factory()
147 ->has(Beatmapset::factory()->state(['approved' => Beatmapset::STATES[$state]]))
148 ->create();
149
150 $this->assertSame(
151 $expectValid,
152 UsernameValidation::validateUsersOfUsername($user->username)->isEmpty(),
153 );
154 }
155
156 /**
157 * @dataProvider usernameAvailabilityWithBeatmapStateDataProvider
158 */
159 public function testValidateUsersOfUsernameHasGuestBeatmaps(string $state, bool $expectValid): void
160 {
161 $user = User::factory()->create();
162
163 Beatmapset
164 ::factory()
165 ->has(Beatmap::factory()->state([
166 'approved' => Beatmapset::STATES[$state],
167 'user_id' => $user,
168 ]))
169 ->create(['approved' => Beatmapset::STATES['ranked']]);
170
171 $this->assertSame(
172 $expectValid,
173 UsernameValidation::validateUsersOfUsername($user->username)->isEmpty(),
174 );
175 }
176
177 /**
178 * Data in order:
179 * - Beatmap or beatmapset state
180 * - Whether the username should be available
181 */
182 public static function usernameAvailabilityWithBeatmapStateDataProvider(): array
183 {
184 return [
185 ['graveyard', true],
186 ['wip', true],
187 ['pending', true],
188 ['ranked', false],
189 ['approved', false],
190 ['qualified', false],
191 ['loved', false],
192 ];
193 }
194
195 /**
196 * Data in order:
197 * - Username
198 * - Whether the username should be valid
199 */
200 public static function usernameValidationDataProvider(): array
201 {
202 return [
203 'alphabetic' => ['Username', true],
204 'alphanumeric' => ['Username1000', true],
205 'numeric' => ['1000', true],
206 'space at beginning' => [' Username', false],
207 'space at end' => ['Username ', false],
208 'space in middle' => ['Username 1000', true],
209 'too short' => ['aa', false],
210 'shortest' => ['aaa', true],
211 'too long' => ['aaaaaaaaaaaaaaaa', false],
212 'longest' => ['aaaaaaaaaaaaaaa', true],
213 'two spaces in middle' => ['Username 1000', false],
214 'invalid special characters' => ['Usern@me', false],
215 'all valid special characters' => ['-[]_', true],
216 'mixed space and underscore' => ['Username_1 2', false],
217 ];
218 }
219
220 /**
221 * Data in order:
222 * - Whether the user lookup should be done through username change history
223 * - Whether the user lookup should have its underscores replaced with spaces
224 * - Whether the user lookup should return the user
225 */
226 public static function usersOfUsernameLookupDataProvider(): array
227 {
228 return [
229 [true, true, false],
230 [true, false, true],
231 [false, true, true],
232 [false, false, true],
233 ];
234 }
235}