Files
newznab-tmux-NNTmux/tests/Feature/RoleUpgradeTest.php
T
2026-03-26 11:43:32 +01:00

828 lines
32 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use PDO;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use Tests\TestCase;
final class RoleUpgradeTest extends TestCase
{
private string $databasePath = '';
/**
* @var array<string, string|false>
*/
private array $originalEnvironment = [];
private User $user;
private Role $userRole;
private Role $supporterRole;
public function createApplication()
{
$this->databasePath = sys_get_temp_dir().'/nntmux-role-upgrade-test.sqlite';
$this->originalEnvironment = [
'APP_ENV' => getenv('APP_ENV'),
'DB_CONNECTION' => getenv('DB_CONNECTION'),
'DB_DATABASE' => getenv('DB_DATABASE'),
];
if (file_exists($this->databasePath)) {
unlink($this->databasePath);
}
$pdo = new PDO('sqlite:'.$this->databasePath);
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
$settings = [
'categorizeforeign' => '0',
'catwebdl' => '0',
'delaytime' => '2',
'crossposttime' => '2',
'maxnzbsprocessed' => '1000',
'completionpercent' => '100',
'collection_timeout' => '30',
'maxsizetoformrelease' => '10737418240',
'minsizetoformrelease' => '0',
'minfilestoformrelease' => '1',
'releaseretentiondays' => '0',
'deletepasswordedrelease' => '0',
'miscotherretentionhours' => '0',
'mischashedretentionhours' => '0',
'partretentionhours' => '0',
'last_run_time' => '',
];
$statement = $pdo->prepare('INSERT INTO settings (name, value) VALUES (:name, :value)');
foreach ($settings as $name => $value) {
$statement->execute(['name' => $name, 'value' => $value]);
}
$this->setEnvironmentValue('APP_ENV', 'testing');
$this->setEnvironmentValue('DB_CONNECTION', 'sqlite');
$this->setEnvironmentValue('DB_DATABASE', $this->databasePath);
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => $this->databasePath]);
DB::purge();
DB::reconnect();
// Create minimal tables needed for testing
$this->createTestTables();
app(PermissionRegistrar::class)->forgetCachedPermissions();
// Create the basic User and Supporter roles using raw inserts so custom columns
// are always populated consistently across mixed-suite runs.
DB::table('roles')->insert([
[
'name' => 'User',
'guard_name' => 'web',
'addyears' => 0,
'apirequests' => 10,
'downloadrequests' => 5,
'defaultinvites' => 0,
'isdefault' => 1,
'donation' => 0,
'canpreview' => 0,
'created_at' => now(),
'updated_at' => now(),
],
[
'name' => 'Supporter',
'guard_name' => 'web',
'addyears' => 1,
'apirequests' => 100,
'downloadrequests' => 50,
'defaultinvites' => 5,
'isdefault' => 0,
'donation' => 10,
'canpreview' => 1,
'created_at' => now(),
'updated_at' => now(),
],
]);
$this->userRole = Role::query()->where('name', 'User')->where('guard_name', 'web')->firstOrFail();
$this->supporterRole = Role::query()->where('name', 'Supporter')->where('guard_name', 'web')->firstOrFail();
// Create a test user with the User role
$this->user = $this->createTestUser($this->userRole->id);
$this->user->assignRole($this->userRole);
}
protected function tearDown(): void
{
DB::disconnect();
parent::tearDown();
if ($this->databasePath !== '' && file_exists($this->databasePath)) {
unlink($this->databasePath);
}
foreach ($this->originalEnvironment as $key => $value) {
$this->setEnvironmentValue($key, $value === false ? null : $value);
}
}
/**
* Create the minimal database tables needed for testing.
*/
private function createTestTables(): void
{
DB::statement('DROP TABLE IF EXISTS role_promotion_stats');
DB::statement('DROP TABLE IF EXISTS role_promotions');
DB::statement('DROP TABLE IF EXISTS user_activities');
DB::statement('DROP TABLE IF EXISTS user_role_history');
DB::statement('DROP TABLE IF EXISTS role_has_permissions');
DB::statement('DROP TABLE IF EXISTS model_has_permissions');
DB::statement('DROP TABLE IF EXISTS permissions');
DB::statement('DROP TABLE IF EXISTS model_has_roles');
DB::statement('DROP TABLE IF EXISTS roles');
DB::statement('DROP TABLE IF EXISTS users');
// Users table
DB::statement('CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
roles_id INTEGER DEFAULT 1,
rolechangedate DATETIME NULL,
pending_roles_id INTEGER NULL,
pending_role_start_date DATETIME NULL,
api_token VARCHAR(255) NULL,
grabs INTEGER DEFAULT 0,
invites INTEGER DEFAULT 0,
notes TEXT DEFAULT "",
movieview INTEGER DEFAULT 1,
xxxview INTEGER DEFAULT 0,
musicview INTEGER DEFAULT 1,
consoleview INTEGER DEFAULT 1,
bookview INTEGER DEFAULT 1,
gameview INTEGER DEFAULT 1,
verified INTEGER DEFAULT 1,
can_post INTEGER DEFAULT 1,
rate_limit INTEGER DEFAULT 60,
email_verified_at DATETIME NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
)');
// Roles table (spatie/laravel-permission)
DB::statement('CREATE TABLE roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
guard_name VARCHAR(255) NOT NULL,
addyears INTEGER DEFAULT 0,
apirequests INTEGER DEFAULT 10,
downloadrequests INTEGER DEFAULT 5,
defaultinvites INTEGER DEFAULT 0,
isdefault INTEGER DEFAULT 0,
donation INTEGER DEFAULT 0,
canpreview INTEGER DEFAULT 0,
created_at DATETIME NULL,
updated_at DATETIME NULL
)');
// Model has roles table (spatie/laravel-permission)
DB::statement('CREATE TABLE model_has_roles (
role_id INTEGER NOT NULL,
model_type VARCHAR(255) NOT NULL,
model_id INTEGER NOT NULL,
PRIMARY KEY (role_id, model_type, model_id)
)');
// Permissions table (spatie/laravel-permission)
DB::statement('CREATE TABLE permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
guard_name VARCHAR(255) NOT NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL
)');
// Model has permissions table (spatie/laravel-permission)
DB::statement('CREATE TABLE model_has_permissions (
permission_id INTEGER NOT NULL,
model_type VARCHAR(255) NOT NULL,
model_id INTEGER NOT NULL,
PRIMARY KEY (permission_id, model_type, model_id)
)');
// Role has permissions table (spatie/laravel-permission)
DB::statement('CREATE TABLE role_has_permissions (
permission_id INTEGER NOT NULL,
role_id INTEGER NOT NULL,
PRIMARY KEY (permission_id, role_id)
)');
// User role history table
DB::statement('CREATE TABLE user_role_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
old_role_id INTEGER NULL,
new_role_id INTEGER NOT NULL,
old_expiry_date DATETIME NULL,
new_expiry_date DATETIME NULL,
effective_date DATETIME NOT NULL,
is_stacked INTEGER DEFAULT 0,
change_reason TEXT NULL,
changed_by INTEGER NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL
)');
// User activities table
DB::statement('CREATE TABLE user_activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NULL,
username VARCHAR(255) NOT NULL,
activity_type VARCHAR(50) NOT NULL,
description TEXT NOT NULL,
metadata TEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)');
// Role promotions table
DB::statement('CREATE TABLE role_promotions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
applicable_roles TEXT NOT NULL,
additional_days INTEGER DEFAULT 0,
start_date DATE NULL,
end_date DATE NULL,
is_active INTEGER DEFAULT 1,
created_at DATETIME NULL,
updated_at DATETIME NULL
)');
// Role promotion stats table
DB::statement('CREATE TABLE role_promotion_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
role_promotion_id INTEGER NOT NULL,
role_id INTEGER NOT NULL,
days_added INTEGER NOT NULL,
previous_expiry_date DATETIME NULL,
new_expiry_date DATETIME NULL,
applied_at DATETIME NOT NULL,
created_at DATETIME NULL,
updated_at DATETIME NULL
)');
}
private function setEnvironmentValue(string $key, ?string $value): void
{
if ($value === null) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
return;
}
putenv($key.'='.$value);
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
/**
* Helper to create a test user without factory.
*/
private function createTestUser(int $roleId, ?string $roleChangeDate = null): User
{
// Use forceCreate to bypass hashed cast
$user = new User;
$user->username = 'testuser_'.Str::random(8);
$user->email = Str::random(8).'@test.com';
$user->roles_id = $roleId;
$user->rolechangedate = $roleChangeDate;
$user->api_token = md5(Str::random(32));
// Insert directly to avoid hashed cast issue
DB::table('users')->insert([
'username' => $user->username,
'email' => $user->email,
'password' => 'hashed_password_placeholder',
'roles_id' => $roleId,
'rolechangedate' => $roleChangeDate,
'api_token' => $user->api_token,
'created_at' => now(),
'updated_at' => now(),
]);
return User::where('email', $user->email)->first();
}
/**
* Test that updating a user role to Supporter with addYears=2 correctly applies 2 years.
* This test catches the bug where addYears parameter is not properly passed to updateUserRole.
*/
public function test_role_upgrade_to_supporter_applies_two_years_correctly(): void
{
Carbon::setTestNow(Carbon::parse('2025-01-01 12:00:00'));
$addYears = 2;
// Call updateUserRole with addYears = 2
$result = User::updateUserRole(
uid: $this->user->id,
role: 'Supporter',
applyPromotions: false,
stackRole: false,
addYears: $addYears
);
$this->assertTrue($result, 'updateUserRole should return true');
// Refresh user from database
$this->user->refresh();
// User should now have Supporter role
$this->assertEquals($this->supporterRole->id, $this->user->roles_id, 'User should have Supporter role');
// The rolechangedate should be 2 years from now (730 days)
$expectedExpiryDate = Carbon::parse('2025-01-01 12:00:00')->addDays($addYears * 365);
$this->assertNotNull($this->user->rolechangedate, 'Role change date should not be null');
$actualExpiryDate = Carbon::parse($this->user->rolechangedate);
// Assert the expiry date is correctly 2 years in the future
$this->assertEquals(
$expectedExpiryDate->toDateString(),
$actualExpiryDate->toDateString(),
"Expected expiry date {$expectedExpiryDate->toDateString()}, but got {$actualExpiryDate->toDateString()}. The addYears parameter may not have been applied correctly."
);
Carbon::setTestNow();
}
/**
* Test that when addYears is null, the role's default addyears is used.
*/
public function test_role_upgrade_uses_default_addyears_when_parameter_is_null(): void
{
Carbon::setTestNow(Carbon::parse('2025-01-01 12:00:00'));
// Call updateUserRole without specifying addYears (should use role's default of 1)
$result = User::updateUserRole(
uid: $this->user->id,
role: 'Supporter',
applyPromotions: false,
stackRole: false,
addYears: null
);
$this->assertTrue($result, 'updateUserRole should return true');
$updatedUser = DB::table('users')->where('id', $this->user->id)->first();
// The rolechangedate should be 1 year from now (365 days) - the role's default
$expectedExpiryDate = Carbon::parse('2025-01-01 12:00:00')->addDays($this->supporterRole->addyears * 365);
$this->assertNotNull($updatedUser?->rolechangedate, 'Role change date should not be null');
$actualExpiryDate = Carbon::parse($updatedUser->rolechangedate);
$this->assertEquals(
$expectedExpiryDate->toDateString(),
$actualExpiryDate->toDateString(),
"Expected expiry date {$expectedExpiryDate->toDateString()} (using role default), but got {$actualExpiryDate->toDateString()}"
);
Carbon::setTestNow();
}
/**
* Test applying addYears to a user who already has the same role with a future expiry date.
* This tests the scenario where a user extends their existing Supporter subscription.
*/
public function test_add_years_applied_to_existing_role_with_future_expiry(): void
{
Carbon::setTestNow(Carbon::parse('2025-01-01 12:00:00'));
// First, set up user with Supporter role expiring in 6 months
$existingExpiryDate = Carbon::parse('2025-01-01 12:00:00')->addMonths(6);
// Update user to Supporter with initial expiry
$this->user->update([
'roles_id' => $this->supporterRole->id,
'rolechangedate' => $existingExpiryDate,
]);
$this->user->syncRoles([$this->supporterRole->name]);
$this->user->refresh();
// Verify initial state
$this->assertEquals($this->supporterRole->id, $this->user->roles_id);
$this->assertTrue(Carbon::parse($this->user->rolechangedate)->isFuture(), 'User should have future expiry date');
$addYears = 2;
// Now try to apply the same role with addYears=2
// This simulates a user buying another 2 years while still having active subscription
$result = User::updateUserRole(
uid: $this->user->id,
role: 'Supporter',
applyPromotions: false,
stackRole: true, // Enable stacking
addYears: $addYears
);
$this->assertTrue($result, 'updateUserRole should return true');
$this->user->refresh();
// When same role with future expiry, it should stack (pending role)
// OR if immediate, it should extend from current expiry
// The key assertion is that addYears (2) was used, not the role's default (1)
// Check if stacked (pending role set)
if ($this->user->pending_roles_id !== null) {
// Role was stacked - the pending role should be Supporter
$this->assertEquals(
$this->supporterRole->id,
$this->user->pending_roles_id,
'Pending role should be Supporter'
);
$this->assertNotNull($this->user->pending_role_start_date, 'Pending role start date should be set');
} else {
// Role was applied immediately or expiry was extended
// The expiry should reflect the addYears parameter being applied
$actualExpiryDate = Carbon::parse($this->user->rolechangedate);
// It should NOT be just 1 year (365 days) from now - that would mean addYears was ignored
$oneYearFromNow = Carbon::parse('2025-01-01 12:00:00')->addDays(365);
$this->assertNotEquals(
$oneYearFromNow->toDateString(),
$actualExpiryDate->toDateString(),
'Expiry date should NOT be just 1 year from now. The addYears=2 parameter should have been applied.'
);
}
Carbon::setTestNow();
}
/**
* Test upgrading from different role to Supporter with addYears when user has future expiry.
* This simulates: User has "User" role expiring in future, upgrades to "Supporter 2 years".
*/
public function test_add_years_applied_when_upgrading_from_different_role(): void
{
Carbon::setTestNow(Carbon::parse('2025-01-01 12:00:00'));
// User has "User" role with a future expiry date (edge case)
$existingExpiryDate = Carbon::parse('2025-01-01 12:00:00')->addMonths(6);
$this->user->update([
'rolechangedate' => $existingExpiryDate,
]);
$this->user->refresh();
// Verify initial state - User role
$this->assertEquals($this->userRole->id, $this->user->roles_id);
$addYears = 2;
// Upgrade to Supporter with addYears=2 (without stacking)
$result = User::updateUserRole(
uid: $this->user->id,
role: 'Supporter',
applyPromotions: false,
stackRole: false, // Disable stacking - should apply immediately
addYears: $addYears
);
$this->assertTrue($result, 'updateUserRole should return true');
$this->user->refresh();
// User should now have Supporter role
$this->assertEquals($this->supporterRole->id, $this->user->roles_id, 'User should have Supporter role');
// The expiry date should be 2 years from now (addYears=2)
$expectedExpiryDate = Carbon::parse('2025-01-01 12:00:00')->addDays($addYears * 365);
$actualExpiryDate = Carbon::parse($this->user->rolechangedate);
// This verifies addYears=2 was used, not the default addyears=1
$this->assertEquals(
$expectedExpiryDate->toDateString(),
$actualExpiryDate->toDateString(),
"When upgrading to Supporter with addYears={$addYears}, expected {$expectedExpiryDate->toDateString()}, got {$actualExpiryDate->toDateString()}. The addYears parameter may not have been applied."
);
Carbon::setTestNow();
}
/**
* Test that when same role is applied with addYears, the expiry should be extended from current expiry.
* This catches the bug where addYears is not applied when role doesn't change.
*/
public function test_add_years_applied_when_same_role_without_stacking(): void
{
Carbon::setTestNow(Carbon::parse('2025-01-01 12:00:00'));
// Set up user with Supporter role expiring in 6 months
$existingExpiryDate = Carbon::parse('2025-01-01 12:00:00')->addMonths(6); // 2025-07-01
$this->user->update([
'roles_id' => $this->supporterRole->id,
'rolechangedate' => $existingExpiryDate,
]);
$this->user->syncRoles([$this->supporterRole->name]);
$this->user->refresh();
$addYears = 2;
// Apply same role with stacking disabled but with addYears=2
// This simulates a user renewing their Supporter subscription for 2 more years
$result = User::updateUserRole(
uid: $this->user->id,
role: 'Supporter',
applyPromotions: false,
stackRole: false,
addYears: $addYears
);
$this->assertTrue($result, 'updateUserRole should return true');
$this->user->refresh();
// EXPECTED: addYears should extend from current expiry date
// Current expiry: 2025-07-01 + 2 years (730 days) = 2027-07-01
$expectedExpiryDate = $existingExpiryDate->copy()->addDays($addYears * 365);
$actualExpiryDate = Carbon::parse($this->user->rolechangedate);
// This assertion will FAIL if addYears is not applied when role doesn't change
$this->assertEquals(
$expectedExpiryDate->toDateString(),
$actualExpiryDate->toDateString(),
"BUG: addYears={$addYears} was not applied when same role is set. Expected {$expectedExpiryDate->toDateString()} (current expiry + 2 years), got {$actualExpiryDate->toDateString()}. The rolechangedate should be extended from current expiry."
);
Carbon::setTestNow();
}
/**
* Test that addYears=2 produces different result than addYears=1.
* This is a regression test to ensure the addYears parameter is actually being used.
*/
public function test_add_years_parameter_makes_a_difference(): void
{
Carbon::setTestNow(Carbon::parse('2025-01-01 12:00:00'));
// First user with 1 year
$user1 = $this->createTestUser($this->userRole->id);
$user1->assignRole($this->userRole);
User::updateUserRole(
uid: $user1->id,
role: 'Supporter',
applyPromotions: false,
stackRole: false,
addYears: 1
);
$user1->refresh();
// Second user with 2 years
$user2 = $this->createTestUser($this->userRole->id);
$user2->assignRole($this->userRole);
User::updateUserRole(
uid: $user2->id,
role: 'Supporter',
applyPromotions: false,
stackRole: false,
addYears: 2
);
$user2->refresh();
$expiryDate1 = Carbon::parse($user1->rolechangedate);
$expiryDate2 = Carbon::parse($user2->rolechangedate);
// The difference should be approximately 365 days (1 year)
$differenceInDays = $expiryDate1->diffInDays($expiryDate2);
$this->assertEquals(
365,
$differenceInDays,
"Expected 365 days difference between 1-year and 2-year subscription, but got {$differenceInDays} days. The addYears parameter is not being applied correctly."
);
Carbon::setTestNow();
}
/**
* Test BtcPay webhook flow simulation - parsing item_description and applying addYears.
* This tests the exact flow from BtcPaymentController.
*/
public function test_btcpay_item_description_parsing_extracts_add_years(): void
{
$testCases = [
['item_description' => 'Supporter 1', 'expected_role' => 'Supporter', 'expected_years' => 1],
['item_description' => 'Supporter 2', 'expected_role' => 'Supporter', 'expected_years' => 2],
['item_description' => 'Supporter 3', 'expected_role' => 'Supporter', 'expected_years' => 3],
['item_description' => 'Admin ++ 2', 'expected_role' => 'Admin ++', 'expected_years' => 2],
['item_description' => 'Friend 1', 'expected_role' => 'Friend', 'expected_years' => 1],
];
foreach ($testCases as $testCase) {
$itemDescription = $testCase['item_description'];
// Use the same regex pattern from BtcPaymentController
if (preg_match('/(?P<role>\w+(\s\+\+)?)[\s]+(?P<addYears>\d+)/i', $itemDescription, $matches)) {
$roleName = $matches['role'];
$addYears = (int) $matches['addYears'];
} else {
$roleName = $itemDescription;
$addYears = null;
}
$this->assertEquals(
$testCase['expected_role'],
$roleName,
"Failed to extract role name from '{$itemDescription}'"
);
$this->assertEquals(
$testCase['expected_years'],
$addYears,
"Failed to extract addYears from '{$itemDescription}'"
);
}
}
/**
* Test full BtcPay simulation - from item description to role update.
* This simulates what happens when a user pays for "Supporter 2" subscription.
*/
public function test_btcpay_supporter_2_year_upgrade_simulation(): void
{
Carbon::setTestNow(Carbon::parse('2025-01-01 12:00:00'));
// Simulate the item_description from a BTCPay order
$itemDescription = 'Supporter 2';
// Extract role name and addYears using the same regex from BtcPaymentController
if (preg_match('/(?P<role>\w+(\s\+\+)?)[\s]+(?P<addYears>\d+)/i', $itemDescription, $matches)) {
$roleName = $matches['role'];
$addYears = (int) $matches['addYears'];
} else {
$roleName = $itemDescription;
$addYears = null;
}
// Ensure we extracted the correct values
$this->assertEquals('Supporter', $roleName);
$this->assertEquals(2, $addYears);
// Now apply the role update
$result = User::updateUserRole($this->user->id, $roleName, addYears: $addYears);
$this->assertTrue($result, 'updateUserRole should succeed');
$this->user->refresh();
// Verify the user has the correct expiry date (2 years from now)
$expectedExpiryDate = Carbon::parse('2025-01-01 12:00:00')->addDays(2 * 365);
$actualExpiryDate = Carbon::parse($this->user->rolechangedate);
$this->assertEquals(
$expectedExpiryDate->toDateString(),
$actualExpiryDate->toDateString(),
"BtcPay 'Supporter 2' order should result in 2-year subscription. Expected {$expectedExpiryDate->toDateString()}, got {$actualExpiryDate->toDateString()}"
);
Carbon::setTestNow();
}
/**
* Test that when a user has a role stacking and the expiry date is extended,
* subsequent role stackings use the new extended expiry date.
*
* Scenario:
* 1. User has Supporter role expiring on 2025-07-01
* 2. Admin extends the user's expiry to 2025-12-01
* 3. User purchases another Supporter subscription (stacking)
* 4. The stacked role should start from 2025-12-01, NOT 2025-07-01
*/
public function test_role_stacking_uses_updated_expiry_when_extended(): void
{
Carbon::setTestNow(Carbon::parse('2025-01-01 12:00:00'));
// Step 1: User has Supporter role expiring in 6 months (2025-07-01)
$originalExpiryDate = Carbon::parse('2025-07-01 12:00:00');
$this->user->update([
'roles_id' => $this->supporterRole->id,
'rolechangedate' => $originalExpiryDate,
]);
$this->user->syncRoles([$this->supporterRole->name]);
$this->user->refresh();
// Step 2: Admin extends the expiry to 2025-12-01
$extendedExpiryDate = Carbon::parse('2025-12-01 12:00:00');
$this->user->update([
'rolechangedate' => $extendedExpiryDate,
]);
$this->user->refresh();
// Verify the extended expiry is set
$this->assertEquals(
$extendedExpiryDate->toDateString(),
Carbon::parse($this->user->rolechangedate)->toDateString(),
'Expiry date should be extended to 2025-12-01'
);
$addYears = 1;
// Step 3: User purchases another Supporter 1 year subscription (stacking)
// The originalExpiryBeforeEdits simulates what the controller would pass
// (the expiry before the admin extended it)
$result = User::updateUserRole(
uid: $this->user->id,
role: 'Supporter',
applyPromotions: false,
stackRole: true,
changedBy: null,
originalExpiryBeforeEdits: $originalExpiryDate->toDateTimeString(), // Old value
addYears: $addYears
);
$this->assertTrue($result, 'updateUserRole should return true');
$this->user->refresh();
// Step 4: When renewing the SAME role, the expiry is extended directly
// (not via pending role stacking - that's only for DIFFERENT roles)
// The new expiry should be currentExpiry + addYears
$expectedNewExpiry = $extendedExpiryDate->copy()->addDays(365);
$actualExpiryDate = Carbon::parse($this->user->rolechangedate);
$this->assertEquals(
$expectedNewExpiry->toDateString(),
$actualExpiryDate->toDateString(),
'Same role renewal should extend from current expiry date (2025-12-01 + 1 year). '.
"Expected {$expectedNewExpiry->toDateString()}, got {$actualExpiryDate->toDateString()}."
);
Carbon::setTestNow();
}
/**
* Test that role stacking correctly handles the case where currentExpiryDate is newer than oldExpiryDate.
* This tests the fix where the stacking start date should be the most recent future date.
*/
public function test_role_stacking_prefers_newer_expiry_date(): void
{
Carbon::setTestNow(Carbon::parse('2025-01-01 12:00:00'));
// Create another paid role for testing
$premiumRole = Role::firstOrCreate(
['name' => 'Premium'],
[
'guard_name' => 'web',
'addyears' => 2,
'apirequests' => 200,
'downloadrequests' => 100,
'defaultinvites' => 10,
'isdefault' => 0,
'donation' => 25,
'canpreview' => 1,
]
);
// User has Supporter role with expiry date that was extended
$currentExpiryDate = Carbon::parse('2026-01-01 12:00:00'); // Extended date
$this->user->update([
'roles_id' => $this->supporterRole->id,
'rolechangedate' => $currentExpiryDate,
]);
$this->user->syncRoles([$this->supporterRole->name]);
$this->user->refresh();
// Simulate that the original expiry was earlier (before extension)
$oldExpiryDate = Carbon::parse('2025-06-01 12:00:00');
$addYears = 2;
// Stack a Premium role upgrade
$result = User::updateUserRole(
uid: $this->user->id,
role: 'Premium',
applyPromotions: false,
stackRole: true,
changedBy: null,
originalExpiryBeforeEdits: $oldExpiryDate->toDateTimeString(),
addYears: $addYears
);
$this->assertTrue($result, 'updateUserRole should return true');
$this->user->refresh();
// Verify the pending role is set
$this->assertEquals($premiumRole->id, $this->user->pending_roles_id, 'Pending role should be Premium');
$this->assertNotNull($this->user->pending_role_start_date, 'Pending role start date should be set');
$pendingStartDate = Carbon::parse($this->user->pending_role_start_date);
// The stacking should use the NEWER (current) expiry date: 2026-01-01
// Not the older expiry date: 2025-06-01
$this->assertEquals(
$currentExpiryDate->toDateString(),
$pendingStartDate->toDateString(),
'Role stacking should use the newer expiry date (2026-01-01) when currentExpiryDate > oldExpiryDate. '.
"Got {$pendingStartDate->toDateString()} instead."
);
Carbon::setTestNow();
}
}