diff --git a/tests/Feature/PermissionMatrixServiceTest.php b/tests/Feature/PermissionMatrixServiceTest.php new file mode 100644 index 0000000..c356b2e --- /dev/null +++ b/tests/Feature/PermissionMatrixServiceTest.php @@ -0,0 +1,849 @@ + 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'); + }); +});