laravel-roles/tests/Unit/HasRolesTest.php

646 lines
22 KiB
PHP
Raw Permalink Normal View History

2026-02-24 11:07:32 +00:00
<?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;
fix: align schema with HasUuids design + add reusable MorphAliasRegistry The package's models (Permission, PermissionMember, Role, RoleMember, Access, RequiredAccess) all use HasUuids but the published create migrations created bigint columns. Every insert blew up in production with 'Incorrect integer value: <uuid> for column id'. Migrations - create_blax_role_tables: uuid PK + uuidMorphs throughout - create_blax_access_table: uuid PK + uuidMorphs/nullableUuidMorphs - create_required_accesses_table: uuid PK + uuidMorphs - add_source_to_accesses_table: nullableUuidMorphs Two upgrade migrations convert in-place for hosts with existing data: - 2026_04_29_000001 fixes required_accesses (idempotent, drops empty table or leaves correct schema alone) - 2026_04_29_000002 fixes the rest (permissions, permission_members, permission_usages, roles, role_members, accesses) by adding staging uuid columns, generating UUIDs per row, propagating into FK columns, swapping in place, and rebuilding FK constraints. MySQL-only; SQLite hosts get the correct schema directly from the create migration. Idempotent (no-op on already-uuid schemas). Models / traits - Permission/PermissionMember restored to HasUuids (the schema fix removes the conflict with the bigint id columns) - RoleMember constructor was looking up the wrong config key (role_members instead of role_member) and falling through to a non-pluralised parent::getTable() - HasRoles/HasPermissions now treat UUID strings as ids; previously they were misinterpreted as role/permission names, so passing $role->id to assignRole created a new role keyed by the UUID - extendOrAddRoleByOrigin no longer json_encodes the context array; the RoleMember 'context' cast handles it (it was double-encoding) Reusable infrastructure - MorphAliasRegistry: central alias <-> FQCN map with custom per-class alias and name resolvers. Auto-bound as a singleton in RolesServiceProvider; hosts register their own (alias, FQCN) pairs - HasRequiredAccess gained addRequiredAccessByAlias / removeRequiredAccessByAlias / requiredAccessAdminPayload helpers - RequiredAccess::toAdminArray serializes a link via the registry Test fixtures - Manual DB::table()->insert() pivot rows now pass an explicit id since pivot inserts don't go through HasUuids - All 162 package tests passing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:48:51 +00:00
use Illuminate\Support\Str;
2026-02-24 11:07:32 +00:00
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);
2026-02-24 11:07:32 +00:00
}
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,
fix: align schema with HasUuids design + add reusable MorphAliasRegistry The package's models (Permission, PermissionMember, Role, RoleMember, Access, RequiredAccess) all use HasUuids but the published create migrations created bigint columns. Every insert blew up in production with 'Incorrect integer value: <uuid> for column id'. Migrations - create_blax_role_tables: uuid PK + uuidMorphs throughout - create_blax_access_table: uuid PK + uuidMorphs/nullableUuidMorphs - create_required_accesses_table: uuid PK + uuidMorphs - add_source_to_accesses_table: nullableUuidMorphs Two upgrade migrations convert in-place for hosts with existing data: - 2026_04_29_000001 fixes required_accesses (idempotent, drops empty table or leaves correct schema alone) - 2026_04_29_000002 fixes the rest (permissions, permission_members, permission_usages, roles, role_members, accesses) by adding staging uuid columns, generating UUIDs per row, propagating into FK columns, swapping in place, and rebuilding FK constraints. MySQL-only; SQLite hosts get the correct schema directly from the create migration. Idempotent (no-op on already-uuid schemas). Models / traits - Permission/PermissionMember restored to HasUuids (the schema fix removes the conflict with the bigint id columns) - RoleMember constructor was looking up the wrong config key (role_members instead of role_member) and falling through to a non-pluralised parent::getTable() - HasRoles/HasPermissions now treat UUID strings as ids; previously they were misinterpreted as role/permission names, so passing $role->id to assignRole created a new role keyed by the UUID - extendOrAddRoleByOrigin no longer json_encodes the context array; the RoleMember 'context' cast handles it (it was double-encoding) Reusable infrastructure - MorphAliasRegistry: central alias <-> FQCN map with custom per-class alias and name resolvers. Auto-bound as a singleton in RolesServiceProvider; hosts register their own (alias, FQCN) pairs - HasRequiredAccess gained addRequiredAccessByAlias / removeRequiredAccessByAlias / requiredAccessAdminPayload helpers - RequiredAccess::toAdminArray serializes a link via the registry Test fixtures - Manual DB::table()->insert() pivot rows now pass an explicit id since pivot inserts don't go through HasUuids - All 162 package tests passing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:48:51 +00:00
'id' => (string) Str::uuid(),
2026-02-24 11:07:32 +00:00
'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,
fix: align schema with HasUuids design + add reusable MorphAliasRegistry The package's models (Permission, PermissionMember, Role, RoleMember, Access, RequiredAccess) all use HasUuids but the published create migrations created bigint columns. Every insert blew up in production with 'Incorrect integer value: <uuid> for column id'. Migrations - create_blax_role_tables: uuid PK + uuidMorphs throughout - create_blax_access_table: uuid PK + uuidMorphs/nullableUuidMorphs - create_required_accesses_table: uuid PK + uuidMorphs - add_source_to_accesses_table: nullableUuidMorphs Two upgrade migrations convert in-place for hosts with existing data: - 2026_04_29_000001 fixes required_accesses (idempotent, drops empty table or leaves correct schema alone) - 2026_04_29_000002 fixes the rest (permissions, permission_members, permission_usages, roles, role_members, accesses) by adding staging uuid columns, generating UUIDs per row, propagating into FK columns, swapping in place, and rebuilding FK constraints. MySQL-only; SQLite hosts get the correct schema directly from the create migration. Idempotent (no-op on already-uuid schemas). Models / traits - Permission/PermissionMember restored to HasUuids (the schema fix removes the conflict with the bigint id columns) - RoleMember constructor was looking up the wrong config key (role_members instead of role_member) and falling through to a non-pluralised parent::getTable() - HasRoles/HasPermissions now treat UUID strings as ids; previously they were misinterpreted as role/permission names, so passing $role->id to assignRole created a new role keyed by the UUID - extendOrAddRoleByOrigin no longer json_encodes the context array; the RoleMember 'context' cast handles it (it was double-encoding) Reusable infrastructure - MorphAliasRegistry: central alias <-> FQCN map with custom per-class alias and name resolvers. Auto-bound as a singleton in RolesServiceProvider; hosts register their own (alias, FQCN) pairs - HasRequiredAccess gained addRequiredAccessByAlias / removeRequiredAccessByAlias / requiredAccessAdminPayload helpers - RequiredAccess::toAdminArray serializes a link via the registry Test fixtures - Manual DB::table()->insert() pivot rows now pass an explicit id since pivot inserts don't go through HasUuids - All 162 package tests passing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:48:51 +00:00
'id' => (string) Str::uuid(),
2026-02-24 11:07:32 +00:00
'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,
fix: align schema with HasUuids design + add reusable MorphAliasRegistry The package's models (Permission, PermissionMember, Role, RoleMember, Access, RequiredAccess) all use HasUuids but the published create migrations created bigint columns. Every insert blew up in production with 'Incorrect integer value: <uuid> for column id'. Migrations - create_blax_role_tables: uuid PK + uuidMorphs throughout - create_blax_access_table: uuid PK + uuidMorphs/nullableUuidMorphs - create_required_accesses_table: uuid PK + uuidMorphs - add_source_to_accesses_table: nullableUuidMorphs Two upgrade migrations convert in-place for hosts with existing data: - 2026_04_29_000001 fixes required_accesses (idempotent, drops empty table or leaves correct schema alone) - 2026_04_29_000002 fixes the rest (permissions, permission_members, permission_usages, roles, role_members, accesses) by adding staging uuid columns, generating UUIDs per row, propagating into FK columns, swapping in place, and rebuilding FK constraints. MySQL-only; SQLite hosts get the correct schema directly from the create migration. Idempotent (no-op on already-uuid schemas). Models / traits - Permission/PermissionMember restored to HasUuids (the schema fix removes the conflict with the bigint id columns) - RoleMember constructor was looking up the wrong config key (role_members instead of role_member) and falling through to a non-pluralised parent::getTable() - HasRoles/HasPermissions now treat UUID strings as ids; previously they were misinterpreted as role/permission names, so passing $role->id to assignRole created a new role keyed by the UUID - extendOrAddRoleByOrigin no longer json_encodes the context array; the RoleMember 'context' cast handles it (it was double-encoding) Reusable infrastructure - MorphAliasRegistry: central alias <-> FQCN map with custom per-class alias and name resolvers. Auto-bound as a singleton in RolesServiceProvider; hosts register their own (alias, FQCN) pairs - HasRequiredAccess gained addRequiredAccessByAlias / removeRequiredAccessByAlias / requiredAccessAdminPayload helpers - RequiredAccess::toAdminArray serializes a link via the registry Test fixtures - Manual DB::table()->insert() pivot rows now pass an explicit id since pivot inserts don't go through HasUuids - All 162 package tests passing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:48:51 +00:00
'id' => (string) Str::uuid(),
2026-02-24 11:07:32 +00:00
'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,
fix: align schema with HasUuids design + add reusable MorphAliasRegistry The package's models (Permission, PermissionMember, Role, RoleMember, Access, RequiredAccess) all use HasUuids but the published create migrations created bigint columns. Every insert blew up in production with 'Incorrect integer value: <uuid> for column id'. Migrations - create_blax_role_tables: uuid PK + uuidMorphs throughout - create_blax_access_table: uuid PK + uuidMorphs/nullableUuidMorphs - create_required_accesses_table: uuid PK + uuidMorphs - add_source_to_accesses_table: nullableUuidMorphs Two upgrade migrations convert in-place for hosts with existing data: - 2026_04_29_000001 fixes required_accesses (idempotent, drops empty table or leaves correct schema alone) - 2026_04_29_000002 fixes the rest (permissions, permission_members, permission_usages, roles, role_members, accesses) by adding staging uuid columns, generating UUIDs per row, propagating into FK columns, swapping in place, and rebuilding FK constraints. MySQL-only; SQLite hosts get the correct schema directly from the create migration. Idempotent (no-op on already-uuid schemas). Models / traits - Permission/PermissionMember restored to HasUuids (the schema fix removes the conflict with the bigint id columns) - RoleMember constructor was looking up the wrong config key (role_members instead of role_member) and falling through to a non-pluralised parent::getTable() - HasRoles/HasPermissions now treat UUID strings as ids; previously they were misinterpreted as role/permission names, so passing $role->id to assignRole created a new role keyed by the UUID - extendOrAddRoleByOrigin no longer json_encodes the context array; the RoleMember 'context' cast handles it (it was double-encoding) Reusable infrastructure - MorphAliasRegistry: central alias <-> FQCN map with custom per-class alias and name resolvers. Auto-bound as a singleton in RolesServiceProvider; hosts register their own (alias, FQCN) pairs - HasRequiredAccess gained addRequiredAccessByAlias / removeRequiredAccessByAlias / requiredAccessAdminPayload helpers - RequiredAccess::toAdminArray serializes a link via the registry Test fixtures - Manual DB::table()->insert() pivot rows now pass an explicit id since pivot inserts don't go through HasUuids - All 162 package tests passing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:48:51 +00:00
'id' => (string) Str::uuid(),
2026-02-24 11:07:32 +00:00
'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);
}
2026-03-31 16:56:47 +00:00
// ─── 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,
fix: align schema with HasUuids design + add reusable MorphAliasRegistry The package's models (Permission, PermissionMember, Role, RoleMember, Access, RequiredAccess) all use HasUuids but the published create migrations created bigint columns. Every insert blew up in production with 'Incorrect integer value: <uuid> for column id'. Migrations - create_blax_role_tables: uuid PK + uuidMorphs throughout - create_blax_access_table: uuid PK + uuidMorphs/nullableUuidMorphs - create_required_accesses_table: uuid PK + uuidMorphs - add_source_to_accesses_table: nullableUuidMorphs Two upgrade migrations convert in-place for hosts with existing data: - 2026_04_29_000001 fixes required_accesses (idempotent, drops empty table or leaves correct schema alone) - 2026_04_29_000002 fixes the rest (permissions, permission_members, permission_usages, roles, role_members, accesses) by adding staging uuid columns, generating UUIDs per row, propagating into FK columns, swapping in place, and rebuilding FK constraints. MySQL-only; SQLite hosts get the correct schema directly from the create migration. Idempotent (no-op on already-uuid schemas). Models / traits - Permission/PermissionMember restored to HasUuids (the schema fix removes the conflict with the bigint id columns) - RoleMember constructor was looking up the wrong config key (role_members instead of role_member) and falling through to a non-pluralised parent::getTable() - HasRoles/HasPermissions now treat UUID strings as ids; previously they were misinterpreted as role/permission names, so passing $role->id to assignRole created a new role keyed by the UUID - extendOrAddRoleByOrigin no longer json_encodes the context array; the RoleMember 'context' cast handles it (it was double-encoding) Reusable infrastructure - MorphAliasRegistry: central alias <-> FQCN map with custom per-class alias and name resolvers. Auto-bound as a singleton in RolesServiceProvider; hosts register their own (alias, FQCN) pairs - HasRequiredAccess gained addRequiredAccessByAlias / removeRequiredAccessByAlias / requiredAccessAdminPayload helpers - RequiredAccess::toAdminArray serializes a link via the registry Test fixtures - Manual DB::table()->insert() pivot rows now pass an explicit id since pivot inserts don't go through HasUuids - All 162 package tests passing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:48:51 +00:00
'id' => (string) Str::uuid(),
2026-03-31 16:56:47 +00:00
'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,
fix: align schema with HasUuids design + add reusable MorphAliasRegistry The package's models (Permission, PermissionMember, Role, RoleMember, Access, RequiredAccess) all use HasUuids but the published create migrations created bigint columns. Every insert blew up in production with 'Incorrect integer value: <uuid> for column id'. Migrations - create_blax_role_tables: uuid PK + uuidMorphs throughout - create_blax_access_table: uuid PK + uuidMorphs/nullableUuidMorphs - create_required_accesses_table: uuid PK + uuidMorphs - add_source_to_accesses_table: nullableUuidMorphs Two upgrade migrations convert in-place for hosts with existing data: - 2026_04_29_000001 fixes required_accesses (idempotent, drops empty table or leaves correct schema alone) - 2026_04_29_000002 fixes the rest (permissions, permission_members, permission_usages, roles, role_members, accesses) by adding staging uuid columns, generating UUIDs per row, propagating into FK columns, swapping in place, and rebuilding FK constraints. MySQL-only; SQLite hosts get the correct schema directly from the create migration. Idempotent (no-op on already-uuid schemas). Models / traits - Permission/PermissionMember restored to HasUuids (the schema fix removes the conflict with the bigint id columns) - RoleMember constructor was looking up the wrong config key (role_members instead of role_member) and falling through to a non-pluralised parent::getTable() - HasRoles/HasPermissions now treat UUID strings as ids; previously they were misinterpreted as role/permission names, so passing $role->id to assignRole created a new role keyed by the UUID - extendOrAddRoleByOrigin no longer json_encodes the context array; the RoleMember 'context' cast handles it (it was double-encoding) Reusable infrastructure - MorphAliasRegistry: central alias <-> FQCN map with custom per-class alias and name resolvers. Auto-bound as a singleton in RolesServiceProvider; hosts register their own (alias, FQCN) pairs - HasRequiredAccess gained addRequiredAccessByAlias / removeRequiredAccessByAlias / requiredAccessAdminPayload helpers - RequiredAccess::toAdminArray serializes a link via the registry Test fixtures - Manual DB::table()->insert() pivot rows now pass an explicit id since pivot inserts don't go through HasUuids - All 162 package tests passing Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:48:51 +00:00
'id' => (string) Str::uuid(),
2026-03-31 16:56:47 +00:00
'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);
}
2026-02-24 11:07:32 +00:00
}