php-tenant/tests/Feature/EntitlementServiceTest.php

1300 lines
51 KiB
PHP
Raw Normal View History

2026-01-26 21:08:59 +00:00
<?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;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Models\WorkspacePackage;
use Core\Tenant\Services\EntitlementResult;
use Core\Tenant\Services\EntitlementService;
2026-01-26 21:08:59 +00:00
use Illuminate\Support\Facades\Cache;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
// Clear cache before each test
Cache::flush();
// Create test workspace
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
// Create features
$this->aiCreditsFeature = Feature::create([
'code' => 'ai.credits',
'name' => 'AI Credits',
'description' => 'AI generation credits',
'category' => 'ai',
'type' => Feature::TYPE_LIMIT,
'reset_type' => Feature::RESET_MONTHLY,
'is_active' => true,
'sort_order' => 1,
]);
$this->apolloTierFeature = Feature::create([
'code' => 'tier.apollo',
'name' => 'Apollo Tier',
'description' => 'Apollo tier access',
'category' => 'tier',
'type' => Feature::TYPE_BOOLEAN,
'reset_type' => Feature::RESET_NONE,
'is_active' => true,
'sort_order' => 1,
]);
$this->socialPostsFeature = Feature::create([
'code' => 'social.posts',
'name' => 'Scheduled Posts',
'description' => 'Monthly scheduled posts',
'category' => 'social',
'type' => Feature::TYPE_LIMIT,
'reset_type' => Feature::RESET_MONTHLY,
'is_active' => true,
'sort_order' => 1,
]);
// Create packages
$this->creatorPackage = Package::create([
'code' => 'creator',
'name' => 'Creator',
'description' => 'For individual creators',
'is_stackable' => false,
'is_base_package' => true,
'is_active' => true,
'is_public' => true,
'sort_order' => 1,
]);
$this->agencyPackage = Package::create([
'code' => 'agency',
'name' => 'Agency',
'description' => 'For agencies',
'is_stackable' => false,
'is_base_package' => true,
'is_active' => true,
'is_public' => true,
'sort_order' => 2,
]);
// Attach features to packages
$this->creatorPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 100]);
$this->creatorPackage->features()->attach($this->apolloTierFeature->id, ['limit_value' => null]);
$this->creatorPackage->features()->attach($this->socialPostsFeature->id, ['limit_value' => 50]);
$this->agencyPackage->features()->attach($this->aiCreditsFeature->id, ['limit_value' => 500]);
$this->agencyPackage->features()->attach($this->apolloTierFeature->id, ['limit_value' => null]);
$this->agencyPackage->features()->attach($this->socialPostsFeature->id, ['limit_value' => 200]);
$this->service = app(EntitlementService::class);
});
describe('EntitlementService', function () {
describe('can() method', function () {
it('denies access when workspace has no packages', function () {
$result = $this->service->can($this->workspace, 'ai.credits');
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 workspace has package with feature', function () {
$this->service->provisionPackage($this->workspace, 'creator');
$result = $this->service->can($this->workspace, 'ai.credits');
expect($result->isAllowed())->toBeTrue()
->and($result->limit)->toBe(100)
->and($result->used)->toBe(0);
});
it('allows boolean features without limits', function () {
$this->service->provisionPackage($this->workspace, 'creator');
$result = $this->service->can($this->workspace, 'tier.apollo');
expect($result->isAllowed())->toBeTrue()
->and($result->limit)->toBeNull();
});
it('denies access when limit is exceeded', function () {
$this->service->provisionPackage($this->workspace, 'creator');
// Record usage up to the limit
for ($i = 0; $i < 100; $i++) {
UsageRecord::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'quantity' => 1,
'recorded_at' => now(),
]);
}
Cache::flush();
$result = $this->service->can($this->workspace, 'ai.credits');
expect($result->isDenied())->toBeTrue()
->and($result->used)->toBe(100)
->and($result->limit)->toBe(100)
->and($result->reason)->toContain('reached your');
});
it('allows access when quantity is within remaining limit', function () {
$this->service->provisionPackage($this->workspace, 'creator');
// Use 50 credits
UsageRecord::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'quantity' => 50,
'recorded_at' => now(),
]);
Cache::flush();
$result = $this->service->can($this->workspace, 'ai.credits', quantity: 25);
expect($result->isAllowed())->toBeTrue()
->and($result->remaining)->toBe(50);
});
it('denies access when requested quantity exceeds remaining', function () {
$this->service->provisionPackage($this->workspace, 'creator');
// Use 90 credits
UsageRecord::create([
'workspace_id' => $this->workspace->id,
'feature_code' => 'ai.credits',
'quantity' => 90,
'recorded_at' => now(),
]);
Cache::flush();
$result = $this->service->can($this->workspace, 'ai.credits', quantity: 20);
expect($result->isDenied())->toBeTrue()
->and($result->used)->toBe(90)
->and($result->remaining)->toBe(10);
});
it('denies access for non-existent feature', function () {
$result = $this->service->can($this->workspace, 'non.existent.feature');
expect($result->isDenied())->toBeTrue()
->and($result->reason)->toContain('does not exist');
});
});
describe('recordUsage() method', function () {
it('creates a usage record', function () {
$this->service->provisionPackage($this->workspace, 'creator');
$record = $this->service->recordUsage(
$this->workspace,
'ai.credits',
quantity: 5,
user: $this->user
);
expect($record)->toBeInstanceOf(UsageRecord::class)
->and($record->workspace_id)->toBe($this->workspace->id)
->and($record->feature_code)->toBe('ai.credits')
->and($record->quantity)->toBe(5)
->and($record->user_id)->toBe($this->user->id);
});
it('records usage with metadata', function () {
$this->service->provisionPackage($this->workspace, 'creator');
$record = $this->service->recordUsage(
$this->workspace,
'ai.credits',
quantity: 1,
metadata: ['model' => 'claude-3', 'tokens' => 1500]
);
expect($record->metadata)->toBe(['model' => 'claude-3', 'tokens' => 1500]);
});
it('invalidates cache after recording usage', function () {
$this->service->provisionPackage($this->workspace, 'creator');
// Warm up cache
$this->service->can($this->workspace, 'ai.credits');
// Record usage
$this->service->recordUsage($this->workspace, 'ai.credits', quantity: 10);
// Check that usage is reflected (cache was invalidated)
$result = $this->service->can($this->workspace, 'ai.credits');
expect($result->used)->toBe(10);
});
});
describe('provisionPackage() method', function () {
it('provisions a package to workspace', function () {
$workspacePackage = $this->service->provisionPackage($this->workspace, 'creator');
expect($workspacePackage)->toBeInstanceOf(WorkspacePackage::class)
->and($workspacePackage->workspace_id)->toBe($this->workspace->id)
->and($workspacePackage->package->code)->toBe('creator')
->and($workspacePackage->status)->toBe(WorkspacePackage::STATUS_ACTIVE);
});
it('creates an entitlement log entry', function () {
$this->service->provisionPackage($this->workspace, 'creator', [
'source' => EntitlementLog::SOURCE_BLESTA,
]);
$log = EntitlementLog::where('workspace_id', $this->workspace->id)
->where('action', EntitlementLog::ACTION_PACKAGE_PROVISIONED)
->first();
expect($log)->not->toBeNull()
->and($log->source)->toBe(EntitlementLog::SOURCE_BLESTA);
});
it('replaces existing base package when provisioning new base package', function () {
// Provision creator package
$creatorWp = $this->service->provisionPackage($this->workspace, 'creator');
// Provision agency package (should cancel creator)
$agencyWp = $this->service->provisionPackage($this->workspace, 'agency');
// Refresh creator package
$creatorWp->refresh();
expect($creatorWp->status)->toBe(WorkspacePackage::STATUS_CANCELLED)
->and($agencyWp->status)->toBe(WorkspacePackage::STATUS_ACTIVE);
});
it('sets billing cycle anchor', function () {
$anchor = now()->subDays(15);
$workspacePackage = $this->service->provisionPackage($this->workspace, 'creator', [
'billing_cycle_anchor' => $anchor,
]);
expect($workspacePackage->billing_cycle_anchor->toDateString())
->toBe($anchor->toDateString());
});
it('stores blesta service id', function () {
$workspacePackage = $this->service->provisionPackage($this->workspace, 'creator', [
'blesta_service_id' => 'blesta_12345',
]);
expect($workspacePackage->blesta_service_id)->toBe('blesta_12345');
});
});
describe('provisionBoost() method', function () {
it('provisions a boost to workspace', function () {
$this->service->provisionPackage($this->workspace, 'creator');
$boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'limit_value' => 100,
'duration_type' => Boost::DURATION_CYCLE_BOUND,
]);
expect($boost)->toBeInstanceOf(Boost::class)
->and($boost->workspace_id)->toBe($this->workspace->id)
->and($boost->feature_code)->toBe('ai.credits')
->and($boost->limit_value)->toBe(100)
->and($boost->status)->toBe(Boost::STATUS_ACTIVE);
});
it('adds boost limit to package limit', function () {
$this->service->provisionPackage($this->workspace, 'creator'); // 100 credits
$this->service->provisionBoost($this->workspace, 'ai.credits', [
'boost_type' => Boost::BOOST_TYPE_ADD_LIMIT,
'limit_value' => 50,
]);
Cache::flush();
$result = $this->service->can($this->workspace, 'ai.credits');
expect($result->limit)->toBe(150); // 100 + 50
});
it('creates an entitlement log entry for boost', function () {
$this->service->provisionBoost($this->workspace, 'ai.credits', [
'limit_value' => 100,
]);
$log = EntitlementLog::where('workspace_id', $this->workspace->id)
->where('action', EntitlementLog::ACTION_BOOST_PROVISIONED)
->first();
expect($log)->not->toBeNull();
});
});
describe('suspendWorkspace() method', function () {
it('suspends all active packages', function () {
$workspacePackage = $this->service->provisionPackage($this->workspace, 'creator');
$this->service->suspendWorkspace($this->workspace);
$workspacePackage->refresh();
expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_SUSPENDED);
});
it('creates suspension log entries', function () {
$this->service->provisionPackage($this->workspace, 'creator');
$this->service->suspendWorkspace($this->workspace, EntitlementLog::SOURCE_BLESTA);
$log = EntitlementLog::where('workspace_id', $this->workspace->id)
->where('action', EntitlementLog::ACTION_PACKAGE_SUSPENDED)
->first();
expect($log)->not->toBeNull()
->and($log->source)->toBe(EntitlementLog::SOURCE_BLESTA);
});
it('denies access after suspension', function () {
$this->service->provisionPackage($this->workspace, 'creator');
// Can access before suspension
expect($this->service->can($this->workspace, 'ai.credits')->isAllowed())->toBeTrue();
$this->service->suspendWorkspace($this->workspace);
Cache::flush();
// Cannot access after suspension
expect($this->service->can($this->workspace, 'ai.credits')->isDenied())->toBeTrue();
});
});
describe('reactivateWorkspace() method', function () {
it('reactivates suspended packages', function () {
$workspacePackage = $this->service->provisionPackage($this->workspace, 'creator');
$this->service->suspendWorkspace($this->workspace);
$this->service->reactivateWorkspace($this->workspace);
$workspacePackage->refresh();
expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_ACTIVE);
});
it('creates reactivation log entries', function () {
$this->service->provisionPackage($this->workspace, 'creator');
$this->service->suspendWorkspace($this->workspace);
$this->service->reactivateWorkspace($this->workspace, EntitlementLog::SOURCE_BLESTA);
$log = EntitlementLog::where('workspace_id', $this->workspace->id)
->where('action', EntitlementLog::ACTION_PACKAGE_REACTIVATED)
->first();
expect($log)->not->toBeNull()
->and($log->source)->toBe(EntitlementLog::SOURCE_BLESTA);
});
it('restores access after reactivation', function () {
$this->service->provisionPackage($this->workspace, 'creator');
$this->service->suspendWorkspace($this->workspace);
$this->service->reactivateWorkspace($this->workspace);
Cache::flush();
expect($this->service->can($this->workspace, 'ai.credits')->isAllowed())->toBeTrue();
});
});
describe('getUsageSummary() method', function () {
it('returns usage summary for all features', function () {
$this->service->provisionPackage($this->workspace, 'creator');
$summary = $this->service->getUsageSummary($this->workspace);
expect($summary)->toBeInstanceOf(\Illuminate\Support\Collection::class)
->and($summary->has('ai'))->toBeTrue()
->and($summary->has('tier'))->toBeTrue()
->and($summary->has('social'))->toBeTrue();
});
it('includes usage percentages', function () {
$this->service->provisionPackage($this->workspace, 'creator');
// Use 50 of 100 credits
$this->service->recordUsage($this->workspace, 'ai.credits', quantity: 50);
$summary = $this->service->getUsageSummary($this->workspace);
$aiFeature = $summary->get('ai')->first();
expect($aiFeature['used'])->toBe(50)
->and($aiFeature['limit'])->toBe(100)
->and((int) $aiFeature['percentage'])->toBe(50);
});
});
describe('getActivePackages() method', function () {
it('returns only active packages', function () {
$this->service->provisionPackage($this->workspace, 'creator');
$this->service->suspendWorkspace($this->workspace);
$activePackages = $this->service->getActivePackages($this->workspace);
expect($activePackages)->toHaveCount(0);
});
it('excludes expired packages', function () {
$wp = $this->service->provisionPackage($this->workspace, 'creator', [
'expires_at' => now()->subDay(),
]);
$activePackages = $this->service->getActivePackages($this->workspace);
expect($activePackages)->toHaveCount(0);
});
});
describe('getActiveBoosts() method', function () {
it('returns only active boosts', function () {
$boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [
'limit_value' => 100,
]);
$activeBoosts = $this->service->getActiveBoosts($this->workspace);
expect($activeBoosts)->toHaveCount(1);
// Cancel the boost
$boost->update(['status' => Boost::STATUS_CANCELLED]);
$activeBoosts = $this->service->getActiveBoosts($this->workspace);
expect($activeBoosts)->toHaveCount(0);
});
});
describe('revokePackage() method', function () {
it('revokes an active package', function () {
$workspacePackage = $this->service->provisionPackage($this->workspace, 'creator');
expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_ACTIVE);
$this->service->revokePackage($this->workspace, 'creator');
$workspacePackage->refresh();
expect($workspacePackage->status)->toBe(WorkspacePackage::STATUS_CANCELLED)
->and($workspacePackage->expires_at)->not->toBeNull();
});
it('creates a cancellation log entry', function () {
$this->service->provisionPackage($this->workspace, 'creator');
$this->service->revokePackage($this->workspace, 'creator', EntitlementLog::SOURCE_SYSTEM);
$log = EntitlementLog::where('workspace_id', $this->workspace->id)
->where('action', EntitlementLog::ACTION_PACKAGE_CANCELLED)
->first();
expect($log)->not->toBeNull()
->and($log->source)->toBe(EntitlementLog::SOURCE_SYSTEM);
});
it('denies access after package revocation', function () {
$this->service->provisionPackage($this->workspace, 'creator');
// Can access before revocation
expect($this->service->can($this->workspace, 'ai.credits')->isAllowed())->toBeTrue();
$this->service->revokePackage($this->workspace, 'creator');
Cache::flush();
// Cannot access after revocation
expect($this->service->can($this->workspace, 'ai.credits')->isDenied())->toBeTrue();
});
it('does nothing when package does not exist', function () {
// Should not throw, just return silently
$this->service->revokePackage($this->workspace, 'nonexistent-package');
// No log entries should be created
$log = EntitlementLog::where('workspace_id', $this->workspace->id)
->where('action', EntitlementLog::ACTION_PACKAGE_CANCELLED)
->first();
expect($log)->toBeNull();
});
it('does nothing when package already cancelled', function () {
$workspacePackage = $this->service->provisionPackage($this->workspace, 'creator');
$workspacePackage->update(['status' => WorkspacePackage::STATUS_CANCELLED]);
// Should not throw
$this->service->revokePackage($this->workspace, 'creator');
// Only one log entry (from provisioning, not cancellation)
$logs = EntitlementLog::where('workspace_id', $this->workspace->id)
->where('action', EntitlementLog::ACTION_PACKAGE_CANCELLED)
->count();
expect($logs)->toBe(0);
});
it('invalidates cache after revocation', function () {
$this->service->provisionPackage($this->workspace, 'creator');
// Warm up cache
$this->service->can($this->workspace, 'ai.credits');
// Revoke
$this->service->revokePackage($this->workspace, 'creator');
// Check that revocation is reflected (cache was invalidated)
$result = $this->service->can($this->workspace, 'ai.credits');
expect($result->isDenied())->toBeTrue();
});
});
describe('expireCycleBoundBoosts() method', function () {
it('expires cycle-bound boosts', function () {
$boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [
'limit_value' => 100,
'duration_type' => Boost::DURATION_CYCLE_BOUND,
]);
$this->service->expireCycleBoundBoosts($this->workspace);
$boost->refresh();
expect($boost->status)->toBe(Boost::STATUS_EXPIRED);
});
it('does not expire permanent boosts', function () {
$boost = $this->service->provisionBoost($this->workspace, 'ai.credits', [
'limit_value' => 100,
'duration_type' => Boost::DURATION_PERMANENT,
]);
$this->service->expireCycleBoundBoosts($this->workspace);
$boost->refresh();
expect($boost->status)->toBe(Boost::STATUS_ACTIVE);
});
it('creates expiration log entries', function () {
$this->service->provisionBoost($this->workspace, 'ai.credits', [
'limit_value' => 100,
'duration_type' => Boost::DURATION_CYCLE_BOUND,
]);
$this->service->expireCycleBoundBoosts($this->workspace);
$log = EntitlementLog::where('workspace_id', $this->workspace->id)
->where('action', EntitlementLog::ACTION_BOOST_EXPIRED)
->first();
expect($log)->not->toBeNull();
});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 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();
});
});
});
2026-01-26 21:08:59 +00:00
describe('EntitlementResult', function () {
it('calculates remaining correctly', function () {
$result = EntitlementResult::allowed(limit: 100, used: 75, featureCode: 'test');
expect($result->remaining)->toBe(25);
});
it('calculates usage percentage correctly', function () {
$result = EntitlementResult::allowed(limit: 100, used: 75, featureCode: 'test');
expect((int) $result->getUsagePercentage())->toBe(75);
});
it('identifies near limit correctly', function () {
$result = EntitlementResult::allowed(limit: 100, used: 85, featureCode: 'test');
expect($result->isNearLimit())->toBeTrue();
$result2 = EntitlementResult::allowed(limit: 100, used: 50, featureCode: 'test');
expect($result2->isNearLimit())->toBeFalse();
});
it('identifies unlimited correctly', function () {
$result = EntitlementResult::unlimited('test');
expect($result->isUnlimited())->toBeTrue()
->and($result->isAllowed())->toBeTrue();
});
});