- Implement AdminDomainOrderTest to cover admin functionalities for domain orders including listing, confirming registration, KYC updates, activation, rejection, and refunds. - Implement ClientDomainOrderTest to cover client functionalities for placing domain orders, including validation for balance, duplicate orders, and domain normalization. - Ensure proper handling of domain pricing and service statuses throughout the tests.
400 lines
16 KiB
PHP
400 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api\DomainOrder;
|
|
|
|
use App\Enums\Domain\DomainProvisioningStatus;
|
|
use App\Enums\Invoice\InvoiceStatus;
|
|
use App\Enums\Order\OrderStatus;
|
|
use App\Helpers\OrderHelper;
|
|
use App\Models\Customer;
|
|
use App\Models\DnsDomain;
|
|
use App\Models\Invoice;
|
|
use App\Models\InvoiceItem;
|
|
use App\Models\Order;
|
|
use App\Models\OrderItem;
|
|
use App\Models\Product;
|
|
use App\Models\Service;
|
|
use App\Models\Setting;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Spatie\Permission\Models\Role;
|
|
use Tests\TestCase;
|
|
|
|
class AdminDomainOrderTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private User $admin;
|
|
private Customer $customer;
|
|
private Product $product;
|
|
private int $dnsServerId;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
|
|
|
$this->dnsServerId = DB::table('dns_manager_servers')->insertGetId([
|
|
'name' => 'Test DNS Server',
|
|
'ip_address' => '127.0.0.1',
|
|
'password' => 'test-password',
|
|
'api_key' => 'test-key',
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
$this->admin = User::factory()->create();
|
|
$this->admin->assignRole('Full Administrator');
|
|
|
|
$this->customer = Customer::factory()->create(['total' => 1_000_000]);
|
|
|
|
$this->product = Product::factory()->create([
|
|
'product_type' => 'domain',
|
|
'is_active' => true,
|
|
]);
|
|
|
|
Setting::create([
|
|
'key' => 'domain_pricing',
|
|
'value' => json_encode([
|
|
'domains' => [
|
|
['tld' => '.com', 'firstYear' => 320_000, 'renewal' => 340_000],
|
|
['tld' => '.vn', 'firstYear' => 400_000, 'renewal' => 400_000],
|
|
],
|
|
]),
|
|
]);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Helpers
|
|
// =========================================================================
|
|
|
|
private function actingAsAdmin(): static
|
|
{
|
|
return $this->actingAs($this->admin, 'admin');
|
|
}
|
|
|
|
/** Create a fully-formed domain order in DB without going through the HTTP layer. */
|
|
private function createDomainOrder(string $domain = 'test.com', string $serviceStatus = 'pending'): array
|
|
{
|
|
$order = Order::create([
|
|
'order_num' => OrderHelper::generateOrderNumber(),
|
|
'customer_id' => $this->customer->id,
|
|
'sub_total' => 320_000,
|
|
'discount_amount'=> 0,
|
|
'total' => 320_000,
|
|
'status' => OrderStatus::PROCESSING->value,
|
|
'payment_method' => 'balance',
|
|
]);
|
|
|
|
$orderItem = OrderItem::create([
|
|
'order_id' => $order->id,
|
|
'product_id' => $this->product->id,
|
|
'domain_name' => $domain,
|
|
'billing_cycle' => 'yearly',
|
|
'months' => 12,
|
|
'quantity' => 1,
|
|
'price' => 320_000,
|
|
'server_name' => $domain,
|
|
]);
|
|
|
|
$invoice = Invoice::create([
|
|
'invoice_number' => OrderHelper::generateOrderNumber('INV'),
|
|
'customer_id' => $this->customer->id,
|
|
'order_id' => $order->id,
|
|
'invoice_type' => 1,
|
|
'sub_total' => 320_000,
|
|
'discount_amount' => 0,
|
|
'tax' => 0,
|
|
'total' => 320_000,
|
|
'status' => InvoiceStatus::PAID->value,
|
|
'payment_method' => 'balance',
|
|
'paid_at' => now(),
|
|
'due_date' => now()->toDateString(),
|
|
]);
|
|
|
|
InvoiceItem::create([
|
|
'invoice_id' => $invoice->id,
|
|
'product_id' => $this->product->id,
|
|
'type' => 'domain',
|
|
'description' => "Đăng ký tên miền {$domain}",
|
|
'months' => 12,
|
|
'quantity' => 1,
|
|
'unit_price' => 320_000,
|
|
'total_price' => 320_000,
|
|
]);
|
|
|
|
$service = Service::create([
|
|
'customer_id' => $this->customer->id,
|
|
'product_id' => $this->product->id,
|
|
'order_item_id' => $orderItem->id,
|
|
'invoice_id' => $invoice->id,
|
|
'server_name' => $domain,
|
|
'status' => $serviceStatus,
|
|
'auto_renew' => false,
|
|
]);
|
|
|
|
return compact('order', 'orderItem', 'invoice', 'service');
|
|
}
|
|
|
|
// =========================================================================
|
|
// List & detail
|
|
// =========================================================================
|
|
|
|
public function test_admin_can_list_domain_orders(): void
|
|
{
|
|
$this->createDomainOrder('list-test.com');
|
|
|
|
$response = $this->actingAsAdmin()->getJson('/api/v1/administrator/domain-orders');
|
|
|
|
$response->assertOk()
|
|
->assertJsonPath('error', false)
|
|
->assertJsonStructure(['data' => ['items', 'total', 'per_page', 'current_page', 'last_page']]);
|
|
|
|
$this->assertCount(1, $response->json('data.items'));
|
|
}
|
|
|
|
public function test_non_admin_cannot_access_domain_orders(): void
|
|
{
|
|
$this->getJson('/api/v1/administrator/domain-orders')->assertStatus(401);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Confirm registration
|
|
// =========================================================================
|
|
|
|
public function test_confirm_registration_creates_dns_domain_and_attaches_customer(): void
|
|
{
|
|
['order' => $order] = $this->createDomainOrder('register-me.com');
|
|
|
|
$response = $this->actingAsAdmin()->postJson("/api/v1/administrator/domain-orders/{$order->id}/confirm-registration", [
|
|
'dns_manager_server_id' => $this->dnsServerId,
|
|
'registrar_reference' => 'REF-12345',
|
|
'registered_at' => now()->toDateString(),
|
|
'expires_at' => now()->addYear()->toDateString(),
|
|
]);
|
|
|
|
$response->assertOk()->assertJsonPath('error', false);
|
|
|
|
$this->assertDatabaseHas('dns_manager_domain', ['domain_name' => 'register-me.com', 'is_created' => 1]);
|
|
$this->assertDatabaseHas('dns_domain_user', ['user_id' => $this->customer->id]);
|
|
$this->assertDatabaseHas('services', [
|
|
'status' => DomainProvisioningStatus::REGISTRAR_CREATED->value,
|
|
'registrar_reference' => 'REF-12345',
|
|
]);
|
|
}
|
|
|
|
public function test_cannot_confirm_already_confirmed_order(): void
|
|
{
|
|
['order' => $order] = $this->createDomainOrder('already-confirmed.com', DomainProvisioningStatus::REGISTRAR_CREATED->value);
|
|
|
|
$response = $this->actingAsAdmin()->postJson("/api/v1/administrator/domain-orders/{$order->id}/confirm-registration", []);
|
|
|
|
$response->assertStatus(422)->assertJsonPath('error', true);
|
|
}
|
|
|
|
public function test_cannot_create_duplicate_dns_domain(): void
|
|
{
|
|
['order' => $order] = $this->createDomainOrder('dup-dns.com');
|
|
|
|
// Pre-create the DNS domain so confirm hits the duplicate guard
|
|
DnsDomain::create(['domain_name' => 'dup-dns.com', 'dns_manager_server_id' => $this->dnsServerId, 'is_created' => 1]);
|
|
|
|
$response = $this->actingAsAdmin()->postJson("/api/v1/administrator/domain-orders/{$order->id}/confirm-registration", []);
|
|
|
|
$response->assertStatus(422)->assertJsonPath('error', true);
|
|
}
|
|
|
|
// =========================================================================
|
|
// KYC
|
|
// =========================================================================
|
|
|
|
public function test_update_kyc_url_sets_pending_kyc_status(): void
|
|
{
|
|
['order' => $order, 'service' => $service] = $this->createDomainOrder('kyc-test.com', DomainProvisioningStatus::REGISTRAR_CREATED->value);
|
|
|
|
$response = $this->actingAsAdmin()->patchJson("/api/v1/administrator/domain-orders/{$order->id}/kyc", [
|
|
'kyc_url' => 'https://kyc.example.com/verify/abc123',
|
|
]);
|
|
|
|
$response->assertOk()->assertJsonPath('error', false);
|
|
$this->assertDatabaseHas('services', [
|
|
'id' => $service->id,
|
|
'status' => DomainProvisioningStatus::PENDING_KYC->value,
|
|
'kyc_url'=> 'https://kyc.example.com/verify/abc123',
|
|
]);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Activate
|
|
// =========================================================================
|
|
|
|
public function test_activate_transitions_from_pending_kyc_to_active(): void
|
|
{
|
|
['order' => $order, 'service' => $service] = $this->createDomainOrder('activate-me.com', DomainProvisioningStatus::PENDING_KYC->value);
|
|
|
|
$response = $this->actingAsAdmin()->patchJson("/api/v1/administrator/domain-orders/{$order->id}/activate");
|
|
|
|
$response->assertOk()->assertJsonPath('error', false);
|
|
$this->assertDatabaseHas('services', ['id' => $service->id, 'status' => DomainProvisioningStatus::ACTIVE->value]);
|
|
$this->assertDatabaseHas('orders', ['id' => $order->id, 'status' => OrderStatus::ACTIVE->value]);
|
|
}
|
|
|
|
public function test_cannot_activate_pending_order_directly(): void
|
|
{
|
|
['order' => $order] = $this->createDomainOrder('cannot-activate.com', DomainProvisioningStatus::PENDING->value);
|
|
|
|
$response = $this->actingAsAdmin()->patchJson("/api/v1/administrator/domain-orders/{$order->id}/activate");
|
|
|
|
$response->assertStatus(422)->assertJsonPath('error', true);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Reject & Refund
|
|
// =========================================================================
|
|
|
|
public function test_reject_and_refund_restores_customer_balance(): void
|
|
{
|
|
$this->customer->update(['total' => 680_000]); // balance after paying 320k
|
|
|
|
['order' => $order, 'service' => $service] = $this->createDomainOrder('refund-me.com');
|
|
|
|
$response = $this->actingAsAdmin()->postJson("/api/v1/administrator/domain-orders/{$order->id}/reject-and-refund", [
|
|
'reason' => 'Tên miền không khả dụng.',
|
|
]);
|
|
|
|
$response->assertOk()->assertJsonPath('error', false);
|
|
|
|
$this->assertEquals(680_000 + 320_000, $this->customer->fresh()->total);
|
|
$this->assertDatabaseHas('services', [
|
|
'id' => $service->id,
|
|
'status' => DomainProvisioningStatus::REFUNDED->value,
|
|
]);
|
|
$this->assertDatabaseHas('invoices', ['order_id' => $order->id, 'status' => InvoiceStatus::REFUNDED->value]);
|
|
$this->assertDatabaseHas('orders', ['id' => $order->id, 'status' => OrderStatus::CANCELLED->value]);
|
|
$this->assertDatabaseHas('transactions', ['customer_id' => $this->customer->id, 'type' => 'refund', 'amount' => 320_000]);
|
|
}
|
|
|
|
public function test_reject_and_refund_is_idempotent(): void
|
|
{
|
|
['order' => $order] = $this->createDomainOrder('idempotent.com');
|
|
|
|
$this->actingAsAdmin()->postJson("/api/v1/administrator/domain-orders/{$order->id}/reject-and-refund", [
|
|
'reason' => 'First refund.',
|
|
])->assertOk();
|
|
|
|
// Second call must fail with 422
|
|
$this->actingAsAdmin()->postJson("/api/v1/administrator/domain-orders/{$order->id}/reject-and-refund", [
|
|
'reason' => 'Second attempt.',
|
|
])->assertStatus(422)->assertJsonPath('error', true);
|
|
}
|
|
|
|
public function test_cannot_reject_non_pending_order(): void
|
|
{
|
|
['order' => $order] = $this->createDomainOrder('not-pending.com', DomainProvisioningStatus::REGISTRAR_CREATED->value);
|
|
|
|
$response = $this->actingAsAdmin()->postJson("/api/v1/administrator/domain-orders/{$order->id}/reject-and-refund", [
|
|
'reason' => 'Should fail.',
|
|
]);
|
|
|
|
$response->assertStatus(422)->assertJsonPath('error', true);
|
|
}
|
|
|
|
public function test_refund_uses_invoice_amount_not_current_pricing(): void
|
|
{
|
|
// Manually create an order where the invoice total differs from current pricing
|
|
$order = Order::create([
|
|
'order_num' => OrderHelper::generateOrderNumber(),
|
|
'customer_id' => $this->customer->id,
|
|
'sub_total' => 250_000,
|
|
'discount_amount'=> 0,
|
|
'total' => 250_000,
|
|
'status' => OrderStatus::PROCESSING->value,
|
|
'payment_method' => 'balance',
|
|
]);
|
|
|
|
$orderItem = OrderItem::create([
|
|
'order_id' => $order->id,
|
|
'product_id' => $this->product->id,
|
|
'domain_name' => 'old-price.com',
|
|
'billing_cycle'=> 'yearly',
|
|
'months' => 12,
|
|
'quantity' => 1,
|
|
'price' => 250_000,
|
|
'server_name' => 'old-price.com',
|
|
]);
|
|
|
|
$invoice = Invoice::create([
|
|
'invoice_number' => OrderHelper::generateOrderNumber('INV'),
|
|
'customer_id' => $this->customer->id,
|
|
'order_id' => $order->id,
|
|
'invoice_type' => 1,
|
|
'sub_total' => 250_000,
|
|
'discount_amount' => 0,
|
|
'tax' => 0,
|
|
'total' => 250_000, // old price, not 320k
|
|
'status' => InvoiceStatus::PAID->value,
|
|
'payment_method' => 'balance',
|
|
'paid_at' => now(),
|
|
'due_date' => now()->toDateString(),
|
|
]);
|
|
|
|
Service::create([
|
|
'customer_id' => $this->customer->id,
|
|
'product_id' => $this->product->id,
|
|
'order_item_id' => $orderItem->id,
|
|
'invoice_id' => $invoice->id,
|
|
'server_name' => 'old-price.com',
|
|
'status' => DomainProvisioningStatus::PENDING->value,
|
|
'auto_renew' => false,
|
|
]);
|
|
|
|
$balanceBefore = (int) $this->customer->fresh()->total;
|
|
|
|
$this->actingAsAdmin()->postJson("/api/v1/administrator/domain-orders/{$order->id}/reject-and-refund", [
|
|
'reason' => 'Price test.',
|
|
])->assertOk();
|
|
|
|
// Must refund 250k (invoice amount), not 320k (current pricing)
|
|
$this->assertEquals($balanceBefore + 250_000, $this->customer->fresh()->total);
|
|
}
|
|
|
|
public function test_transaction_rollback_on_error(): void
|
|
{
|
|
// Order with no service — will throw inside the transaction
|
|
$order = Order::create([
|
|
'order_num' => OrderHelper::generateOrderNumber(),
|
|
'customer_id' => $this->customer->id,
|
|
'sub_total' => 320_000,
|
|
'discount_amount'=> 0,
|
|
'total' => 320_000,
|
|
'status' => OrderStatus::PROCESSING->value,
|
|
'payment_method' => 'balance',
|
|
]);
|
|
|
|
OrderItem::create([
|
|
'order_id' => $order->id,
|
|
'product_id' => $this->product->id,
|
|
'domain_name' => 'rollback.com',
|
|
'billing_cycle'=> 'yearly',
|
|
'months' => 12,
|
|
'quantity' => 1,
|
|
'price' => 320_000,
|
|
'server_name' => 'rollback.com',
|
|
]);
|
|
// No Invoice, no Service — rejectAndRefund will fail
|
|
|
|
$balanceBefore = (int) $this->customer->fresh()->total;
|
|
|
|
$this->actingAsAdmin()->postJson("/api/v1/administrator/domain-orders/{$order->id}/reject-and-refund", [
|
|
'reason' => 'Should rollback.',
|
|
])->assertStatus(422); // firstOrFail or invoice null → exception
|
|
|
|
// Balance must not have changed
|
|
$this->assertEquals($balanceBefore, $this->customer->fresh()->total);
|
|
}
|
|
}
|