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:
baodev0
2026-10-30 18:36:41 +07:00
co-authored by Claude Sonnet 5.5
parent 047c23beff
commit 55a834a2f8
53 changed files with 1463 additions and 119 deletions
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin;
use App\Models\Customer;
use App\Models\Product;
use App\Models\Service;
use App\Models\Template;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class MailCampaignRenewalServicesTest extends TestCase
{
use RefreshDatabase;
private function admin(): User
{
$role = Role::create(['name' => 'role-' . uniqid(), 'guard_name' => 'admin']);
$role->givePermissionTo(Permission::firstOrCreate(['name' => 'mail-campaign.create', 'guard_name' => 'admin']));
$admin = User::factory()->create();
$admin->assignRole($role);
return $admin;
}
public function test_renewal_services_listing_only_contains_due_services_of_selected_customers(): void
{
$customer = Customer::factory()->create();
$this->actingAs($this->admin(), 'admin')
->getJson('/api/v1/administrator/mail-campaigns/renewal-services?' . http_build_query(['uuids' => [$customer->uuid], 'days' => 30]))
->assertOk()
->assertExactJson(['error' => false, 'data' => []]);
}
public function test_renewal_services_are_rejected_for_a_template_that_is_not_a_renewal_reminder(): void
{
Queue::fake();
$customer = Customer::factory()->create();
$template = Template::create([
'type_id' => 0, 'name' => 'T ' . uniqid(), 'format' => 'html',
'subject' => 'Ưu đãi', 'message' => '<p>Hello</p>', 'status' => 1,
]);
$service = Service::query()->forceCreate([
'uuid' => (string) \Illuminate\Support\Str::uuid(), 'customer_id' => $customer->id,
'product_id' => Product::factory()->create()->id, 'type' => 'vps', 'status' => 'active',
]);
$this->actingAs($this->admin(), 'admin')
->postJson('/api/v1/administrator/mail-campaigns', [
'template_id' => $template->id,
'recipient_type' => 'selected',
'uuids' => [$customer->uuid],
'renewal_services' => [['service_id' => $service->id, 'months' => 1]],
])
->assertStatus(422);
}
}
@@ -116,12 +116,33 @@ class ClientTwoFactorTest extends TestCase
$this->assertTrue($device->expires_at->between(now()->addDays(89), now()->addDays(91)));
$this->login($customer, $deviceToken)->assertOk()->assertJsonStructure(['data' => ['token']]);
$this->login($customer, str_repeat('x', 64))->assertOk()->assertJsonPath('data.two_factor.resend_in', 60);
$this->login($customer, str_repeat('x', 64))->assertOk()->assertJsonPath('data.two_factor.resend_in', 300);
$this->travel(91)->days();
$this->login($customer, $deviceToken)->assertOk()->assertJsonStructure(['data' => ['two_factor']]);
}
public function test_repeated_logins_from_same_client_reuse_code_until_it_expires(): void
{
Mail::fake();
$customer = $this->customer(twoFactor: true);
$first = $this->login($customer)->json('data.two_factor.challenge');
$this->travel(2)->minutes();
$second = $this->login($customer)->assertJsonPath('data.two_factor.resend_in', 180)->json('data.two_factor.challenge');
$this->assertSame($first, $second);
Mail::assertQueuedCount(1);
$this->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])->login($customer);
Mail::assertQueuedCount(2);
$this->travel(301)->seconds();
$third = $this->login($customer)->json('data.two_factor.challenge');
$this->assertNotSame($first, $third);
Mail::assertQueuedCount(3);
}
public function test_challenge_is_destroyed_after_five_wrong_codes(): void
{
Mail::fake();
@@ -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];
}
}
@@ -0,0 +1,82 @@
<?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\Customer\VMCreationFailedJob;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\Service;
use App\Services\InvoicePaymentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class ProvisionHostingAccountJobTest extends TestCase
{
use RefreshDatabase;
public function test_failed_provisioning_marks_service_failed_and_refunds_the_customer(): void
{
Bus::fake([VMCreationFailedJob::class]);
Http::fake();
$customer = Customer::factory()->create(['total' => 500_000]);
$product = Product::factory()->create(['product_type' => 'hosting', 'service_plan_id' => null]);
$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-hang.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-hang.test',
'status' => 'pending',
'started_at' => now(),
'expires_at' => now()->addMonth(),
]);
app(InvoicePaymentService::class)->payWithBalance($invoice, $customer, 'balance');
$this->assertSame(300_000, (int) $customer->fresh()->total);
dispatch_sync(new ProvisionHostingAccountJob($item->fresh()));
$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);
Bus::assertDispatched(VMCreationFailedJob::class);
}
}
+14 -2
View File
@@ -42,7 +42,7 @@ class TemplateSeederTest extends TestCase
}
}
public function test_service_lifecycle_templates_are_seeded_once_as_marketing(): void
public function test_service_lifecycle_templates_are_seeded_once_and_are_not_marketing(): void
{
$items = ServiceLifecycleTemplates::all();
$this->assertCount(39, $items);
@@ -53,11 +53,23 @@ class TemplateSeederTest extends TestCase
foreach ($items as $item) {
$this->assertSame(1, Template::where('name', $item['name'])->count(), $item['name']);
$template = Template::where('name', $item['name'])->first();
$this->assertTrue($template->isMarketing(), $item['name']);
$this->assertFalse($template->isMarketing(), $item['name']);
$this->assertStringContainsString('class="Footer"', $template->message, $item['name']);
}
}
public function test_only_promotional_templates_are_marketing_and_respect_customer_opt_out(): void
{
$this->seed(TemplateSeeder::class);
$marketing = Template::whereNull('key')->whereNull('scenario')->get()
->filter(fn (Template $template) => $template->isMarketing())
->pluck('name')->sort()->values()->all();
$this->assertSame(['Khuyến mãi tháng 10', 'Ra mắt Premium Hosting'], $marketing);
$this->assertFalse(Template::where('name', 'Nhắc gia hạn dịch vụ - Thanh toán QR')->firstOrFail()->isMarketing());
}
public function test_generic_new_service_template_fills_customer_email_and_hides_empty_access_rows(): void
{
$this->seed(TemplateSeeder::class);