test: add comprehensive tests for PermissionMatrixService

Cover permission checks (can), top-down immutable hierarchy cascade,
lock/unlock, setPermission, train, getPermissions/getEffective,
gateRequest (strict/non-strict/default_allow), training mode, pending
requests workflow, and PermissionResult value object.

Fixes #10

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-03-24 16:34:21 +00:00
parent 5bce748a0f
commit 6bb546be77
No known key found for this signature in database
GPG key ID: AF404715446AEB41

View file

@ -0,0 +1,849 @@
<?php
declare(strict_types=1);
use Core\Mod\Commerce\Models\Entity;
use Core\Mod\Commerce\Models\PermissionMatrix;
use Core\Mod\Commerce\Models\PermissionRequest;
use Core\Mod\Commerce\Services\PermissionLockedException;
use Core\Mod\Commerce\Services\PermissionMatrixService;
use Core\Mod\Commerce\Services\PermissionResult;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
uses(RefreshDatabase::class);
beforeEach(function () {
// Build a three-tier hierarchy: M1 -> M2 -> M3
$this->m1 = Entity::createMaster('ACME', 'Acme Corp');
$this->m2 = $this->m1->createFacade('SHOP', 'Acme Shopfront');
$this->m3 = $this->m2->createDropshipper('DROP', 'Acme Dropshipper');
// Default config: strict mode on, training mode off
config([
'commerce.matrix.training_mode' => false,
'commerce.matrix.strict_mode' => true,
'commerce.matrix.log_all_checks' => false,
'commerce.matrix.log_denials' => false,
'commerce.matrix.default_allow' => false,
]);
$this->service = new PermissionMatrixService;
});
// ---------------------------------------------------------------------------
// Hierarchy & Entity Setup
// ---------------------------------------------------------------------------
describe('entity hierarchy', function () {
it('builds a valid three-tier hierarchy', function () {
expect($this->m1->isMaster())->toBeTrue()
->and($this->m1->depth)->toBe(0)
->and($this->m1->path)->toBe('ACME');
expect($this->m2->isFacade())->toBeTrue()
->and($this->m2->depth)->toBe(1)
->and($this->m2->path)->toBe('ACME/SHOP');
expect($this->m3->isDropshipper())->toBeTrue()
->and($this->m3->depth)->toBe(2)
->and($this->m3->path)->toBe('ACME/SHOP/DROP');
});
it('returns correct ancestors for M3', function () {
$ancestors = $this->m3->getAncestors();
expect($ancestors)->toHaveCount(2)
->and($ancestors->first()->id)->toBe($this->m1->id)
->and($ancestors->last()->id)->toBe($this->m2->id);
});
it('returns correct ancestors for M2', function () {
$ancestors = $this->m2->getAncestors();
expect($ancestors)->toHaveCount(1)
->and($ancestors->first()->id)->toBe($this->m1->id);
});
it('returns empty ancestors for M1', function () {
$ancestors = $this->m1->getAncestors();
expect($ancestors)->toBeEmpty();
});
});
// ---------------------------------------------------------------------------
// can() — basic permission checks
// ---------------------------------------------------------------------------
describe('PermissionMatrixService::can()', function () {
it('returns undefined when no permission exists', function () {
$result = $this->service->can($this->m2, 'products.create');
expect($result)->toBeInstanceOf(PermissionResult::class)
->and($result->isUndefined())->toBeTrue()
->and($result->key)->toBe('products.create');
});
it('returns allowed when entity has explicit allow', function () {
PermissionMatrix::create([
'entity_id' => $this->m2->id,
'key' => 'products.create',
'scope' => null,
'allowed' => true,
'locked' => false,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
]);
$result = $this->service->can($this->m2, 'products.create');
expect($result->isAllowed())->toBeTrue();
});
it('returns denied when entity has explicit deny', function () {
PermissionMatrix::create([
'entity_id' => $this->m2->id,
'key' => 'products.create',
'scope' => null,
'allowed' => false,
'locked' => false,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
]);
$result = $this->service->can($this->m2, 'products.create');
expect($result->isDenied())->toBeTrue()
->and($result->reason)->toBe('Denied by own policy');
});
it('respects scope in permission checks', function () {
PermissionMatrix::create([
'entity_id' => $this->m2->id,
'key' => 'products.view',
'scope' => 'category-a',
'allowed' => true,
'locked' => false,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
]);
$allowed = $this->service->can($this->m2, 'products.view', 'category-a');
$undefined = $this->service->can($this->m2, 'products.view', 'category-b');
expect($allowed->isAllowed())->toBeTrue()
->and($undefined->isUndefined())->toBeTrue();
});
});
// ---------------------------------------------------------------------------
// can() — top-down immutable hierarchy rules
// ---------------------------------------------------------------------------
describe('top-down immutable permission cascade', function () {
it('denies child when M1 has locked denial', function () {
// M1 locks a denial
PermissionMatrix::create([
'entity_id' => $this->m1->id,
'key' => 'products.delete',
'scope' => null,
'allowed' => false,
'locked' => true,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
]);
// Even if M2 allows it locally, the check traverses ancestors first
PermissionMatrix::create([
'entity_id' => $this->m2->id,
'key' => 'products.delete',
'scope' => null,
'allowed' => true,
'locked' => false,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
]);
$result = $this->service->can($this->m2, 'products.delete');
expect($result->isDenied())->toBeTrue()
->and($result->isLocked())->toBeTrue()
->and($result->lockedBy->id)->toBe($this->m1->id)
->and($result->reason)->toContain('Locked by');
});
it('denies M3 when M1 locks denial even with M2 allow', function () {
PermissionMatrix::create([
'entity_id' => $this->m1->id,
'key' => 'orders.refund',
'scope' => null,
'allowed' => false,
'locked' => true,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
]);
PermissionMatrix::create([
'entity_id' => $this->m2->id,
'key' => 'orders.refund',
'scope' => null,
'allowed' => true,
'locked' => false,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
]);
$result = $this->service->can($this->m3, 'orders.refund');
expect($result->isDenied())->toBeTrue()
->and($result->isLocked())->toBeTrue();
});
it('denies M3 when M2 has unlocked denial', function () {
// M1 allows
PermissionMatrix::create([
'entity_id' => $this->m1->id,
'key' => 'products.edit',
'scope' => null,
'allowed' => true,
'locked' => false,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
]);
// M2 denies (not locked)
PermissionMatrix::create([
'entity_id' => $this->m2->id,
'key' => 'products.edit',
'scope' => null,
'allowed' => false,
'locked' => false,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
]);
$result = $this->service->can($this->m3, 'products.edit');
expect($result->isDenied())->toBeTrue()
->and($result->reason)->toContain('Denied by');
});
it('allows M2 to deny itself even when M1 allows', function () {
PermissionMatrix::create([
'entity_id' => $this->m1->id,
'key' => 'analytics.export',
'scope' => null,
'allowed' => true,
'locked' => false,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
]);
PermissionMatrix::create([
'entity_id' => $this->m2->id,
'key' => 'analytics.export',
'scope' => null,
'allowed' => false,
'locked' => false,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
]);
$result = $this->service->can($this->m2, 'analytics.export');
expect($result->isDenied())->toBeTrue()
->and($result->reason)->toBe('Denied by own policy');
});
it('allows when entire hierarchy permits', function () {
// M1 allows (not locked)
PermissionMatrix::create([
'entity_id' => $this->m1->id,
'key' => 'products.view',
'scope' => null,
'allowed' => true,
'locked' => false,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
]);
// M2 allows (not locked)
PermissionMatrix::create([
'entity_id' => $this->m2->id,
'key' => 'products.view',
'scope' => null,
'allowed' => true,
'locked' => false,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
]);
// M3 allows
PermissionMatrix::create([
'entity_id' => $this->m3->id,
'key' => 'products.view',
'scope' => null,
'allowed' => true,
'locked' => false,
'source' => PermissionMatrix::SOURCE_EXPLICIT,
]);
$result = $this->service->can($this->m3, 'products.view');
expect($result->isAllowed())->toBeTrue();
});
});
// ---------------------------------------------------------------------------
// lock() / unlock()
// ---------------------------------------------------------------------------
describe('lock() and unlock()', function () {
it('locks a permission and cascades to descendants', function () {
$this->service->lock($this->m1, 'products.delete', false);
// M1 should have a locked denial
$m1Perm = PermissionMatrix::where('entity_id', $this->m1->id)
->where('key', 'products.delete')
->first();
expect($m1Perm->locked)->toBeTrue()
->and($m1Perm->allowed)->toBeFalse()
->and($m1Perm->source)->toBe(PermissionMatrix::SOURCE_EXPLICIT);
// Descendants should inherit the lock
$m2Perm = PermissionMatrix::where('entity_id', $this->m2->id)
->where('key', 'products.delete')
->first();
$m3Perm = PermissionMatrix::where('entity_id', $this->m3->id)
->where('key', 'products.delete')
->first();
expect($m2Perm->locked)->toBeTrue()
->and($m2Perm->allowed)->toBeFalse()
->and($m2Perm->source)->toBe(PermissionMatrix::SOURCE_INHERITED)
->and($m2Perm->set_by_entity_id)->toBe($this->m1->id);
expect($m3Perm->locked)->toBeTrue()
->and($m3Perm->allowed)->toBeFalse()
->and($m3Perm->source)->toBe(PermissionMatrix::SOURCE_INHERITED);
});
it('locks an allow permission and cascades to descendants', function () {
$this->service->lock($this->m1, 'products.view', true);
$m1Perm = PermissionMatrix::where('entity_id', $this->m1->id)
->where('key', 'products.view')
->first();
expect($m1Perm->locked)->toBeTrue()
->and($m1Perm->allowed)->toBeTrue();
$m2Perm = PermissionMatrix::where('entity_id', $this->m2->id)
->where('key', 'products.view')
->first();
expect($m2Perm->locked)->toBeTrue()
->and($m2Perm->allowed)->toBeTrue();
});
it('locks with scope', function () {
$this->service->lock($this->m1, 'products.view', false, 'premium');
$m1Perm = PermissionMatrix::where('entity_id', $this->m1->id)
->where('key', 'products.view')
->where('scope', 'premium')
->first();
expect($m1Perm)->not->toBeNull()
->and($m1Perm->locked)->toBeTrue();
});
it('unlocks a permission and removes inherited locks from descendants', function () {
// First lock it
$this->service->lock($this->m1, 'products.delete', false);
// Verify it cascaded
expect(PermissionMatrix::where('key', 'products.delete')->count())->toBe(3);
// Now unlock
$this->service->unlock($this->m1, 'products.delete');
// M1 should be unlocked
$m1Perm = PermissionMatrix::where('entity_id', $this->m1->id)
->where('key', 'products.delete')
->first();
expect($m1Perm->locked)->toBeFalse()
->and($m1Perm->source)->toBe(PermissionMatrix::SOURCE_EXPLICIT);
// Descendants' inherited locks should be removed
$m2Perm = PermissionMatrix::where('entity_id', $this->m2->id)
->where('key', 'products.delete')
->first();
$m3Perm = PermissionMatrix::where('entity_id', $this->m3->id)
->where('key', 'products.delete')
->first();
expect($m2Perm)->toBeNull()
->and($m3Perm)->toBeNull();
});
});
// ---------------------------------------------------------------------------
// setPermission()
// ---------------------------------------------------------------------------
describe('setPermission()', function () {
it('creates an explicit permission', function () {
$perm = $this->service->setPermission($this->m2, 'products.create', true);
expect($perm)->toBeInstanceOf(PermissionMatrix::class)
->and($perm->entity_id)->toBe($this->m2->id)
->and($perm->key)->toBe('products.create')
->and($perm->allowed)->toBeTrue()
->and($perm->locked)->toBeFalse()
->and($perm->source)->toBe(PermissionMatrix::SOURCE_EXPLICIT);
});
it('creates a scoped permission', function () {
$perm = $this->service->setPermission($this->m2, 'products.view', true, 'category-a');
expect($perm->scope)->toBe('category-a');
});
it('updates an existing permission', function () {
$this->service->setPermission($this->m2, 'products.create', true);
$this->service->setPermission($this->m2, 'products.create', false);
$perms = PermissionMatrix::where('entity_id', $this->m2->id)
->where('key', 'products.create')
->get();
expect($perms)->toHaveCount(1)
->and($perms->first()->allowed)->toBeFalse();
});
it('throws PermissionLockedException when parent has locked denial and trying to allow', function () {
// M1 locks a denial
$this->service->lock($this->m1, 'products.delete', false);
expect(fn () => $this->service->setPermission($this->m2, 'products.delete', true))
->toThrow(PermissionLockedException::class);
});
it('allows setting denial even when parent has locked denial', function () {
// M1 locks a denial
$this->service->lock($this->m1, 'products.delete', false);
// Setting a denial should work (not trying to override the lock)
$perm = $this->service->setPermission($this->m2, 'products.delete', false);
expect($perm->allowed)->toBeFalse();
});
it('allows setting permission when no parent lock exists', function () {
$perm = $this->service->setPermission($this->m3, 'products.create', true);
expect($perm->allowed)->toBeTrue();
});
});
// ---------------------------------------------------------------------------
// train()
// ---------------------------------------------------------------------------
describe('train()', function () {
it('creates a trained permission', function () {
$perm = $this->service->train($this->m2, 'orders.create', null, true, '/api/orders');
expect($perm)->toBeInstanceOf(PermissionMatrix::class)
->and($perm->allowed)->toBeTrue()
->and($perm->source)->toBe(PermissionMatrix::SOURCE_TRAINED)
->and($perm->trained_route)->toBe('/api/orders')
->and($perm->trained_at)->not->toBeNull();
});
it('updates existing permission via training', function () {
$this->service->train($this->m2, 'orders.create', null, true);
$this->service->train($this->m2, 'orders.create', null, false);
$perms = PermissionMatrix::where('entity_id', $this->m2->id)
->where('key', 'orders.create')
->get();
expect($perms)->toHaveCount(1)
->and($perms->first()->allowed)->toBeFalse();
});
it('throws PermissionLockedException when parent has locked denial', function () {
$this->service->lock($this->m1, 'products.delete', false);
expect(fn () => $this->service->train($this->m2, 'products.delete', null, true))
->toThrow(PermissionLockedException::class);
});
it('allows training a denial even when parent has locked denial', function () {
$this->service->lock($this->m1, 'products.delete', false);
// Training a denial should not throw (the lock check only prevents allow)
$perm = $this->service->train($this->m2, 'products.delete', null, false);
expect($perm->allowed)->toBeFalse();
});
});
// ---------------------------------------------------------------------------
// getPermissions() / getEffectivePermissions()
// ---------------------------------------------------------------------------
describe('getPermissions()', function () {
it('returns only own permissions', function () {
$this->service->setPermission($this->m1, 'products.view', true);
$this->service->setPermission($this->m2, 'products.create', true);
$this->service->setPermission($this->m2, 'orders.view', false);
$m2Perms = $this->service->getPermissions($this->m2);
expect($m2Perms)->toHaveCount(2)
->and($m2Perms->pluck('key')->toArray())->toEqualCanonicalizing(['orders.view', 'products.create']);
});
it('returns empty collection when no permissions set', function () {
$perms = $this->service->getPermissions($this->m3);
expect($perms)->toBeEmpty();
});
});
describe('getEffectivePermissions()', function () {
it('includes inherited permissions from ancestors', function () {
$this->service->setPermission($this->m1, 'products.view', true);
$this->service->setPermission($this->m2, 'orders.create', true);
$this->service->setPermission($this->m3, 'reports.view', true);
$effective = $this->service->getEffectivePermissions($this->m3);
expect($effective)->toHaveCount(3)
->and($effective->keys()->toArray())->toEqualCanonicalizing([
'products.view',
'orders.create',
'reports.view',
]);
});
it('locked denial wins over entity own allow', function () {
// M1 locks denial
$this->service->lock($this->m1, 'products.delete', false);
// M2 explicitly allows (should not matter)
$this->service->setPermission($this->m2, 'products.delete', true);
$effective = $this->service->getEffectivePermissions($this->m2);
$deletePerm = $effective->get('products.delete');
expect($deletePerm)->not->toBeNull()
->and($deletePerm->allowed)->toBeFalse()
->and($deletePerm->locked)->toBeTrue();
});
it('returns entity own permission when no locks exist', function () {
$this->service->setPermission($this->m1, 'products.view', true);
$this->service->setPermission($this->m2, 'products.view', false);
$effective = $this->service->getEffectivePermissions($this->m2);
$viewPerm = $effective->get('products.view');
expect($viewPerm->entity_id)->toBe($this->m2->id)
->and($viewPerm->allowed)->toBeFalse();
});
});
// ---------------------------------------------------------------------------
// gateRequest()
// ---------------------------------------------------------------------------
describe('gateRequest()', function () {
it('returns denied for undefined permission in strict mode', function () {
config(['commerce.matrix.strict_mode' => true]);
$service = new PermissionMatrixService;
$request = Request::create('/api/products', 'GET');
$result = $service->gateRequest($request, $this->m2, 'products.create');
expect($result->isDenied())->toBeTrue()
->and($result->reason)->toContain('No permission defined');
});
it('returns denied for undefined permission when non-strict and default_allow is false', function () {
config([
'commerce.matrix.strict_mode' => false,
'commerce.matrix.training_mode' => false,
'commerce.matrix.default_allow' => false,
]);
$service = new PermissionMatrixService;
$request = Request::create('/api/products', 'GET');
$result = $service->gateRequest($request, $this->m2, 'products.create');
expect($result->isDenied())->toBeTrue()
->and($result->reason)->toContain('No permission defined');
});
it('returns allowed for undefined permission when non-strict and default_allow is true', function () {
config([
'commerce.matrix.strict_mode' => false,
'commerce.matrix.training_mode' => false,
'commerce.matrix.default_allow' => true,
]);
$service = new PermissionMatrixService;
$request = Request::create('/api/products', 'GET');
$result = $service->gateRequest($request, $this->m2, 'products.create');
expect($result->isAllowed())->toBeTrue();
});
it('returns allowed when permission is explicitly set', function () {
$this->service->setPermission($this->m2, 'products.view', true);
$request = Request::create('/api/products', 'GET');
$result = $this->service->gateRequest($request, $this->m2, 'products.view');
expect($result->isAllowed())->toBeTrue();
});
it('returns denied when permission is explicitly denied', function () {
$this->service->setPermission($this->m2, 'products.delete', false);
$request = Request::create('/api/products/1', 'DELETE');
$result = $this->service->gateRequest($request, $this->m2, 'products.delete');
expect($result->isDenied())->toBeTrue();
});
});
// ---------------------------------------------------------------------------
// Training mode
// ---------------------------------------------------------------------------
describe('training mode', function () {
it('reports training mode status', function () {
config(['commerce.matrix.training_mode' => true]);
$service = new PermissionMatrixService;
expect($service->isTrainingMode())->toBeTrue();
config(['commerce.matrix.training_mode' => false]);
$service2 = new PermissionMatrixService;
expect($service2->isTrainingMode())->toBeFalse();
});
it('reports strict mode status', function () {
config(['commerce.matrix.strict_mode' => true]);
$service = new PermissionMatrixService;
expect($service->isStrictMode())->toBeTrue();
config(['commerce.matrix.strict_mode' => false]);
$service2 = new PermissionMatrixService;
expect($service2->isStrictMode())->toBeFalse();
});
});
// ---------------------------------------------------------------------------
// getPendingRequests() / markRequestsTrained()
// ---------------------------------------------------------------------------
describe('pending requests and training workflow', function () {
it('returns pending untrained requests', function () {
PermissionRequest::create([
'entity_id' => $this->m2->id,
'method' => 'GET',
'route' => '/api/products',
'action' => 'products.view',
'status' => PermissionRequest::STATUS_PENDING,
'was_trained' => false,
]);
PermissionRequest::create([
'entity_id' => $this->m2->id,
'method' => 'POST',
'route' => '/api/products',
'action' => 'products.create',
'status' => PermissionRequest::STATUS_PENDING,
'was_trained' => false,
]);
$pending = $this->service->getPendingRequests();
expect($pending)->toHaveCount(2);
});
it('filters pending requests by entity', function () {
PermissionRequest::create([
'entity_id' => $this->m2->id,
'method' => 'GET',
'route' => '/api/products',
'action' => 'products.view',
'status' => PermissionRequest::STATUS_PENDING,
'was_trained' => false,
]);
PermissionRequest::create([
'entity_id' => $this->m3->id,
'method' => 'GET',
'route' => '/api/products',
'action' => 'products.view',
'status' => PermissionRequest::STATUS_PENDING,
'was_trained' => false,
]);
$m2Pending = $this->service->getPendingRequests($this->m2);
$m3Pending = $this->service->getPendingRequests($this->m3);
expect($m2Pending)->toHaveCount(1)
->and($m3Pending)->toHaveCount(1);
});
it('excludes already-trained requests', function () {
PermissionRequest::create([
'entity_id' => $this->m2->id,
'method' => 'GET',
'route' => '/api/products',
'action' => 'products.view',
'status' => PermissionRequest::STATUS_PENDING,
'was_trained' => true,
'trained_at' => now(),
]);
$pending = $this->service->getPendingRequests($this->m2);
expect($pending)->toBeEmpty();
});
it('excludes non-pending requests', function () {
PermissionRequest::create([
'entity_id' => $this->m2->id,
'method' => 'GET',
'route' => '/api/products',
'action' => 'products.view',
'status' => PermissionRequest::STATUS_ALLOWED,
'was_trained' => false,
]);
$pending = $this->service->getPendingRequests($this->m2);
expect($pending)->toBeEmpty();
});
it('marks pending requests as trained', function () {
PermissionRequest::create([
'entity_id' => $this->m2->id,
'method' => 'GET',
'route' => '/api/products',
'action' => 'products.view',
'status' => PermissionRequest::STATUS_PENDING,
'was_trained' => false,
]);
PermissionRequest::create([
'entity_id' => $this->m2->id,
'method' => 'GET',
'route' => '/api/products/1',
'action' => 'products.view',
'status' => PermissionRequest::STATUS_PENDING,
'was_trained' => false,
]);
$count = $this->service->markRequestsTrained($this->m2, 'products.view');
expect($count)->toBe(2);
$remaining = $this->service->getPendingRequests($this->m2);
expect($remaining)->toBeEmpty();
});
it('only marks matching action requests as trained', function () {
PermissionRequest::create([
'entity_id' => $this->m2->id,
'method' => 'GET',
'route' => '/api/products',
'action' => 'products.view',
'status' => PermissionRequest::STATUS_PENDING,
'was_trained' => false,
]);
PermissionRequest::create([
'entity_id' => $this->m2->id,
'method' => 'POST',
'route' => '/api/products',
'action' => 'products.create',
'status' => PermissionRequest::STATUS_PENDING,
'was_trained' => false,
]);
$count = $this->service->markRequestsTrained($this->m2, 'products.view');
expect($count)->toBe(1);
$pending = $this->service->getPendingRequests($this->m2);
expect($pending)->toHaveCount(1)
->and($pending->first()->action)->toBe('products.create');
});
});
// ---------------------------------------------------------------------------
// PermissionResult value object
// ---------------------------------------------------------------------------
describe('PermissionResult', function () {
it('creates allowed result', function () {
$result = PermissionResult::allowed();
expect($result->isAllowed())->toBeTrue()
->and($result->isDenied())->toBeFalse()
->and($result->isPending())->toBeFalse()
->and($result->isUndefined())->toBeFalse()
->and($result->isLocked())->toBeFalse();
});
it('creates denied result with reason', function () {
$result = PermissionResult::denied('Test reason');
expect($result->isDenied())->toBeTrue()
->and($result->reason)->toBe('Test reason')
->and($result->isLocked())->toBeFalse();
});
it('creates denied result with locked-by entity', function () {
$result = PermissionResult::denied('Locked by ACME', $this->m1);
expect($result->isDenied())->toBeTrue()
->and($result->isLocked())->toBeTrue()
->and($result->lockedBy->id)->toBe($this->m1->id);
});
it('creates undefined result with key and scope', function () {
$result = PermissionResult::undefined('products.view', 'premium');
expect($result->isUndefined())->toBeTrue()
->and($result->key)->toBe('products.view')
->and($result->scope)->toBe('premium');
});
it('converts to array correctly', function () {
$result = PermissionResult::denied('Locked by ACME', $this->m1);
$array = $result->toArray();
expect($array)->toHaveKey('status', 'denied')
->and($array)->toHaveKey('reason', 'Locked by ACME')
->and($array)->toHaveKey('locked_by', 'Acme Corp');
});
it('filters null values from array', function () {
$result = PermissionResult::allowed();
$array = $result->toArray();
expect($array)->toBe(['status' => 'allowed'])
->and($array)->not->toHaveKey('reason')
->and($array)->not->toHaveKey('locked_by')
->and($array)->not->toHaveKey('key')
->and($array)->not->toHaveKey('scope');
});
});