I context logic & tests

This commit is contained in:
Fabian @ Blax Software 2026-03-31 18:56:47 +02:00
parent 2f19523dcf
commit b780d154f2
3 changed files with 211 additions and 2 deletions

2
.gitignore vendored
View File

@ -4,4 +4,4 @@ vendor
composer.lock
.idea/
workbench
.phpunit.result.cache
.phpunit.*

View File

@ -21,7 +21,7 @@ trait HasRoles
config('roles.models.role', \Blax\Roles\Models\Role::class),
'member',
$pivotTable
)->withPivot('expires_at', 'created_at', 'updated_at')
)->withPivot('expires_at', 'context', 'created_at', 'updated_at')
->withTimestamps()
->where(function ($q) use ($pivotTable) {
$q->where($pivotTable . '.expires_at', '>', now())
@ -201,6 +201,71 @@ trait HasRoles
return $this;
}
/**
* Extend or create a role membership, scoped by an origin identifier stored in `context`.
*
* This allows multiple independent role_member records for the same role+user (e.g.,
* one from a subscription and one from a day-pass purchase). Each origin tracks its
* own expiry independently.
*
* - If an active (non-expired) record with the same origin exists extend it
* - If only expired records exist for this origin, or no record exists create new
*
* @param int|string|Role $role The role to assign/extend
* @param int $hours Duration in hours
* @param string $originName Human-readable label (e.g., product name)
* @param string $originValue Lookup key (e.g., "ProductPrice:uuid")
* @param bool $forceExpiry If true, set expiration even on records with null expires_at
* @return $this
*/
public function extendOrAddRoleByOrigin(int|string|Role $role, int $hours, string $originName, string $originValue, bool $forceExpiry = false)
{
$hours = (int) $hours;
if ($hours <= 0) {
return $this;
}
// Resolve role
if (is_string($role) && !is_numeric($role)) {
$role = config('roles.models.role', \Blax\Roles\Models\Role::class)::firstOrCreate([
'name' => $role,
], [
'slug' => str()->slug($role)
]);
} elseif (is_numeric($role)) {
$role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role);
} elseif (!$role instanceof Role) {
throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.');
}
if (!$role) {
return $this;
}
$roleMemberModel = config('roles.models.role_member', \Blax\Roles\Models\RoleMember::class);
// Look for an active (non-expired) record from the same origin
$existing = $roleMemberModel::where('role_id', $role->id)
->where('member_id', $this->getKey())
->where('member_type', $this->getMorphClass())
->whereJsonContains('context->origin_value', $originValue)
->first();
if ($existing) {
$existing->extendByHours($hours, $forceExpiry);
} else {
$this->roles()->attach($role->id, [
'expires_at' => now()->addHours($hours),
'context' => json_encode([
'origin_name' => $originName,
'origin_value' => $originValue,
]),
]);
}
return $this;
}
/**
* Checks if the memberable has any of the given roles
*

View File

@ -490,4 +490,148 @@ class HasRolesTest extends TestCase
$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,
'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,
'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);
}
}