Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
375 lines
14 KiB
PHP
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(),
|
|
]);
|
|
}
|
|
}
|