create(); $product = Product::factory()->create(['product_type' => 'hosting', 'is_active' => true]); $order = Order::create([ 'order_num' => 'ORD-' . uniqid(), 'customer_id' => $customer->id, 'sub_total' => $price, 'total' => $price, 'status' => OrderStatus::ACTIVE->value, ]); $orderItem = OrderItem::create([ 'order_id' => $order->id, 'product_id' => $product->id, 'billing_cycle' => 'monthly', 'months' => 1, 'quantity' => 1, 'price' => $price, ]); return Service::create([ 'customer_id' => $customer->id, 'product_id' => $product->id, 'order_item_id' => $orderItem->id, 'status' => 'active', 'server_name' => 'test-server', 'expires_at' => now()->addDays(3), ]); } public function test_expiry_command_creates_renewal_invoice_on_the_original_order(): void { $service = $this->createExpiringService(90_000); $originalOrderId = $service->orderItem->order_id; $this->artisan('service:expiry')->assertExitCode(0); $service->refresh(); $this->assertNotNull($service->invoice_id); $invoice = Invoice::find($service->invoice_id); $this->assertSame($originalOrderId, $invoice->order_id); $this->assertSame(2, $invoice->invoice_type); $this->assertSame(InvoiceStatus::UNPAID->value, $invoice->status); $this->assertNotNull($invoice->billing_period_start); $this->assertNotNull($invoice->billing_period_end); // Order 1:N Invoice — no second Order row created for the renewal. $this->assertSame(1, Order::query()->count()); } public function test_running_expiry_command_twice_does_not_duplicate_the_renewal_invoice(): void { $service = $this->createExpiringService(90_000); $this->artisan('service:expiry')->assertExitCode(0); $firstInvoiceId = $service->refresh()->invoice_id; $this->artisan('service:expiry')->assertExitCode(0); $secondInvoiceId = $service->refresh()->invoice_id; $this->assertSame($firstInvoiceId, $secondInvoiceId); $this->assertSame(1, Invoice::where('invoice_type', 2)->count()); } public function test_cancelled_service_does_not_get_a_renewal_invoice(): void { $service = $this->createExpiringService(90_000); $service->update(['status' => 'cancelled', 'expires_at' => now()->subDay()]); $this->artisan('service:expiry')->assertExitCode(0); $this->assertNull($service->refresh()->invoice_id); $this->assertSame(0, Invoice::where('invoice_type', 2)->count()); } /** * Đúng flow đề bài mục 6: Invoice gia hạn quá due_date -> chuyển OVERDUE (không tạo Invoice mới). * Test này verify bước tiếp theo: cron service:expiry chạy lại SAU KHI invoice đã OVERDUE * (không còn UNPAID nữa) vẫn phải nhận ra invoice đang mở và KHÔNG tạo invoice #2 trùng kỳ. */ public function test_expiry_command_does_not_duplicate_invoice_after_it_became_overdue(): void { $service = $this->createExpiringService(90_000); // Lần chạy 1: sinh invoice gia hạn (UNPAID). $this->artisan('service:expiry')->assertExitCode(0); $invoiceId = $service->refresh()->invoice_id; $this->assertNotNull($invoiceId); // Giả lập thời gian trôi qua khỏi due_date rồi cron invoices:check-overdue chạy thật, // chuyển invoice sang OVERDUE — đúng như flow thật, không set status tay. Invoice::whereKey($invoiceId)->update(['due_date' => now()->subDay()->toDateString()]); $this->artisan('invoices:check-overdue')->assertExitCode(0); $invoice = Invoice::find($invoiceId); $this->assertSame(InvoiceStatus::OVERDUE->value, (int) $invoice->status); // Lần chạy 2: service:expiry chạy lại (vd. mỗi 5 phút) trong khi invoice đang OVERDUE. $this->artisan('service:expiry')->assertExitCode(0); $this->assertSame($invoiceId, $service->refresh()->invoice_id); $this->assertSame(1, Invoice::where('invoice_type', 2)->count()); // Vẫn đúng là OVERDUE — service:expiry không được đụng vào status của invoice. $this->assertSame(InvoiceStatus::OVERDUE->value, (int) $invoice->fresh()->status); } }