2026-01-26 21:08:59 +00:00
|
|
|
<?php
|
|
|
|
|
|
2026-01-27 00:58:42 +00:00
|
|
|
use Core\Mod\Tenant\Models\Boost;
|
|
|
|
|
use Core\Mod\Tenant\Models\EntitlementLog;
|
|
|
|
|
use Core\Mod\Tenant\Models\Feature;
|
|
|
|
|
use Core\Mod\Tenant\Models\Package;
|
|
|
|
|
use Core\Mod\Tenant\Models\UsageRecord;
|
|
|
|
|
use Core\Mod\Tenant\Models\User;
|
|
|
|
|
use Core\Mod\Tenant\Models\Workspace;
|
|
|
|
|
use Core\Mod\Tenant\Models\WorkspacePackage;
|
|
|
|
|
use Core\Mod\Tenant\Services\EntitlementResult;
|
|
|
|
|
use Core\Mod\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();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|