Files
ladill-servers/tests/Feature/HostingPhaseThreeApiTest.php
isaaccladandCursor b6c8ac343f Extract Ladill Servers as standalone app at servers.ladill.com.
VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 19:18:30 +00:00

375 lines
14 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\CustomerHostingOrder;
use App\Models\Domain;
use App\Models\HostingAccount;
use App\Models\HostingBillingInvoice;
use App\Models\HostingPlanChange;
use App\Models\HostingProduct;
use App\Models\Mailbox;
use App\Models\User;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class HostingPhaseThreeApiTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_plans_endpoint_returns_visible_shared_hosting_products(): void
{
$user = User::factory()->create();
$visible = $this->makeProduct([
'name' => 'Starter Shared',
'slug' => 'starter-shared-phase3',
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
]);
$hidden = $this->makeProduct([
'name' => 'Hidden Shared',
'slug' => 'hidden-shared-phase3',
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_MULTI_DOMAIN,
'is_visible' => false,
]);
$this->actingAs($user)
->getJson('/api/plans')
->assertOk()
->assertJsonPath('data.0.id', $visible->id)
->assertJsonMissing(['id' => $hidden->id]);
}
public function test_upgrade_endpoint_applies_prorated_plan_change_and_updates_quotas(): void
{
Carbon::setTestNow(Carbon::create(2026, 4, 6, 10, 0, 0));
$user = User::factory()->create();
$currentProduct = $this->makeProduct([
'name' => 'Starter',
'slug' => 'starter-phase3-upgrade',
'price_monthly' => 10,
'disk_gb' => 10,
'max_domains' => 1,
'max_databases' => 2,
'max_email_accounts' => 5,
]);
$targetProduct = $this->makeProduct([
'name' => 'Growth',
'slug' => 'growth-phase3-upgrade',
'price_monthly' => 25,
'disk_gb' => 25,
'max_domains' => 5,
'max_databases' => 10,
'max_email_accounts' => 15,
]);
$account = $this->makeAccount($user, $currentProduct, [
'allocated_disk_gb' => 10,
'expires_at' => now()->addMonth(),
'resource_limits' => [
'disk_gb' => 10,
'max_domains' => 1,
'max_databases' => 2,
'max_email_accounts' => 5,
],
]);
CustomerHostingOrder::create([
'user_id' => $user->id,
'hosting_product_id' => $currentProduct->id,
'hosting_account_id' => $account->id,
'domain_name' => $account->primary_domain,
'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY,
'amount_paid' => 10,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_ACTIVE,
'expires_at' => now()->addMonth(),
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('syncAccountPackage')->once()->andReturn(true);
$this->app->instance(SharedNodeProvider::class, $provider);
$this->actingAs($user)
->postJson('/api/hosting/upgrade', [
'hosting_account_id' => $account->id,
'target_product_id' => $targetProduct->id,
])
->assertOk()
->assertJsonPath('data.quote.direction', HostingPlanChange::DIRECTION_UPGRADE)
->assertJsonPath('data.quote.charge_amount', 15)
->assertJsonPath('data.invoice.total_amount', '15.00');
$account->refresh();
$this->assertSame($targetProduct->id, $account->hosting_product_id);
$this->assertSame(25, $account->allocated_disk_gb);
$this->assertSame(5, data_get($account->resource_limits, 'max_domains'));
$this->assertSame(15, data_get($account->resource_limits, 'max_email_accounts'));
$this->assertDatabaseHas('hosting_plan_changes', [
'hosting_account_id' => $account->id,
'direction' => HostingPlanChange::DIRECTION_UPGRADE,
'charge_amount' => 15,
'credit_amount' => 0,
]);
$this->assertDatabaseHas('hosting_billing_invoices', [
'hosting_account_id' => $account->id,
'invoice_type' => HostingBillingInvoice::TYPE_PLAN_CHANGE,
'status' => HostingBillingInvoice::STATUS_PENDING,
'total_amount' => 15,
]);
}
public function test_downgrade_endpoint_applies_credit_and_reduces_quota_immediately(): void
{
Carbon::setTestNow(Carbon::create(2026, 4, 6, 10, 0, 0));
$user = User::factory()->create();
$currentProduct = $this->makeProduct([
'name' => 'Growth',
'slug' => 'growth-phase3-downgrade',
'price_monthly' => 25,
'disk_gb' => 25,
'max_domains' => 5,
'max_databases' => 10,
'max_email_accounts' => 15,
]);
$targetProduct = $this->makeProduct([
'name' => 'Starter',
'slug' => 'starter-phase3-downgrade',
'price_monthly' => 10,
'disk_gb' => 10,
'max_domains' => 1,
'max_databases' => 2,
'max_email_accounts' => 5,
]);
$account = $this->makeAccount($user, $currentProduct, [
'allocated_disk_gb' => 25,
'expires_at' => now()->addMonth(),
'resource_limits' => [
'disk_gb' => 25,
'max_domains' => 5,
'max_databases' => 10,
'max_email_accounts' => 15,
],
]);
CustomerHostingOrder::create([
'user_id' => $user->id,
'hosting_product_id' => $currentProduct->id,
'hosting_account_id' => $account->id,
'domain_name' => $account->primary_domain,
'billing_cycle' => CustomerHostingOrder::CYCLE_MONTHLY,
'amount_paid' => 25,
'currency' => 'GHS',
'status' => CustomerHostingOrder::STATUS_ACTIVE,
'expires_at' => now()->addMonth(),
]);
$provider = \Mockery::mock(SharedNodeProvider::class);
$provider->shouldReceive('syncAccountPackage')->once()->andReturn(true);
$this->app->instance(SharedNodeProvider::class, $provider);
$this->actingAs($user)
->postJson('/api/hosting/downgrade', [
'hosting_account_id' => $account->id,
'target_product_id' => $targetProduct->id,
])
->assertOk()
->assertJsonPath('data.quote.direction', HostingPlanChange::DIRECTION_DOWNGRADE)
->assertJsonPath('data.quote.credit_amount', 15)
->assertJsonPath('data.invoice.status', HostingBillingInvoice::STATUS_CREDITED);
$account->refresh();
$this->assertSame($targetProduct->id, $account->hosting_product_id);
$this->assertSame(10, $account->allocated_disk_gb);
$this->assertDatabaseHas('hosting_billing_invoices', [
'hosting_account_id' => $account->id,
'invoice_type' => HostingBillingInvoice::TYPE_PLAN_CHANGE,
'status' => HostingBillingInvoice::STATUS_CREDITED,
'credit_amount' => 15,
'total_amount' => 0,
]);
}
public function test_paid_hosting_mailbox_creation_creates_addon_invoice(): void
{
$user = User::factory()->create();
$product = $this->makeProduct([
'name' => 'Starter Mail',
'slug' => 'starter-mail-phase3',
'max_email_accounts' => 1,
]);
$account = $this->makeAccount($user, $product);
$domain = $this->makeDomain($user, $account, 'phase3-mail.test');
Mailbox::create([
'user_id' => $user->id,
'hosting_account_id' => $account->id,
'domain_id' => $domain->id,
'display_name' => 'Free Mailbox',
'local_part' => 'info',
'address' => 'info@phase3-mail.test',
'password_hash' => Hash::make('password123'),
'quota_mb' => 2048,
'status' => 'active',
'maildir_path' => 'hosting/1/phase3-mail.test/info',
'credentials_issued_at' => now(),
'incoming_token' => 'token-free-1',
'inbound_enabled' => true,
'outbound_enabled' => true,
'outbound_provider' => 'smtp',
'is_paid' => false,
]);
$this->actingAs($user)
->postJson('/api/email/create', [
'hosting_account_id' => $account->id,
'domain_id' => $domain->id,
'local_part' => 'sales',
'display_name' => 'Sales',
'password' => 'password123',
])
->assertCreated()
->assertJsonPath('data.mailbox.is_paid', true)
->assertJsonPath('data.usage.extra_count', 1)
->assertJsonPath('data.invoice.total_amount', '5.00');
$this->assertDatabaseHas('mailboxes', [
'hosting_account_id' => $account->id,
'address' => 'sales@phase3-mail.test',
'is_paid' => true,
]);
$this->assertDatabaseHas('hosting_billing_invoices', [
'hosting_account_id' => $account->id,
'invoice_type' => HostingBillingInvoice::TYPE_EMAIL_ADDON,
'status' => HostingBillingInvoice::STATUS_PENDING,
'total_amount' => 5,
]);
}
public function test_mailbox_delete_requires_account_ownership(): void
{
$owner = User::factory()->create();
$otherUser = User::factory()->create();
$product = $this->makeProduct([
'name' => 'Starter Delete',
'slug' => 'starter-delete-phase3',
'max_email_accounts' => 1,
]);
$account = $this->makeAccount($owner, $product);
$domain = $this->makeDomain($owner, $account, 'delete-mail.test');
$mailbox = Mailbox::create([
'user_id' => $owner->id,
'hosting_account_id' => $account->id,
'domain_id' => $domain->id,
'display_name' => 'Owner Mailbox',
'local_part' => 'admin',
'address' => 'admin@delete-mail.test',
'password_hash' => Hash::make('password123'),
'quota_mb' => 2048,
'status' => 'active',
'maildir_path' => 'hosting/1/delete-mail.test/admin',
'credentials_issued_at' => now(),
'incoming_token' => 'token-delete-1',
'inbound_enabled' => true,
'outbound_enabled' => true,
'outbound_provider' => 'smtp',
'is_paid' => false,
]);
$this->actingAs($otherUser)
->deleteJson('/api/email/delete', [
'mailbox_id' => $mailbox->id,
])
->assertForbidden();
}
private function makeProduct(array $overrides = []): HostingProduct
{
return HostingProduct::create(array_merge([
'name' => 'Shared Phase 3',
'slug' => 'shared-phase-3-' . str()->random(8),
'category' => HostingProduct::CATEGORY_SHARED,
'type' => HostingProduct::TYPE_SINGLE_DOMAIN,
'price_monthly' => 10,
'price_quarterly' => 30,
'price_yearly' => 120,
'price_biennial' => 240,
'currency' => 'GHS',
'disk_gb' => 10,
'max_domains' => 1,
'max_databases' => 2,
'max_email_accounts' => 5,
'is_active' => true,
'is_visible' => true,
], $overrides));
}
private function makeAccount(User $user, HostingProduct $product, array $overrides = []): HostingAccount
{
return HostingAccount::unguarded(function () use ($user, $product, $overrides) {
return HostingAccount::create(array_merge([
'user_id' => $user->id,
'hosting_product_id' => $product->id,
'username' => 'acct' . random_int(1000, 9999),
'primary_domain' => 'phase3.example.test',
'type' => HostingAccount::TYPE_SHARED,
'status' => HostingAccount::STATUS_ACTIVE,
'allocated_disk_gb' => (int) ($product->disk_gb ?? 0),
'php_version' => '8.4',
'cpu_limit_percent' => 50,
'memory_limit_mb' => 512,
'process_limit' => 10,
'io_limit_mb' => 5,
'inode_limit' => 50000,
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
'resource_limits' => [
'disk_gb' => (int) ($product->disk_gb ?? 0),
'max_domains' => $product->max_domains,
'max_databases' => $product->max_databases,
'max_email_accounts' => $product->max_email_accounts,
],
], $overrides));
});
}
private function makeDomain(User $user, HostingAccount $account, string $host): Domain
{
return Domain::create([
'user_id' => $user->id,
'website_id' => null,
'hosting_account_id' => $account->id,
'host' => $host,
'type' => 'custom',
'source' => 'hosting',
'status' => 'verified',
'onboarding_mode' => Domain::MODE_NS_AUTO,
'onboarding_state' => Domain::STATE_ACTIVE,
'dns_mode' => 'managed',
'verified_at' => now(),
'active_at' => now(),
]);
}
}