diff --git a/TODO.md b/TODO.md index cc7504e..c66dc44 100644 --- a/TODO.md +++ b/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 --- diff --git a/tests/Feature/EntitlementServiceTest.php b/tests/Feature/EntitlementServiceTest.php index 3ecec0c..09fb853 100644 --- a/tests/Feature/EntitlementServiceTest.php +++ b/tests/Feature/EntitlementServiceTest.php @@ -1,8 +1,13 @@ 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');