laravel-roles/tests/Unit/HasRolesTest.php

646 lines
22 KiB
PHP

<?php
namespace Blax\Roles\Tests\Unit;
use Blax\Roles\Models\Permission;
use Blax\Roles\Models\Role;
use Blax\Roles\RolesServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Orchestra\Testbench\TestCase;
use Workbench\App\Models\User;
class HasRolesTest extends TestCase
{
use RefreshDatabase;
protected function getPackageProviders($app): array
{
return [RolesServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
$app['config']->set('roles.run_migrations', false);
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations');
}
// ─── roles relationship ──────────────────────────────────────
public function test_user_has_no_roles_by_default(): void
{
$user = User::factory()->create();
$this->assertCount(0, $user->roles);
}
// ─── hasRole ─────────────────────────────────────────────────
public function test_has_role_returns_false_when_not_assigned(): void
{
$user = User::factory()->create();
Role::create(['name' => 'Admin', 'slug' => 'admin']);
$this->assertFalse($user->hasRole('admin'));
}
public function test_has_role_by_slug_string(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Admin', 'slug' => 'admin']);
$user->assignRole($role);
$this->assertTrue($user->hasRole('admin'));
}
public function test_has_role_by_model_instance(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Editor', 'slug' => 'editor']);
$user->assignRole($role);
$this->assertTrue($user->hasRole($role));
}
public function test_has_role_returns_false_for_nonexistent_slug(): void
{
$user = User::factory()->create();
$this->assertFalse($user->hasRole('nonexistent'));
}
// ─── assignRole ──────────────────────────────────────────────
public function test_assign_role_by_string_creates_role(): void
{
$user = User::factory()->create();
$user->assignRole('new-role');
$this->assertDatabaseHas('roles', ['slug' => 'new-role']);
$this->assertTrue($user->hasRole('new-role'));
}
public function test_assign_role_by_model_instance(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Writer', 'slug' => 'writer']);
$user->assignRole($role);
$this->assertTrue($user->hasRole($role));
}
public function test_assign_role_by_numeric_id(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Viewer', 'slug' => 'viewer']);
$user->assignRole($role->id);
$this->assertTrue($user->hasRole('viewer'));
}
public function test_assign_role_respects_max_times_limit(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Limited', 'slug' => 'limited']);
// max_times = 1 (default), so second assign should be ignored
$user->assignRole($role, 1);
$user->assignRole($role, 1);
$count = DB::table(config('roles.table_names.role_member'))
->where('member_id', $user->id)
->where('member_type', $user->getMorphClass())
->where('role_id', $role->id)
->count();
$this->assertEquals(1, $count);
}
public function test_assign_role_allows_duplicates_when_max_times_higher(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Stackable', 'slug' => 'stackable']);
$user->assignRole($role, 3);
$user->assignRole($role, 3);
$user->assignRole($role, 3);
$user->assignRole($role, 3); // should be blocked
$count = DB::table(config('roles.table_names.role_member'))
->where('member_id', $user->id)
->where('member_type', $user->getMorphClass())
->where('role_id', $role->id)
->count();
$this->assertEquals(3, $count);
}
public function test_assign_role_throws_on_invalid_argument(): void
{
$user = User::factory()->create();
// Type-hinted as string|Role, so stdClass triggers TypeError
$this->expectException(\TypeError::class);
$user->assignRole(new \stdClass());
}
// ─── removeRole ──────────────────────────────────────────────
public function test_remove_role_by_model(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Temp', 'slug' => 'temp']);
$user->assignRole($role);
$this->assertTrue($user->hasRole($role));
$user->removeRole($role);
// Refresh to clear cached relations
$user->load('roles');
$this->assertFalse($user->hasRole($role));
}
public function test_remove_role_by_slug(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Removable', 'slug' => 'removable']);
$user->assignRole($role);
$user->removeRole('removable');
$user->load('roles');
$this->assertFalse($user->hasRole('removable'));
}
public function test_remove_role_does_not_affect_other_roles(): void
{
$user = User::factory()->create();
$keepRole = Role::create(['name' => 'Keep', 'slug' => 'keep']);
$removeRole = Role::create(['name' => 'Remove', 'slug' => 'remove']);
$user->assignRole($keepRole);
$user->assignRole($removeRole);
$user->removeRole($removeRole);
$user->load('roles');
$this->assertTrue($user->hasRole($keepRole));
$this->assertFalse($user->hasRole($removeRole));
}
// ─── syncRoles ───────────────────────────────────────────────
public function test_sync_roles_by_slug_strings(): void
{
$user = User::factory()->create();
$user->assignRole('old-role');
$user->syncRoles(['new-role-1', 'new-role-2']);
$user->load('roles');
$this->assertFalse($user->hasRole('old-role'));
$this->assertTrue($user->hasRole('new-role-1'));
$this->assertTrue($user->hasRole('new-role-2'));
}
public function test_sync_roles_by_model_instances(): void
{
$user = User::factory()->create();
$role1 = Role::create(['name' => 'Role1', 'slug' => 'role1']);
$role2 = Role::create(['name' => 'Role2', 'slug' => 'role2']);
$user->syncRoles([$role1, $role2]);
$user->load('roles');
$this->assertTrue($user->hasRole($role1));
$this->assertTrue($user->hasRole($role2));
}
public function test_sync_roles_by_numeric_ids(): void
{
$user = User::factory()->create();
$role1 = Role::create(['name' => 'R1', 'slug' => 'r1']);
$role2 = Role::create(['name' => 'R2', 'slug' => 'r2']);
$user->syncRoles([$role1->id, $role2->id]);
$user->load('roles');
$this->assertTrue($user->hasRole('r1'));
$this->assertTrue($user->hasRole('r2'));
}
public function test_sync_roles_by_objects_with_id(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'ObjRole', 'slug' => 'objrole']);
$user->syncRoles([(object) ['id' => $role->id]]);
$user->load('roles');
$this->assertTrue($user->hasRole('objrole'));
}
public function test_sync_roles_by_arrays_with_id(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'ArrRole', 'slug' => 'arrrole']);
$user->syncRoles([['id' => $role->id]]);
$user->load('roles');
$this->assertTrue($user->hasRole('arrrole'));
}
public function test_sync_roles_empty_removes_all(): void
{
$user = User::factory()->create();
$user->assignRole('existing');
$user->syncRoles([]);
$user->load('roles');
$this->assertCount(0, $user->roles);
}
// ─── hasAnyRole ──────────────────────────────────────────────
public function test_has_any_role_returns_true_when_one_matches(): void
{
$user = User::factory()->create();
$user->assignRole('editor');
$this->assertTrue($user->hasAnyRole(['admin', 'editor', 'viewer']));
}
public function test_has_any_role_returns_false_when_none_match(): void
{
$user = User::factory()->create();
$user->assignRole('writer');
$this->assertFalse($user->hasAnyRole(['admin', 'editor']));
}
public function test_has_any_role_returns_false_for_empty_array(): void
{
$user = User::factory()->create();
$this->assertFalse($user->hasAnyRole([]));
}
// ─── hasAllRoles ─────────────────────────────────────────────
public function test_has_all_roles_returns_true_when_all_match(): void
{
$user = User::factory()->create();
$user->assignRole('admin');
$user->assignRole('editor');
$this->assertTrue($user->hasAllRoles(['admin', 'editor']));
}
public function test_has_all_roles_returns_false_when_one_missing(): void
{
$user = User::factory()->create();
$user->assignRole('admin');
$this->assertFalse($user->hasAllRoles(['admin', 'editor']));
}
public function test_has_all_roles_returns_true_for_empty_array(): void
{
$user = User::factory()->create();
$this->assertTrue($user->hasAllRoles([]));
}
// ─── Expiration ──────────────────────────────────────────────
public function test_expired_role_is_not_returned_in_roles_relation(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Expired', 'slug' => 'expired']);
DB::table(config('roles.table_names.role_member'))->insert([
'role_id' => $role->id,
'member_id' => $user->id,
'id' => (string) Str::uuid(),
'member_type' => $user->getMorphClass(),
'expires_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
]);
$this->assertCount(0, $user->roles);
$this->assertFalse($user->hasRole($role));
}
public function test_non_expired_role_is_returned_in_roles_relation(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Future', 'slug' => 'future']);
DB::table(config('roles.table_names.role_member'))->insert([
'role_id' => $role->id,
'member_id' => $user->id,
'id' => (string) Str::uuid(),
'member_type' => $user->getMorphClass(),
'expires_at' => now()->addWeek(),
'created_at' => now(),
'updated_at' => now(),
]);
$this->assertCount(1, $user->roles);
$this->assertTrue($user->hasRole($role));
}
// ─── extendOrAddRole ─────────────────────────────────────────
public function test_extend_or_add_role_creates_new_membership(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Extend', 'slug' => 'extend']);
$user->extendOrAddRole($role, 48);
$membership = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->first();
$this->assertNotNull($membership);
$this->assertNotNull($membership->expires_at);
}
public function test_extend_or_add_role_extends_existing_future_membership(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Ext', 'slug' => 'ext']);
// Create initial membership expiring in 24 hours
DB::table(config('roles.table_names.role_member'))->insert([
'role_id' => $role->id,
'member_id' => $user->id,
'id' => (string) Str::uuid(),
'member_type' => $user->getMorphClass(),
'expires_at' => now()->addHours(24),
'created_at' => now(),
'updated_at' => now(),
]);
$user->extendOrAddRole($role, 24);
// Should now expire in ~48 hours from original base
$membership = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->first();
$this->assertNotNull($membership);
// Should be extended: approximately 48 hours from now
$expiresAt = \Carbon\Carbon::parse($membership->expires_at);
$this->assertTrue($expiresAt->gt(now()->addHours(40)));
}
public function test_extend_or_add_role_does_not_modify_null_expiry(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'NoExp', 'slug' => 'noexp']);
// Create a permanent membership
DB::table(config('roles.table_names.role_member'))->insert([
'role_id' => $role->id,
'member_id' => $user->id,
'id' => (string) Str::uuid(),
'member_type' => $user->getMorphClass(),
'expires_at' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$user->extendOrAddRole($role, 24);
$membership = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->first();
// Should still be null (permanent)
$this->assertNull($membership->expires_at);
}
public function test_extend_or_add_role_with_zero_hours_does_nothing(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Zero', 'slug' => 'zero']);
$user->extendOrAddRole($role, 0);
$count = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->count();
$this->assertEquals(0, $count);
}
public function test_extend_or_add_role_by_slug_string(): void
{
$user = User::factory()->create();
// extendOrAddRole resolves strings via firstOrCreate by name
$role = Role::create(['name' => 'byslug', 'slug' => 'byslug']);
$user->extendOrAddRole('byslug', 12);
$this->assertTrue($user->hasRole('byslug'));
}
public function test_extend_or_add_role_by_numeric_id(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'ByNum', 'slug' => 'bynum']);
$user->extendOrAddRole($role->id, 12);
$this->assertTrue($user->hasRole('bynum'));
}
// ─── Roles independence between users ────────────────────────
public function test_roles_are_independent_between_users(): void
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$user1->assignRole('admin');
$user2->assignRole('editor');
$this->assertTrue($user1->hasRole('admin'));
$this->assertFalse($user1->hasRole('editor'));
$this->assertTrue($user2->hasRole('editor'));
$this->assertFalse($user2->hasRole('admin'));
}
// ─── Role slug uniqueness ────────────────────────────────────
public function test_role_slug_is_auto_suffixed_on_conflict(): void
{
$role1 = Role::create(['name' => 'Admin', 'slug' => 'admin']);
$role2 = Role::create(['name' => 'Admin', 'slug' => 'admin']);
$this->assertEquals('admin', $role1->slug);
$this->assertEquals('admin-1', $role2->slug);
}
// ─── extendOrAddRoleByOrigin ────────────────────────────────
public function test_extend_by_origin_creates_new_record_with_context(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Premium', 'slug' => 'premium']);
$user->extendOrAddRoleByOrigin($role, 24, 'Monthly Sub', 'ProductPrice:abc-123');
$membership = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->first();
$this->assertNotNull($membership);
$context = json_decode($membership->context, true);
$this->assertEquals('Monthly Sub', $context['origin_name']);
$this->assertEquals('ProductPrice:abc-123', $context['origin_value']);
}
public function test_extend_by_origin_extends_active_record_from_same_origin(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Premium', 'slug' => 'premium']);
// First purchase — creates record
$user->extendOrAddRoleByOrigin($role, 24, 'Monthly Sub', 'ProductPrice:abc-123');
// Second purchase from same price — should extend, not create new
$user->extendOrAddRoleByOrigin($role, 24, 'Monthly Sub', 'ProductPrice:abc-123');
$count = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->count();
$this->assertEquals(1, $count);
// Should be ~48 hours from now
$membership = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->first();
$expiresAt = \Carbon\Carbon::parse($membership->expires_at);
$this->assertTrue($expiresAt->gt(now()->addHours(40)));
}
public function test_extend_by_origin_creates_separate_records_for_different_origins(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Premium', 'slug' => 'premium']);
// Subscription creates one record
$user->extendOrAddRoleByOrigin($role, 720, 'Monthly Sub', 'ProductPrice:sub-monthly');
// Day pass creates a separate record
$user->extendOrAddRoleByOrigin($role, 24, 'Day Pass', 'ProductPrice:day-pass');
$count = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->count();
// Both should coexist
$this->assertEquals(2, $count);
// User should have the role
$this->assertTrue($user->hasRole($role));
}
public function test_extend_by_origin_creates_new_record_when_previous_expired(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Premium', 'slug' => 'premium']);
// Insert an expired record from a subscription
DB::table(config('roles.table_names.role_member'))->insert([
'role_id' => $role->id,
'member_id' => $user->id,
'id' => (string) Str::uuid(),
'member_type' => $user->getMorphClass(),
'expires_at' => now()->subDay(),
'context' => json_encode(['origin_name' => 'Monthly Sub', 'origin_value' => 'ProductPrice:sub-monthly']),
'created_at' => now(),
'updated_at' => now(),
]);
// Same price triggers again — expired record should NOT be extended, new one created
$user->extendOrAddRoleByOrigin($role, 720, 'Monthly Sub', 'ProductPrice:sub-monthly');
$count = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->count();
// Old expired + new active = 2 records
$this->assertEquals(2, $count);
// User should have the role (active one)
$this->assertTrue($user->hasRole($role));
}
public function test_extend_by_origin_with_zero_hours_does_nothing(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Zero', 'slug' => 'zero']);
$user->extendOrAddRoleByOrigin($role, 0, 'Test', 'ProductPrice:test');
$count = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->count();
$this->assertEquals(0, $count);
}
public function test_extend_by_origin_force_expiry_sets_expiration_on_null(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Perm', 'slug' => 'perm']);
// Insert a permanent (null expires_at) record with matching origin
DB::table(config('roles.table_names.role_member'))->insert([
'role_id' => $role->id,
'member_id' => $user->id,
'id' => (string) Str::uuid(),
'member_type' => $user->getMorphClass(),
'expires_at' => null,
'context' => json_encode(['origin_name' => 'Sub', 'origin_value' => 'ProductPrice:sub']),
'created_at' => now(),
'updated_at' => now(),
]);
// With forceExpiry=true, should set expiration even on null expires_at
$user->extendOrAddRoleByOrigin($role, 24, 'Sub', 'ProductPrice:sub', true);
$membership = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->first();
$this->assertNotNull($membership->expires_at);
}
}