feat: nhắc gia hạn QR theo dịch vụ, failover server hosting, CCCD admin, log cấp phát dịch vụ
- Mail campaign: mẫu nhắc gia hạn thanh toán QR, chọn dịch vụ + chu kỳ từng dòng (renewal_services) - Hosting: nhiều server Plesk cho một gói (service_plan_servers), chuyển server khi server chết - Admin xem/sửa giấy tờ định danh khách, tuỳ chọn gia hạn cho khách, nhật ký cấp phát dịch vụ - Đánh dấu mẫu email dịch vụ là non-marketing Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
047c23beff
commit
55a834a2f8
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Jobs;
|
||||
|
||||
use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Enums\Order\OrderStatus;
|
||||
use App\Jobs\Hosting\ProvisionHostingAccountJob;
|
||||
use App\Jobs\Mail\Admin\HostingAccountCreatedAdminNotificationJob;
|
||||
use App\Jobs\Mail\Customer\HostingAccountCreatedJob;
|
||||
use App\Jobs\Mail\Customer\VMCreationFailedJob;
|
||||
use App\Models\Customer;
|
||||
use App\Models\DnsServer;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\ModuleHostingAccount;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServicePlan;
|
||||
use App\Services\Hosting\HostingServerSelector;
|
||||
use App\Services\InvoicePaymentService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class HostingServerFailoverTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function server(string $ip, array $overrides = []): DnsServer
|
||||
{
|
||||
return DnsServer::create($overrides + [
|
||||
'name' => "plesk-{$ip}",
|
||||
'ip_address' => $ip,
|
||||
'username' => 'admin',
|
||||
'password' => Crypt::encryptString('secret'),
|
||||
'status' => 'activated',
|
||||
'type' => 'hosting',
|
||||
]);
|
||||
}
|
||||
|
||||
private function plan(DnsServer ...$servers): ServicePlan
|
||||
{
|
||||
$plan = ServicePlan::create(['title' => 'Basic', 'vcpu' => 1, 'ram' => 1, 'disk' => 10, 'domain' => 1]);
|
||||
$plan->plesk()->create(['plesk_plan_name' => 'basic-hosting']);
|
||||
$plan->syncServers(array_map(fn (DnsServer $server) => $server->id, $servers));
|
||||
|
||||
return $plan->fresh();
|
||||
}
|
||||
|
||||
public function test_pick_skips_unhealthy_and_inactive_servers_in_priority_order(): void
|
||||
{
|
||||
Cache::flush();
|
||||
$down = $this->server('10.0.0.1');
|
||||
$inactive = $this->server('10.0.0.2', ['status' => 'deactivated']);
|
||||
$up = $this->server('10.0.0.3');
|
||||
$plan = $this->plan($down, $inactive, $up);
|
||||
|
||||
Http::fake([
|
||||
'10.0.0.1:8443/*' => Http::response([], 500),
|
||||
'*' => Http::response(['platform' => 'Linux'], 200),
|
||||
]);
|
||||
|
||||
$selector = app(HostingServerSelector::class);
|
||||
|
||||
$this->assertSame($up->id, $selector->pick($plan)->id);
|
||||
$this->assertNull($selector->pick($plan, [$up->id]));
|
||||
}
|
||||
|
||||
public function test_plan_primary_server_column_follows_first_priority(): void
|
||||
{
|
||||
$a = $this->server('10.0.0.1');
|
||||
$b = $this->server('10.0.0.2');
|
||||
$plan = $this->plan($b, $a);
|
||||
|
||||
$this->assertSame($b->id, (int) $plan->dns_manager_server_id);
|
||||
$this->assertSame([$b->id, $a->id], $plan->servers->pluck('id')->all());
|
||||
}
|
||||
|
||||
public function test_provisioning_fails_over_to_the_backup_server_when_the_primary_goes_down(): void
|
||||
{
|
||||
Cache::flush();
|
||||
Bus::fake([HostingAccountCreatedJob::class, HostingAccountCreatedAdminNotificationJob::class]);
|
||||
$primary = $this->server('10.0.0.1');
|
||||
$backup = $this->server('10.0.0.2');
|
||||
$plan = $this->plan($primary, $backup);
|
||||
|
||||
Http::fake([
|
||||
'10.0.0.1:8443/api/v2/server*' => Http::sequence()->push([], 200)->push([], 500),
|
||||
'10.0.0.1:8443/*' => Http::response(['message' => 'down'], 500),
|
||||
'10.0.0.2:8443/api/v2/server*' => Http::response([], 200),
|
||||
'10.0.0.2:8443/api/v2/clients*' => Http::response(['id' => 7, 'guid' => 'client-guid'], 201),
|
||||
'10.0.0.2:8443/*' => Http::response(['id' => 9, 'guid' => 'sub-guid', 'www_root' => '/var/www/vhosts/khach.test/httpdocs'], 201),
|
||||
'*' => Http::response([], 200),
|
||||
]);
|
||||
|
||||
[$item, $service] = $this->paidHostingOrder($plan);
|
||||
|
||||
dispatch_sync(new ProvisionHostingAccountJob($item));
|
||||
|
||||
$this->assertSame('active', $service->fresh()->status);
|
||||
$this->assertSame($backup->id, ModuleHostingAccount::where('service_id', $service->id)->value('dns_manager_server_id'));
|
||||
}
|
||||
|
||||
public function test_provisioning_fails_and_refunds_when_every_server_is_down(): void
|
||||
{
|
||||
Cache::flush();
|
||||
Bus::fake([VMCreationFailedJob::class]);
|
||||
$plan = $this->plan($this->server('10.0.0.1'), $this->server('10.0.0.2'));
|
||||
|
||||
Http::fake(['10.0.0.*' => Http::response([], 500), '*' => Http::response([], 200)]);
|
||||
|
||||
[$item, $service, $customer, $invoice, $order] = $this->paidHostingOrder($plan);
|
||||
|
||||
dispatch_sync(new ProvisionHostingAccountJob($item));
|
||||
|
||||
$this->assertSame('failed', $service->fresh()->status);
|
||||
$this->assertSame(500_000, (int) $customer->fresh()->total);
|
||||
$this->assertSame(InvoiceStatus::REFUNDED->value, (int) $invoice->fresh()->status);
|
||||
$this->assertSame(OrderStatus::CANCELLED->value, $order->fresh()->status);
|
||||
}
|
||||
|
||||
private function paidHostingOrder(ServicePlan $plan): array
|
||||
{
|
||||
$customer = Customer::factory()->create(['total' => 500_000]);
|
||||
$product = Product::factory()->create(['product_type' => 'hosting', 'service_plan_id' => $plan->id]);
|
||||
$order = Order::create([
|
||||
'order_num' => 'ORD-' . uniqid(),
|
||||
'customer_id' => $customer->id,
|
||||
'sub_total' => 200_000,
|
||||
'total' => 200_000,
|
||||
'status' => OrderStatus::PENDING->value,
|
||||
]);
|
||||
$item = OrderItem::create([
|
||||
'order_id' => $order->id,
|
||||
'product_id' => $product->id,
|
||||
'billing_cycle' => 'monthly',
|
||||
'months' => 1,
|
||||
'server_name' => 'khach.test',
|
||||
'quantity' => 1,
|
||||
'price' => 200_000,
|
||||
]);
|
||||
$invoice = Invoice::create([
|
||||
'invoice_number' => 'INV-' . uniqid(),
|
||||
'customer_id' => $customer->id,
|
||||
'order_id' => $order->id,
|
||||
'invoice_type' => 1,
|
||||
'sub_total' => 200_000,
|
||||
'tax' => 0,
|
||||
'total' => 200_000,
|
||||
'status' => InvoiceStatus::UNPAID->value,
|
||||
]);
|
||||
$service = Service::create([
|
||||
'customer_id' => $customer->id,
|
||||
'product_id' => $product->id,
|
||||
'order_item_id' => $item->id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'server_name' => 'khach.test',
|
||||
'status' => 'pending',
|
||||
'started_at' => now(),
|
||||
'expires_at' => now()->addMonth(),
|
||||
]);
|
||||
|
||||
app(InvoicePaymentService::class)->payWithBalance($invoice, $customer, 'balance');
|
||||
|
||||
return [$item->fresh(), $service, $customer, $invoice, $order];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user