test(entitlements): add namespace-level entitlement tests (P2-021)

Add comprehensive Pest tests for namespace-level entitlements including:
- canForNamespace() with user-owned and workspace-owned namespaces
- Entitlement cascade (namespace -> workspace -> user tier)
- provisionNamespacePackage() with replacement, expiry, metadata
- provisionNamespaceBoost() with stacking and unlimited overrides
- recordNamespaceUsage() with metadata and workspace context
- getNamespaceUsageSummary() with percentages and near-limit detection
- invalidateNamespaceCache() for limits and usage
- Multiple namespaces with separate usage tracking
- Boost stacking behaviour

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 18:41:24 +00:00
parent 67b5b14b8e
commit a067453a6a
2 changed files with 670 additions and 6 deletions

18
TODO.md
View file

@ -147,16 +147,22 @@ The EntitlementService is the core API for entitlement checks but lacks comprehe
---
### TEST-001: Add tests for namespace-level entitlements
**Status:** Open
**Status:** Fixed (2026-01-29)
**File:** `tests/Feature/EntitlementServiceTest.php`
The test file covers workspace-level entitlements but not namespace-level (`canForNamespace`, `recordNamespaceUsage`, etc.).
**Acceptance Criteria:**
- Test `canForNamespace()` with various ownership scenarios
- Test entitlement cascade (namespace -> workspace -> user tier)
- Test `provisionNamespacePackage()` and `provisionNamespaceBoost()`
- Test namespace cache invalidation
**Resolution:**
- Added comprehensive test suite for namespace-level entitlements
- Tests `canForNamespace()` with user-owned and workspace-owned namespaces
- Tests entitlement cascade (namespace -> workspace -> user tier) with various scenarios
- Tests `provisionNamespacePackage()` including package replacement, expiry, and metadata
- Tests `provisionNamespaceBoost()` including stacking, unlimited boosts, and expiry
- Tests `recordNamespaceUsage()` with metadata and workspace context
- Tests `getNamespaceUsageSummary()` with usage percentages and near-limit detection
- Tests `invalidateNamespaceCache()` for both limits and usage
- Tests multiple namespaces with different entitlements and usage tracking
- Tests boost stacking behaviour including unlimited override
---

View file

