makeAccount([ 'allocated_disk_gb' => 10, 'disk_used_bytes' => (int) (9.1 * 1073741824), 'inode_limit' => 50000, 'inode_count' => 1000, 'cpu_limit_percent' => 50, 'cpu_usage_percent' => 10, 'memory_limit_mb' => 512, 'memory_used_mb' => 128, 'process_limit' => 10, 'process_count' => 2, ]); $provider = \Mockery::mock(SharedNodeProvider::class); $provider->shouldReceive('applyAccountResourceProfile')->never(); $provider->shouldReceive('suspendAccount')->never(); $this->app->instance(SharedNodeProvider::class, $provider); app(HostingResourcePolicyService::class)->process($account); $account->refresh(); $this->assertTrue($account->is_flagged); $this->assertSame(HostingAccount::RESOURCE_STATUS_ACTIVE, $account->resource_status); $this->assertDatabaseHas('hosting_account_alerts', [ 'hosting_account_id' => $account->id, 'alert_type' => HostingAccountAlert::TYPE_WARNING, 'is_resolved' => false, ]); Notification::assertSentTo($account->user, HostingResourceWarningNotification::class); } public function test_it_throttles_an_account_when_cpu_limit_is_exceeded(): void { Notification::fake(); $account = $this->makeAccount([ 'cpu_limit_percent' => 50, 'cpu_usage_percent' => 90, 'memory_limit_mb' => 512, 'memory_used_mb' => 128, 'process_limit' => 10, 'process_count' => 3, 'inode_limit' => 50000, 'inode_count' => 1000, 'throttled_at' => now()->subHours(25), 'cpu_breach_streak' => 11, ]); $provider = \Mockery::mock(SharedNodeProvider::class); $provider->shouldReceive('applyAccountResourceProfile')->once()->withArgs( fn (HostingAccount $candidate, bool $throttled): bool => $candidate->id === $account->id && $throttled === true )->andReturn(true); $provider->shouldReceive('suspendAccount')->never(); $this->app->instance(SharedNodeProvider::class, $provider); app(HostingResourcePolicyService::class)->process($account); $account->refresh(); $this->assertSame(HostingAccount::STATUS_ACTIVE, $account->status); $this->assertSame(HostingAccount::RESOURCE_STATUS_THROTTLED, $account->resource_status); $this->assertNotNull($account->throttled_at); $this->assertDatabaseHas('hosting_account_alerts', [ 'hosting_account_id' => $account->id, 'alert_type' => HostingAccountAlert::TYPE_THROTTLED, 'is_resolved' => false, ]); } public function test_it_suspends_a_throttled_account_when_abuse_continues(): void { Notification::fake(); $account = $this->makeAccount([ 'status' => HostingAccount::STATUS_ACTIVE, 'resource_status' => HostingAccount::RESOURCE_STATUS_THROTTLED, 'cpu_limit_percent' => 50, 'cpu_usage_percent' => 10, 'memory_limit_mb' => 512, 'memory_used_mb' => 1024, 'process_limit' => 10, 'process_count' => 3, 'inode_limit' => 50000, 'inode_count' => 1000, 'throttled_at' => now()->subHours(25), 'memory_breach_streak' => 11, ]); $order = CustomerHostingOrder::create([ 'user_id' => $account->user_id, 'hosting_product_id' => $account->hosting_product_id, 'hosting_account_id' => $account->id, 'domain_name' => $account->primary_domain ?: 'example.test', 'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY, 'amount_paid' => 10, 'currency' => 'GHS', 'status' => CustomerHostingOrder::STATUS_ACTIVE, ]); $provider = \Mockery::mock(SharedNodeProvider::class); $provider->shouldReceive('applyAccountResourceProfile')->never(); $provider->shouldReceive('suspendAccount')->once()->withArgs( fn (HostingAccount $candidate, string $reason): bool => $candidate->id === $account->id && str_contains($reason, 'Memory usage') )->andReturn(true); $this->app->instance(SharedNodeProvider::class, $provider); app(HostingResourcePolicyService::class)->process($account); $account->refresh(); $order->refresh(); $this->assertSame(HostingAccount::STATUS_SUSPENDED, $account->status); $this->assertSame(HostingAccount::RESOURCE_STATUS_SUSPENDED, $account->resource_status); $this->assertSame(CustomerHostingOrder::STATUS_SUSPENDED, $order->status); $this->assertDatabaseHas('hosting_account_alerts', [ 'hosting_account_id' => $account->id, 'alert_type' => HostingAccountAlert::TYPE_SUSPENDED, 'is_resolved' => false, ]); Notification::assertNothingSent(); } public function test_it_unsuspends_an_automatically_suspended_account_when_usage_returns_to_normal(): void { Notification::fake(); $account = $this->makeAccount([ 'status' => HostingAccount::STATUS_SUSPENDED, 'resource_status' => HostingAccount::RESOURCE_STATUS_SUSPENDED, 'cpu_limit_percent' => 50, 'cpu_usage_percent' => 0, 'memory_limit_mb' => 512, 'memory_used_mb' => 128, 'process_limit' => 10, 'process_count' => 2, 'inode_limit' => 50000, 'inode_count' => 1000, 'suspension_reason' => 'CPU usage reached 110.6% against a 50% limit.', 'suspended_at' => now()->subMinutes(10), 'metadata' => [ 'suspension_origin' => 'automatic', ], ]); $provider = \Mockery::mock(SharedNodeProvider::class); $provider->shouldReceive('unsuspendAccount')->once()->withArgs( fn (HostingAccount $candidate): bool => $candidate->id === $account->id )->andReturn(true); $provider->shouldReceive('applyAccountResourceProfile')->once()->withArgs( fn (HostingAccount $candidate, bool $throttled): bool => $candidate->id === $account->id && $throttled === false )->andReturn(true); $provider->shouldReceive('suspendAccount')->never(); $this->app->instance(SharedNodeProvider::class, $provider); app(HostingResourcePolicyService::class)->process($account); $account->refresh(); $this->assertSame(HostingAccount::STATUS_ACTIVE, $account->status); $this->assertSame(HostingAccount::RESOURCE_STATUS_ACTIVE, $account->resource_status); $this->assertNull($account->suspended_at); $this->assertNull($account->suspension_reason); $this->assertFalse($account->uploads_restricted); } public function test_it_does_not_unsuspend_a_manually_suspended_account_when_usage_is_normal(): void { Notification::fake(); $account = $this->makeAccount([ 'status' => HostingAccount::STATUS_SUSPENDED, 'resource_status' => HostingAccount::RESOURCE_STATUS_SUSPENDED, 'cpu_limit_percent' => 50, 'cpu_usage_percent' => 0, 'memory_limit_mb' => 512, 'memory_used_mb' => 128, 'process_limit' => 10, 'process_count' => 2, 'inode_limit' => 50000, 'inode_count' => 1000, 'suspension_reason' => 'Manual suspension.', 'suspended_at' => now()->subMinutes(10), 'metadata' => [ 'suspension_origin' => 'manual', ], ]); $provider = \Mockery::mock(SharedNodeProvider::class); $provider->shouldReceive('applyAccountResourceProfile')->never(); $provider->shouldReceive('unsuspendAccount')->never(); $provider->shouldReceive('suspendAccount')->never(); $this->app->instance(SharedNodeProvider::class, $provider); app(HostingResourcePolicyService::class)->process($account); $account->refresh(); $this->assertSame(HostingAccount::STATUS_SUSPENDED, $account->status); $this->assertSame(HostingAccount::RESOURCE_STATUS_SUSPENDED, $account->resource_status); $this->assertNotNull($account->suspended_at); $this->assertSame('Manual suspension.', $account->suspension_reason); } public function test_it_applies_slug_specific_inode_limits_for_single_domain_products(): void { $starter = HostingProduct::query()->updateOrCreate([ 'slug' => 'starter-plan', ], [ 'name' => 'Starter Plan', 'category' => HostingProduct::CATEGORY_SHARED, 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, 'price_monthly' => 1, 'currency' => 'GHS', 'disk_gb' => 5, 'max_domains' => 1, 'is_active' => true, 'is_visible' => true, ]); $basic = HostingProduct::query()->updateOrCreate([ 'slug' => 'basic-plan', ], [ 'name' => 'Basic Plan', 'category' => HostingProduct::CATEGORY_SHARED, 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, 'price_monthly' => 25, 'currency' => 'GHS', 'disk_gb' => 10, 'max_domains' => 1, 'is_active' => true, 'is_visible' => true, ]); $plus = HostingProduct::query()->updateOrCreate([ 'slug' => 'plus-plan', ], [ 'name' => 'Plus Plan', 'category' => HostingProduct::CATEGORY_SHARED, 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, 'price_monthly' => 35, 'currency' => 'GHS', 'disk_gb' => 20, 'max_domains' => 1, 'is_active' => true, 'is_visible' => true, ]); $growth = HostingProduct::query()->updateOrCreate([ 'slug' => 'growth-plan', ], [ 'name' => 'Growth Plan', 'category' => HostingProduct::CATEGORY_SHARED, 'type' => HostingProduct::TYPE_MULTI_DOMAIN, 'price_monthly' => 55, 'currency' => 'GHS', 'disk_gb' => 30, 'max_domains' => 5, 'is_active' => true, 'is_visible' => true, ]); $pro = HostingProduct::query()->updateOrCreate([ 'slug' => 'pro-plan', ], [ 'name' => 'Pro Plan', 'category' => HostingProduct::CATEGORY_SHARED, 'type' => HostingProduct::TYPE_MULTI_DOMAIN, 'price_monthly' => 175, 'currency' => 'GHS', 'disk_gb' => 150, 'max_domains' => -1, 'is_active' => true, 'is_visible' => true, ]); $service = app(HostingResourcePolicyService::class); $this->assertSame(225000, $service->defaultLimitsForProduct($starter)['inode_limit']); $this->assertSame(450000, $service->defaultLimitsForProduct($basic)['inode_limit']); $this->assertSame(450000, $service->defaultLimitsForProduct($plus)['inode_limit']); $this->assertSame(750000, $service->defaultLimitsForProduct($growth)['inode_limit']); $this->assertSame(1200000, $service->defaultLimitsForProduct($pro)['inode_limit']); } private function makeAccount(array $overrides = []): HostingAccount { $user = User::factory()->create(); $product = HostingProduct::create([ 'name' => 'Resource Test Hosting', 'slug' => 'resource-test-hosting-' . str()->random(6), 'category' => HostingProduct::CATEGORY_SHARED, 'type' => HostingProduct::TYPE_SINGLE_DOMAIN, 'price_monthly' => 10, 'currency' => 'GHS', 'disk_gb' => 10, 'max_domains' => 1, 'is_active' => true, 'is_visible' => true, ]); return HostingAccount::create(array_merge([ 'user_id' => $user->id, 'hosting_product_id' => $product->id, 'username' => 'acct' . random_int(1000, 9999), 'primary_domain' => 'resource-test.example', 'type' => HostingAccount::TYPE_SHARED, 'status' => HostingAccount::STATUS_ACTIVE, 'allocated_disk_gb' => 10, 'cpu_limit_percent' => 50, 'memory_limit_mb' => 512, 'process_limit' => 10, 'io_limit_mb' => 5, 'inode_limit' => 50000, 'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE, ], $overrides)); } }