Skip to content

Commit

Permalink
Merge pull request ppy#11809 from notbakaneko/feature/username-change…
Browse files Browse the repository at this point in the history
…-cost-update

Adjust username change cost
  • Loading branch information
nanaya authored Jan 23, 2025
2 parents 515ffbd + 64dc89f commit 89b2f71
Show file tree
Hide file tree
Showing 4 changed files with 212 additions and 64 deletions.
31 changes: 15 additions & 16 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -317,24 +317,23 @@ public function getAuthPassword()

public function usernameChangeCost()
{
$changesToDate = $this->usernameChangeHistory()
->whereIn('type', ['support', 'paid'])
->count();
$tier = min($this->usernameChangeHistory()->paid()->count(), 5);

switch ($changesToDate) {
case 0:
return 0;
case 1:
return 8;
case 2:
return 16;
case 3:
return 32;
case 4:
return 64;
default:
return 100;
if ($tier > 1) {
$lastChange = $this->usernameChangeHistory()->paid()->last()?->timestamp;
if ($lastChange !== null) {
$tier = max($tier - $lastChange->diffInYears(Carbon::now(), false), 1);
}
}

return match ($tier) {
0 => 0,
1 => 8,
2 => 16,
3 => 32,
4 => 64,
default => 100,
};
}

public function revertUsername($type = 'revert'): UsernameChangeHistory
Expand Down
5 changes: 5 additions & 0 deletions app/Models/UsernameChangeHistory.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ class UsernameChangeHistory extends Model
protected $table = 'osu_username_change_history';
protected $primaryKey = 'change_id';

public function scopePaid($query)
{
$query->whereIn('type', ['support', 'paid']); // changed by support counts as paid.
}

public function scopeVisible($query)
{
$query->whereIn('type', ['support', 'paid', 'admin']);
Expand Down
31 changes: 31 additions & 0 deletions database/factories/UsernameChangeHistoryFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the GNU Affero General Public License v3.0.
// See the LICENCE file in the repository root for full licence text.

declare(strict_types=1);

namespace Database\Factories;

use App\Models\User;
use App\Models\UsernameChangeHistory;
use Carbon\Carbon;

class UsernameChangeHistoryFactory extends Factory
{
protected $model = UsernameChangeHistory::class;

public function definition(): array
{
return [
'timestamp' => Carbon::now(),
'type' => 'paid',
'user_id' => User::factory(),

// depend on user_id; the username will be incorrect when factorying multiple names at once,
// so they should be handled separately if realistic name changes are wanted.
'username' => fn (array $attr) => User::find($attr['user_id'])->username,
'username_last' => fn (array $attr) => "{$attr['username']}_prev",
];
}
}
209 changes: 161 additions & 48 deletions tests/Models/UserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,99 @@
use App\Libraries\Session\Store;
use App\Models\OAuth\Token;
use App\Models\User;
use App\Models\UsernameChangeHistory;
use Carbon\CarbonImmutable;
use Database\Factories\OAuth\RefreshTokenFactory;
use Tests\TestCase;

class UserTest extends TestCase
{
public static function dataProviderForAttributeTwitter(): array
{
return [
['@hello', 'hello'],
['hello', 'hello'],
['@', null],
['', null],
[null, null],
];
}

public static function dataProviderForUsernameChangeCost()
{
return [
[0, 0],
[1, 8],
[2, 16],
[3, 32],
[4, 64],
[5, 100],
[6, 100],
[10, 100],
];
}

public static function dataProviderForUsernameChangeCostLastChange()
{
// assume there are 6 changes (max tier + 1)
return [
[0, 100],
[1, 64],
[2, 32],
[3, 16],
[4, 8],
[5, 8],
[6, 8],
[10, 8],
];
}

public static function dataProviderForUsernameChangeCostType()
{
return [
['admin', 0],
['inactive', 0],
['paid', 8],
['revert', 0],
['support', 8],
];
}

public static function dataProviderForUsernameChangeCostTypeLastChange()
{
return [
['admin', 8],
['inactive', 8],
['paid', 32],
['revert', 8],
['support', 32],
];
}

public static function dataProviderValidDiscordUsername(): array
{
return [
['username', true],
['user_name', true],
['user.name', true],
['user2name', true],
['u_sernam.e1337', true],
['username#', false],
['u', false],
['morethan32characterinthisusername', false], // 33 characters

// old format
['username#1337', true],
['ユーザー名#1337', true],
['username#1', false],
['username#13bb', false],
['username#abcd', false],
['user@name#1337', false],
['user#name#1337', false],
['user:name#1337', false],
];
}

/**
* @dataProvider dataProviderForAttributeTwitter
*/
Expand Down Expand Up @@ -47,6 +135,26 @@ public function testEmailLoginEnabled()
$this->assertTrue($user->is(User::findForLogin('[email protected]')));
}

public function testResetSessions(): void
{
$user = User::factory()->create();

// create session
$this->post(route('login'), ['username' => $user->username, 'password' => User::factory()::DEFAULT_PASSWORD]);
// sanity check
$this->assertNotEmpty(Store::ids($user->getKey()));

// create token
$token = Token::factory()->create(['user_id' => $user, 'revoked' => false]);
$refreshToken = (new RefreshTokenFactory())->create(['access_token_id' => $token, 'revoked' => false]);

$user->resetSessions();

$this->assertEmpty(Store::ids($user->getKey()));
$this->assertTrue($token->fresh()->revoked);
$this->assertTrue($refreshToken->fresh()->revoked);
}

public function testUsernameAvailableAtForDefaultGroup()
{
config_set('osu.user.allowed_rename_groups', ['default']);
Expand All @@ -65,24 +173,64 @@ public function testUsernameAvailableAtForNonDefaultGroup()
$this->assertGreaterThanOrEqual($allowedAt, $user->getUsernameAvailableAt());
}

public function testResetSessions(): void
/**
* @dataProvider dataProviderForUsernameChangeCost
*/
public function testUsernameChangeCost(int $changes, int $cost)
{
$user = User::factory()->create();
$user = User::factory()
->has(UsernameChangeHistory::factory()->count($changes))
->create();

// create session
$this->post(route('login'), ['username' => $user->username, 'password' => User::factory()::DEFAULT_PASSWORD]);
// sanity check
$this->assertNotEmpty(Store::ids($user->getKey()));
$this->assertSame($cost, $user->usernameChangeCost());
}

// create token
$token = Token::factory()->create(['user_id' => $user, 'revoked' => false]);
$refreshToken = (new RefreshTokenFactory())->create(['access_token_id' => $token, 'revoked' => false]);
/**
* @dataProvider dataProviderForUsernameChangeCostLastChange
*/
public function testUsernameChangeCostLastChange(int $years, int $cost)
{
$this->travelTo(CarbonImmutable::now()->subYears($years));

$user->resetSessions();
$user = User::factory()
->has(UsernameChangeHistory::factory()->count(6)) // 6 = max tier + 1
->create();

$this->assertEmpty(Store::ids($user->getKey()));
$this->assertTrue($token->fresh()->revoked);
$this->assertTrue($refreshToken->fresh()->revoked);
$this->travelBack();

$this->assertSame($cost, $user->usernameChangeCost());
}

/**
* @dataProvider dataProviderForUsernameChangeCostType
*/
public function testUsernameChangeCostType(string $type, int $cost)
{
$user = User::factory()
->has(UsernameChangeHistory::factory()->state(['type' => $type]))
->create();

$this->assertSame($cost, $user->usernameChangeCost());
}

/**
* This tests the correct last UsernameChangeHistory is used when applying the cost changes.
*
* @dataProvider dataProviderForUsernameChangeCostTypeLastChange
*/
public function testUsernameChangeCostTypeLastChange(string $type, int $cost)
{
$this->travelTo(CarbonImmutable::now()->subYears(1));

$user = User::factory()
->has(UsernameChangeHistory::factory()->count(2))
->create();

$this->travelBack();

UsernameChangeHistory::factory()->state(['type' => $type, 'user_id' => $user])->create();

$this->assertSame($cost, $user->usernameChangeCost());
}

/**
Expand All @@ -99,39 +247,4 @@ public function testValidDiscordUsername(string $username, bool $valid)
$this->assertArrayHasKey('user_discord', $user->validationErrors()->all());
}
}

public static function dataProviderForAttributeTwitter(): array
{
return [
['@hello', 'hello'],
['hello', 'hello'],
['@', null],
['', null],
[null, null],
];
}

public static function dataProviderValidDiscordUsername(): array
{
return [
['username', true],
['user_name', true],
['user.name', true],
['user2name', true],
['u_sernam.e1337', true],
['username#', false],
['u', false],
['morethan32characterinthisusername', false], // 33 characters

// old format
['username#1337', true],
['ユーザー名#1337', true],
['username#1', false],
['username#13bb', false],
['username#abcd', false],
['user@name#1337', false],
['user#name#1337', false],
['user:name#1337', false],
];
}
}

0 comments on commit 89b2f71

Please sign in to comment.