Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
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>
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user