@ -1,8 +1,13 @@
<?php
declare(strict_types=1);
use Core\Tenant\Enums\UserTier;
use Core\Tenant\Models\Boost;
use Core\Tenant\Models\EntitlementLog;
use Core\Tenant\Models\Feature;
use Core\Tenant\Models\Namespace_;
use Core\Tenant\Models\NamespacePackage;
use Core\Tenant\Models\Package;
use Core\Tenant\Models\UsageRecord;
use Core\Tenant\Models\User;
@ -609,6 +614,659 @@ describe('EntitlementService', function () {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Namespace-Level Entitlement Tests
// ─────────────────────────────────────────────────────────────────────────────
describe('Namespace Entitlements', function () {
beforeEach(function () {
// Clear cache before each test
Cache::flush();
// Create test user and workspace
$this->user = User::factory()->create(['tier' => UserTier::APOLLO]);
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
// Create a user-owned namespace
$this->userNamespace = Namespace_::create([
'name' => 'User Namespace',
'slug' => 'user-ns',
'owner_type' => User::class,
'owner_id' => $this->user->id,
'is_active' => true,
]);
// Create a workspace-owned namespace
$this->workspaceNamespace = Namespace_::create([
'name' => 'Workspace Namespace',
'slug' => 'workspace-ns',
'owner_type' => Workspace::class,
'owner_id' => $this->workspace->id,
'workspace_id' => $this->workspace->id,
'is_active' => true,
]);
// Create a namespace with explicit workspace context (user-owned but billed through workspace)
$this->billedNamespace = Namespace_::create([
'name' => 'Billed Namespace',
'slug' => 'billed-ns',
'owner_type' => User::class,
'owner_id' => $this->user->id,
'workspace_id' => $this->workspace->id,
'is_active' => true,
]);
// Create features
$this->linksFeature = Feature::create([
'code' => 'links',
'name' => 'Links',
'description' => 'Number of links allowed',
'category' => 'content',
'type' => Feature::TYPE_LIMIT,
'reset_type' => Feature::RESET_NONE,
'is_active' => true,
'sort_order' => 1,
]);
$this->customDomainFeature = Feature::create([
'code' => 'custom_domain',
'name' => 'Custom Domain',
'description' => 'Custom domain support',
'category' => 'features',
'type' => Feature::TYPE_BOOLEAN,
'reset_type' => Feature::RESET_NONE,
'is_active' => true,
'sort_order' => 2,
]);
$this->pageViewsFeature = Feature::create([
'code' => 'page_views',
'name' => 'Page Views',
'description' => 'Monthly page views',
'category' => 'analytics',
'type' => Feature::TYPE_LIMIT,
'reset_type' => Feature::RESET_MONTHLY,
'is_active' => true,
'sort_order' => 3,
]);
// Create packages
$this->bioFreePackage = Package::create([
'code' => 'bio-free',
'name' => 'Bio Free',
'description' => 'Free bio plan',
'is_stackable' => false,
'is_base_package' => true,
'is_active' => true,
'is_public' => true,
'sort_order' => 1,
]);
$this->bioProPackage = Package::create([
'code' => 'bio-pro',
'name' => 'Bio Pro',
'description' => 'Professional bio plan',
'is_stackable' => false,
'is_base_package' => true,
'is_active' => true,
'is_public' => true,
'sort_order' => 2,
]);
// Attach features to packages
$this->bioFreePackage->features()->attach($this->linksFeature->id, ['limit_value' => 10]);
$this->bioProPackage->features()->attach($this->linksFeature->id, ['limit_value' => 100]);
$this->bioProPackage->features()->attach($this->customDomainFeature->id, ['limit_value' => null]);
$this->bioProPackage->features()->attach($this->pageViewsFeature->id, ['limit_value' => 50000]);
$this->service = app(EntitlementService::class);
});
describe('canForNamespace() method', function () {
it('denies access when namespace has no packages', function () {
$result = $this->service->canForNamespace($this->userNamespace, 'links');
expect($result)->toBeInstanceOf(EntitlementResult::class)
->and($result->isAllowed())->toBeFalse()
->and($result->isDenied())->toBeTrue()
->and($result->reason)->toContain('plan does not include');
});
it('allows access when namespace has package with feature', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-pro');
$result = $this->service->canForNamespace($this->userNamespace, 'links');
expect($result->isAllowed())->toBeTrue()
->and($result->limit)->toBe(100)
->and($result->used)->toBe(0);
});
it('allows boolean features without limits', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-pro');
$result = $this->service->canForNamespace($this->userNamespace, 'custom_domain');
expect($result->isAllowed())->toBeTrue()
->and($result->limit)->toBeNull();
});
it('denies access when limit is exceeded', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-free');
// Record usage up to the limit
for ($i = 0; $i < 10; $i++) {
UsageRecord::create([
'namespace_id' => $this->userNamespace->id,
'feature_code' => 'links',
'quantity' => 1,
'recorded_at' => now(),
]);
}
Cache::flush();
$result = $this->service->canForNamespace($this->userNamespace, 'links');
expect($result->isDenied())->toBeTrue()
->and($result->used)->toBe(10)
->and($result->limit)->toBe(10)
->and($result->reason)->toContain('reached your');
});
it('denies access for non-existent feature', function () {
$result = $this->service->canForNamespace($this->userNamespace, 'non.existent.feature');
expect($result->isDenied())->toBeTrue()
->and($result->reason)->toContain('does not exist');
});
});
describe('entitlement cascade', function () {
it('uses namespace package when available', function () {
// Give workspace more links than namespace
$this->service->provisionPackage($this->workspace, 'bio-pro');
$this->service->provisionNamespacePackage($this->workspaceNamespace, 'bio-free');
$result = $this->service->canForNamespace($this->workspaceNamespace, 'links');
// Should use namespace's bio-free (10 links), not workspace's bio-pro (100 links)
expect($result->isAllowed())->toBeTrue()
->and($result->limit)->toBe(10);
});
it('falls back to workspace package when namespace has none', function () {
// Only workspace has a package
$this->service->provisionPackage($this->workspace, 'bio-pro');
$result = $this->service->canForNamespace($this->workspaceNamespace, 'links');
// Should use workspace's bio-pro (100 links)
expect($result->isAllowed())->toBeTrue()
->and($result->limit)->toBe(100);
});
it('uses workspace context for billing when explicitly set', function () {
// Namespace is user-owned but has workspace_id for billing
$this->service->provisionPackage($this->workspace, 'bio-pro');
$result = $this->service->canForNamespace($this->billedNamespace, 'links');
// Should use workspace's bio-pro (100 links) via workspace_id
expect($result->isAllowed())->toBeTrue()
->and($result->limit)->toBe(100);
});
it('falls back to user tier for user-owned namespace without workspace', function () {
// User has Apollo tier which includes 'analytics_basic'
// Create a feature that Apollo tier grants
Feature::create([
'code' => 'analytics_basic',
'name' => 'Basic Analytics',
'description' => 'Basic analytics access',
'category' => 'analytics',
'type' => Feature::TYPE_BOOLEAN,
'reset_type' => Feature::RESET_NONE,
'is_active' => true,
'sort_order' => 10,
]);
$result = $this->service->canForNamespace($this->userNamespace, 'analytics_basic');
// Should allow based on user's Apollo tier
expect($result->isAllowed())->toBeTrue();
});
it('denies access when user tier does not include feature', function () {
// Create a free user
$freeUser = User::factory()->create(['tier' => UserTier::FREE]);
$freeNamespace = Namespace_::create([
'name' => 'Free User Namespace',
'slug' => 'free-user-ns',
'owner_type' => User::class,
'owner_id' => $freeUser->id,
'is_active' => true,
]);
// Feature that only Hades has
Feature::create([
'code' => 'api_access',
'name' => 'API Access',
'description' => 'API access feature',
'category' => 'advanced',
'type' => Feature::TYPE_BOOLEAN,
'reset_type' => Feature::RESET_NONE,
'is_active' => true,
'sort_order' => 20,
]);
$result = $this->service->canForNamespace($freeNamespace, 'api_access');
expect($result->isDenied())->toBeTrue();
});
it('namespace package overrides workspace package', function () {
// Give workspace bio-pro (100 links)
$this->service->provisionPackage($this->workspace, 'bio-pro');
// Give namespace bio-free (10 links)
$this->service->provisionNamespacePackage($this->workspaceNamespace, 'bio-free');
$result = $this->service->canForNamespace($this->workspaceNamespace, 'links');
// Namespace package takes precedence
expect($result->limit)->toBe(10);
});
});
describe('recordNamespaceUsage() method', function () {
it('creates a usage record for namespace', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-pro');
$record = $this->service->recordNamespaceUsage(
$this->userNamespace,
'links',
quantity: 5,
user: $this->user
);
expect($record)->toBeInstanceOf(UsageRecord::class)
->and($record->namespace_id)->toBe($this->userNamespace->id)
->and($record->feature_code)->toBe('links')
->and($record->quantity)->toBe(5)
->and($record->user_id)->toBe($this->user->id);
});
it('records usage with metadata', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-pro');
$record = $this->service->recordNamespaceUsage(
$this->userNamespace,
'links',
quantity: 1,
metadata: ['link_type' => 'social', 'platform' => 'instagram']
);
expect($record->metadata)->toBe(['link_type' => 'social', 'platform' => 'instagram']);
});
it('includes workspace_id when namespace has workspace context', function () {
$this->service->provisionPackage($this->workspace, 'bio-pro');
$record = $this->service->recordNamespaceUsage(
$this->workspaceNamespace,
'links',
quantity: 1
);
expect($record->workspace_id)->toBe($this->workspace->id);
});
it('invalidates namespace cache after recording usage', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-free');
// Warm up cache
$this->service->canForNamespace($this->userNamespace, 'links');
// Record usage
$this->service->recordNamespaceUsage($this->userNamespace, 'links', quantity: 3);
// Check that usage is reflected (cache was invalidated)
$result = $this->service->canForNamespace($this->userNamespace, 'links');
expect($result->used)->toBe(3);
});
});
describe('provisionNamespacePackage() method', function () {
it('provisions a package to namespace', function () {
$namespacePackage = $this->service->provisionNamespacePackage($this->userNamespace, 'bio-pro');
expect($namespacePackage)->toBeInstanceOf(NamespacePackage::class)
->and($namespacePackage->namespace_id)->toBe($this->userNamespace->id)
->and($namespacePackage->package->code)->toBe('bio-pro')
->and($namespacePackage->status)->toBe(NamespacePackage::STATUS_ACTIVE);
});
it('replaces existing base package when provisioning new base package', function () {
// Provision bio-free package
$freePackage = $this->service->provisionNamespacePackage($this->userNamespace, 'bio-free');
// Provision bio-pro package (should cancel bio-free)
$proPackage = $this->service->provisionNamespacePackage($this->userNamespace, 'bio-pro');
// Refresh the free package
$freePackage->refresh();
expect($freePackage->status)->toBe(NamespacePackage::STATUS_CANCELLED)
->and($proPackage->status)->toBe(NamespacePackage::STATUS_ACTIVE);
});
it('sets billing cycle anchor', function () {
$anchor = now()->subDays(15);
$namespacePackage = $this->service->provisionNamespacePackage($this->userNamespace, 'bio-pro', [
'billing_cycle_anchor' => $anchor,
]);
expect($namespacePackage->billing_cycle_anchor->toDateString())
->toBe($anchor->toDateString());
});
it('stores metadata', function () {
$namespacePackage = $this->service->provisionNamespacePackage($this->userNamespace, 'bio-pro', [
'metadata' => ['upgraded_from' => 'bio-free', 'reason' => 'trial'],
]);
expect($namespacePackage->metadata)->toBe(['upgraded_from' => 'bio-free', 'reason' => 'trial']);
});
it('invalidates namespace cache after provisioning', function () {
// Check entitlement before (should be denied)
$resultBefore = $this->service->canForNamespace($this->userNamespace, 'links');
expect($resultBefore->isDenied())->toBeTrue();
// Provision package
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-pro');
// Check entitlement after (should be allowed)
$resultAfter = $this->service->canForNamespace($this->userNamespace, 'links');
expect($resultAfter->isAllowed())->toBeTrue();
});
it('allows setting expiry date', function () {
$expiryDate = now()->addDays(14);
$namespacePackage = $this->service->provisionNamespacePackage($this->userNamespace, 'bio-pro', [
'expires_at' => $expiryDate,
]);
expect($namespacePackage->expires_at->toDateString())
->toBe($expiryDate->toDateString());
});
});
describe('provisionNamespaceBoost() method', function () {
it('provisions a boost to namespace', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-free');
$boost = $this->service->provisionNamespaceBoost($this->userNamespace, 'links', [
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'limit_value' => 50,
'duration_type' => Boost::DURATION_PERMANENT,
]);
expect($boost)->toBeInstanceOf(Boost::class)
->and($boost->namespace_id)->toBe($this->userNamespace->id)
->and($boost->feature_code)->toBe('links')
->and($boost->limit_value)->toBe(50)
->and($boost->status)->toBe(Boost::STATUS_ACTIVE);
});
it('adds boost limit to namespace package limit', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-free'); // 10 links
$this->service->provisionNamespaceBoost($this->userNamespace, 'links', [
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'limit_value' => 25,
]);
Cache::flush();
$result = $this->service->canForNamespace($this->userNamespace, 'links');
expect($result->limit)->toBe(35); // 10 + 25
});
it('supports unlimited boost type', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-free'); // 10 links
$this->service->provisionNamespaceBoost($this->userNamespace, 'links', [
'boost_type' => Boost::BOOST_TYPE_UNLIMITED,
]);
Cache::flush();
$result = $this->service->canForNamespace($this->userNamespace, 'links');
expect($result->isUnlimited())->toBeTrue();
});
it('includes workspace_id when namespace has workspace context', function () {
$boost = $this->service->provisionNamespaceBoost($this->workspaceNamespace, 'links', [
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'limit_value' => 50,
]);
expect($boost->workspace_id)->toBe($this->workspace->id);
});
it('supports expiry date for boosts', function () {
$expiryDate = now()->addDays(7);
$boost = $this->service->provisionNamespaceBoost($this->userNamespace, 'links', [
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'limit_value' => 50,
'expires_at' => $expiryDate,
]);
expect($boost->expires_at->toDateString())
->toBe($expiryDate->toDateString());
});
it('invalidates namespace cache after provisioning boost', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-free'); // 10 links
// Warm up cache
$initialResult = $this->service->canForNamespace($this->userNamespace, 'links');
expect($initialResult->limit)->toBe(10);
// Provision boost
$this->service->provisionNamespaceBoost($this->userNamespace, 'links', [
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'limit_value' => 40,
]);
// Cache should be invalidated
$result = $this->service->canForNamespace($this->userNamespace, 'links');
expect($result->limit)->toBe(50);
});
});
describe('getNamespaceUsageSummary() method', function () {
it('returns usage summary for namespace features', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-pro');
$summary = $this->service->getNamespaceUsageSummary($this->userNamespace);
expect($summary)->toBeInstanceOf(\Illuminate\Support\Collection::class)
->and($summary->has('content'))->toBeTrue()
->and($summary->has('features'))->toBeTrue();
});
it('includes usage percentages for namespace', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-pro');
// Use 50 of 100 links
$this->service->recordNamespaceUsage($this->userNamespace, 'links', quantity: 50);
$summary = $this->service->getNamespaceUsageSummary($this->userNamespace);
$linksFeature = $summary->get('content')->first();
expect($linksFeature['used'])->toBe(50)
->and($linksFeature['limit'])->toBe(100)
->and((int) $linksFeature['percentage'])->toBe(50);
});
it('identifies near-limit features', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-free'); // 10 links
// Use 9 of 10 links (90%)
$this->service->recordNamespaceUsage($this->userNamespace, 'links', quantity: 9);
$summary = $this->service->getNamespaceUsageSummary($this->userNamespace);
$linksFeature = $summary->get('content')->first();
expect($linksFeature['near_limit'])->toBeTrue();
});
});
describe('invalidateNamespaceCache() method', function () {
it('clears cached limits for namespace', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-free');
// Warm up cache
$this->service->canForNamespace($this->userNamespace, 'links');
// Manually check cache key exists
$cacheKey = "entitlement:ns:{$this->userNamespace->id}:limit:links";
expect(Cache::has($cacheKey))->toBeTrue();
// Invalidate cache
$this->service->invalidateNamespaceCache($this->userNamespace);
// Cache should be cleared
expect(Cache::has($cacheKey))->toBeFalse();
});
it('clears cached usage for namespace', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-free');
$this->service->recordNamespaceUsage($this->userNamespace, 'links', quantity: 5);
// Warm up cache
$this->service->canForNamespace($this->userNamespace, 'links');
// Invalidate cache
$this->service->invalidateNamespaceCache($this->userNamespace);
// Usage cache key should be cleared
$cacheKey = "entitlement:ns:{$this->userNamespace->id}:usage:links";
expect(Cache::has($cacheKey))->toBeFalse();
});
});
describe('namespace ownership scenarios', function () {
it('handles user-owned namespace correctly', function () {
expect($this->userNamespace->isOwnedByUser())->toBeTrue()
->and($this->userNamespace->isOwnedByWorkspace())->toBeFalse()
->and($this->userNamespace->getOwnerUser()->id)->toBe($this->user->id)
->and($this->userNamespace->getOwnerWorkspace())->toBeNull();
});
it('handles workspace-owned namespace correctly', function () {
expect($this->workspaceNamespace->isOwnedByWorkspace())->toBeTrue()
->and($this->workspaceNamespace->isOwnedByUser())->toBeFalse()
->and($this->workspaceNamespace->getOwnerWorkspace()->id)->toBe($this->workspace->id)
->and($this->workspaceNamespace->getOwnerUser())->toBeNull();
});
it('handles user-owned namespace with workspace billing context', function () {
expect($this->billedNamespace->isOwnedByUser())->toBeTrue()
->and($this->billedNamespace->workspace_id)->toBe($this->workspace->id)
->and($this->billedNamespace->getBillingContext()->id)->toBe($this->workspace->id);
});
});
describe('multiple namespaces with different entitlements', function () {
it('tracks usage separately per namespace', function () {
// Provision same package to both namespaces
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-free');
$this->service->provisionNamespacePackage($this->workspaceNamespace, 'bio-free');
// Record different usage for each
$this->service->recordNamespaceUsage($this->userNamespace, 'links', quantity: 3);
$this->service->recordNamespaceUsage($this->workspaceNamespace, 'links', quantity: 7);
Cache::flush();
$userResult = $this->service->canForNamespace($this->userNamespace, 'links');
$workspaceResult = $this->service->canForNamespace($this->workspaceNamespace, 'links');
expect($userResult->used)->toBe(3)
->and($userResult->remaining)->toBe(7)
->and($workspaceResult->used)->toBe(7)
->and($workspaceResult->remaining)->toBe(3);
});
it('allows different package levels per namespace', function () {
// User namespace gets free plan
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-free');
// Workspace namespace gets pro plan
$this->service->provisionNamespacePackage($this->workspaceNamespace, 'bio-pro');
$userResult = $this->service->canForNamespace($this->userNamespace, 'links');
$workspaceResult = $this->service->canForNamespace($this->workspaceNamespace, 'links');
expect($userResult->limit)->toBe(10)
->and($workspaceResult->limit)->toBe(100);
});
});
describe('boost stacking with namespace packages', function () {
it('stacks multiple boosts on namespace package', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-free'); // 10 links
// Add two boosts
$this->service->provisionNamespaceBoost($this->userNamespace, 'links', [
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'limit_value' => 20,
]);
$this->service->provisionNamespaceBoost($this->userNamespace, 'links', [
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'limit_value' => 15,
]);
Cache::flush();
$result = $this->service->canForNamespace($this->userNamespace, 'links');
expect($result->limit)->toBe(45); // 10 + 20 + 15
});
it('unlimited boost overrides all limits', function () {
$this->service->provisionNamespacePackage($this->userNamespace, 'bio-free'); // 10 links
// Add a regular boost first
$this->service->provisionNamespaceBoost($this->userNamespace, 'links', [
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'limit_value' => 20,
]);
// Add unlimited boost
$this->service->provisionNamespaceBoost($this->userNamespace, 'links', [
'boost_type' => Boost::BOOST_TYPE_UNLIMITED,
]);
Cache::flush();
$result = $this->service->canForNamespace($this->userNamespace, 'links');
expect($result->isUnlimited())->toBeTrue();
});
});
});
describe('EntitlementResult', function () {
it('calculates remaining correctly', function () {
$result = EntitlementResult::allowed(limit: 100, used: 75, featureCode: 'test');