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:
parent
67b5b14b8e
commit
a067453a6a
2 changed files with 670 additions and 6 deletions
18
TODO.md
18
TODO.md
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue