update email template
This commit is contained in:
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Service;
|
||||
|
||||
use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Enums\Order\OrderStatus;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
@@ -123,4 +124,105 @@ class RenewServiceTest extends TestCase
|
||||
$response->assertStatus(400);
|
||||
$this->assertSame(0, Invoice::where('customer_id', $this->customer->id)->count());
|
||||
}
|
||||
|
||||
public function test_renew_reuses_original_order_instead_of_creating_a_new_one(): void
|
||||
{
|
||||
$service = $this->createServiceWithOldPrice(90_000);
|
||||
$originalOrderId = $service->orderItem->order_id;
|
||||
|
||||
ProductPricings::create([
|
||||
'product_id' => $this->product->id,
|
||||
'billing_cycle' => 'monthly',
|
||||
'price' => 100_000,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAsCustomer()->postJson(
|
||||
"/v1/client/dich-vu/{$service->id}/renew",
|
||||
['months' => 1],
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('data.order_id', $originalOrderId);
|
||||
|
||||
// Order 1:N Invoice — no second Order row is ever created for a renewal.
|
||||
$this->assertSame(1, Order::where('customer_id', $this->customer->id)->count());
|
||||
$this->assertDatabaseHas('invoices', [
|
||||
'id' => $response->json('data.invoice_id'),
|
||||
'order_id' => $originalOrderId,
|
||||
'invoice_type' => 2,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_renewal_invoice_has_a_billing_period_and_is_not_duplicated_for_the_same_period(): void
|
||||
{
|
||||
$service = $this->createServiceWithOldPrice(90_000);
|
||||
|
||||
ProductPricings::create([
|
||||
'product_id' => $this->product->id,
|
||||
'billing_cycle' => 'monthly',
|
||||
'price' => 100_000,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->actingAsCustomer()->postJson(
|
||||
"/v1/client/dich-vu/{$service->id}/renew",
|
||||
['months' => 1],
|
||||
);
|
||||
$response->assertOk();
|
||||
|
||||
$invoiceId = $response->json('data.invoice_id');
|
||||
$this->assertDatabaseHas('invoices', ['id' => $invoiceId]);
|
||||
$invoice = Invoice::find($invoiceId);
|
||||
$this->assertNotNull($invoice->billing_period_start);
|
||||
$this->assertNotNull($invoice->billing_period_end);
|
||||
|
||||
// Calling renew again while the invoice is still open must return the SAME
|
||||
// invoice, not create a duplicate for the same billing period.
|
||||
$second = $this->actingAsCustomer()->postJson(
|
||||
"/v1/client/dich-vu/{$service->id}/renew",
|
||||
['months' => 1],
|
||||
);
|
||||
$second->assertOk();
|
||||
$second->assertJsonPath('data.invoice_id', $invoiceId);
|
||||
$this->assertSame(1, Invoice::where('customer_id', $this->customer->id)->where('invoice_type', 2)->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* Đúng flow đề bài mục 6: invoice gia hạn quá due_date -> OVERDUE, KHÔNG tạo invoice mới.
|
||||
* Verify khách bấm "Gia hạn" lại LÚC invoice đang OVERDUE (chưa qua billing cycle tiếp theo)
|
||||
* vẫn nhận về đúng invoice cũ, không bị tạo trùng.
|
||||
*/
|
||||
public function test_renew_while_existing_invoice_is_overdue_returns_the_same_invoice(): void
|
||||
{
|
||||
$service = $this->createServiceWithOldPrice(90_000);
|
||||
|
||||
ProductPricings::create([
|
||||
'product_id' => $this->product->id,
|
||||
'billing_cycle' => 'monthly',
|
||||
'price' => 100_000,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$first = $this->actingAsCustomer()->postJson(
|
||||
"/v1/client/dich-vu/{$service->id}/renew",
|
||||
['months' => 1],
|
||||
);
|
||||
$first->assertOk();
|
||||
$invoiceId = $first->json('data.invoice_id');
|
||||
|
||||
// Invoice quá due_date -> cron invoices:check-overdue chuyển sang OVERDUE (flow thật).
|
||||
Invoice::whereKey($invoiceId)->update(['due_date' => now()->subDay()->toDateString()]);
|
||||
$this->artisan('invoices:check-overdue')->assertExitCode(0);
|
||||
$this->assertSame(InvoiceStatus::OVERDUE->value, (int) Invoice::find($invoiceId)->status);
|
||||
|
||||
// Khách bấm "Gia hạn" lại trong khi invoice đang OVERDUE — không được tạo invoice #2.
|
||||
$second = $this->actingAsCustomer()->postJson(
|
||||
"/v1/client/dich-vu/{$service->id}/renew",
|
||||
['months' => 1],
|
||||
);
|
||||
$second->assertOk();
|
||||
$second->assertJsonPath('data.invoice_id', $invoiceId);
|
||||
$this->assertSame(1, Invoice::where('customer_id', $this->customer->id)->where('invoice_type', 2)->count());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Enums\Order\OrderStatus;
|
||||
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 Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ServiceExpiryTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function createExpiringService(int $price): Service
|
||||
{
|
||||
$customer = Customer::factory()->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Exceptions\InvalidTemplateVariableException;
|
||||
use App\Jobs\Mail\SendTemplateEmailJob;
|
||||
use App\Mail\GenericTemplateMail;
|
||||
use App\Models\Template;
|
||||
use App\Services\Email\EmailTemplateService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmailTemplateServiceTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
private function makeTemplate(array $overrides = []): Template
|
||||
{
|
||||
return Template::create(array_merge([
|
||||
'type_id' => 0,
|
||||
'scenario' => 'unit_test_scenario_' . uniqid(),
|
||||
'format' => 'html',
|
||||
'name' => 'Test scenario',
|
||||
'subject' => 'Xin chào {{full_name}}',
|
||||
'message' => '<p>Chào {{full_name}}, mời truy cập {{login_url}}</p>',
|
||||
'metadata' => [
|
||||
'variables' => [
|
||||
['key' => 'full_name', 'label' => 'Họ tên', 'type' => 'string', 'example' => 'Quý khách'],
|
||||
['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://example.com'],
|
||||
],
|
||||
],
|
||||
'status' => 1,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
public function test_send_dispatches_job_to_queue(): void
|
||||
{
|
||||
Bus::fake();
|
||||
|
||||
$template = $this->makeTemplate();
|
||||
|
||||
app(EmailTemplateService::class)->send($template->scenario, 'user@example.com', [
|
||||
'full_name' => 'Nguyễn Văn A',
|
||||
'login_url' => 'https://portal.tgs.com.vn/login',
|
||||
]);
|
||||
|
||||
Bus::assertDispatched(SendTemplateEmailJob::class, fn (SendTemplateEmailJob $job) => $job->scenario === $template->scenario
|
||||
&& $job->to === ['user@example.com']
|
||||
&& $job->data['full_name'] === 'Nguyễn Văn A');
|
||||
}
|
||||
|
||||
public function test_job_renders_and_sends_mail_with_template_sender_config(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$template = $this->makeTemplate([
|
||||
'metadata' => [
|
||||
'variables' => [
|
||||
['key' => 'full_name', 'label' => 'Họ tên', 'type' => 'string'],
|
||||
['key' => 'login_url', 'label' => 'Link', 'type' => 'url'],
|
||||
],
|
||||
'mail' => [
|
||||
'from_email' => 'noreply@tgs.com.vn',
|
||||
'from_name' => 'Thế Giới Số',
|
||||
'reply_to' => 'support@tgs.com.vn',
|
||||
'reply_to_name' => 'TGS Support',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
(new SendTemplateEmailJob($template->scenario, ['user@example.com'], [
|
||||
'full_name' => 'Nguyễn Văn A',
|
||||
'login_url' => 'https://portal.tgs.com.vn/login',
|
||||
]))->handle(app(EmailTemplateService::class), app(\App\Services\Email\EmailTemplateRenderer::class));
|
||||
|
||||
Mail::assertSent(GenericTemplateMail::class, function (GenericTemplateMail $mail) {
|
||||
$mail->assertTo('user@example.com');
|
||||
$envelope = $mail->envelope();
|
||||
|
||||
return $envelope->subject === 'Xin chào Nguyễn Văn A'
|
||||
&& $envelope->from->address === 'noreply@tgs.com.vn'
|
||||
&& $envelope->replyTo[0]->address === 'support@tgs.com.vn'
|
||||
&& str_contains($mail->renderedHtml, 'Nguyễn Văn A');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_job_falls_back_to_system_default_sender_when_template_has_no_mail_config(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$template = $this->makeTemplate();
|
||||
|
||||
(new SendTemplateEmailJob($template->scenario, ['user@example.com'], [
|
||||
'full_name' => 'A',
|
||||
'login_url' => 'https://example.com',
|
||||
]))->handle(app(EmailTemplateService::class), app(\App\Services\Email\EmailTemplateRenderer::class));
|
||||
|
||||
Mail::assertSent(GenericTemplateMail::class, function (GenericTemplateMail $mail) {
|
||||
return $mail->envelope()->from === null;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_preview_throws_for_undeclared_variable(): void
|
||||
{
|
||||
$template = $this->makeTemplate([
|
||||
'message' => '<p>{{full_name}} {{not_declared}}</p>',
|
||||
]);
|
||||
|
||||
$this->expectException(InvalidTemplateVariableException::class);
|
||||
|
||||
app(EmailTemplateService::class)->preview($template->scenario, ['full_name' => 'A']);
|
||||
}
|
||||
|
||||
public function test_load_template_is_cached_by_scenario(): void
|
||||
{
|
||||
$template = $this->makeTemplate();
|
||||
$service = app(EmailTemplateService::class);
|
||||
|
||||
$first = $service->loadTemplate($template->scenario);
|
||||
$this->assertSame('Xin chào {{full_name}}', $first->subject);
|
||||
|
||||
$template->update(['subject' => 'Đã đổi']); // model event must bust the cache
|
||||
$second = $service->loadTemplate($template->scenario);
|
||||
|
||||
$this->assertSame('Đã đổi', $second->subject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Payment;
|
||||
|
||||
use App\Enums\TransactionStatus;
|
||||
use App\Http\Controllers\Api\Admin\Transaction\AdminTransactionController;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminTransactionApproveTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_approving_the_same_manual_deposit_twice_credits_the_customer_only_once(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$customer = Customer::factory()->create(['total' => 0, 'credit' => 0, 'total_pending' => 100_000]);
|
||||
$transaction = Transaction::create([
|
||||
'uuid' => (string) \Illuminate\Support\Str::uuid(),
|
||||
'public_code' => 'DEP-TEST-0001',
|
||||
'trans' => 'DEP-TEST-0001',
|
||||
'amount' => 100_000,
|
||||
'customer_id' => $customer->id,
|
||||
'type' => 'manual',
|
||||
'status' => TransactionStatus::PENDING->value,
|
||||
]);
|
||||
|
||||
$controller = app(AdminTransactionController::class);
|
||||
|
||||
$first = $controller->approve($transaction->uuid);
|
||||
$second = $controller->approve($transaction->uuid);
|
||||
|
||||
$this->assertSame(200, $first->getStatusCode());
|
||||
// Second call finds the transaction no longer PENDING → treated as not-approvable.
|
||||
$this->assertSame(404, $second->getStatusCode());
|
||||
|
||||
$this->assertSame(100_000, (int) $customer->fresh()->total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Payment;
|
||||
|
||||
use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Enums\Order\OrderStatus;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Order;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\InvoicePaymentService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InvoicePaymentServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_paying_an_already_paid_invoice_does_not_double_deduct_balance(): void
|
||||
{
|
||||
$customer = Customer::factory()->create(['total' => 200_000]);
|
||||
$order = Order::create([
|
||||
'order_num' => 'ORD-' . uniqid(),
|
||||
'customer_id' => $customer->id,
|
||||
'sub_total' => 100_000,
|
||||
'total' => 100_000,
|
||||
'status' => OrderStatus::PENDING->value,
|
||||
]);
|
||||
$invoice = Invoice::create([
|
||||
'invoice_number' => 'INV-' . uniqid(),
|
||||
'customer_id' => $customer->id,
|
||||
'order_id' => $order->id,
|
||||
'invoice_type' => 1,
|
||||
'sub_total' => 100_000,
|
||||
'tax' => 0,
|
||||
'total' => 100_000,
|
||||
'status' => InvoiceStatus::UNPAID->value,
|
||||
]);
|
||||
|
||||
$service = app(InvoicePaymentService::class);
|
||||
|
||||
$service->payWithBalance($invoice, $customer, 'balance');
|
||||
// Simulate a retried request (double-click / network retry) for the same invoice.
|
||||
$service->payWithBalance($invoice->fresh(), $customer->fresh(), 'balance');
|
||||
|
||||
$this->assertSame(100_000, (int) $customer->fresh()->total);
|
||||
$this->assertSame(1, Transaction::where('customer_id', $customer->id)->where('type', 'invoice')->count());
|
||||
}
|
||||
|
||||
public function test_a_cancelled_invoice_cannot_be_paid_and_is_never_counted_as_paid(): void
|
||||
{
|
||||
$customer = Customer::factory()->create(['total' => 200_000]);
|
||||
$order = Order::create([
|
||||
'order_num' => 'ORD-' . uniqid(),
|
||||
'customer_id' => $customer->id,
|
||||
'sub_total' => 100_000,
|
||||
'total' => 100_000,
|
||||
'status' => OrderStatus::CANCELLED->value,
|
||||
]);
|
||||
$invoice = Invoice::create([
|
||||
'invoice_number' => 'INV-' . uniqid(),
|
||||
'customer_id' => $customer->id,
|
||||
'order_id' => $order->id,
|
||||
'invoice_type' => 1,
|
||||
'sub_total' => 100_000,
|
||||
'tax' => 0,
|
||||
'total' => 100_000,
|
||||
'status' => InvoiceStatus::CANCELLED->value,
|
||||
]);
|
||||
|
||||
$service = app(InvoicePaymentService::class);
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
try {
|
||||
$service->payWithBalance($invoice, $customer, 'balance');
|
||||
} finally {
|
||||
$this->assertSame(200_000, (int) $customer->fresh()->total);
|
||||
$this->assertSame(InvoiceStatus::CANCELLED->value, (int) $invoice->fresh()->status);
|
||||
$this->assertNull($invoice->fresh()->paid_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Exceptions\InvalidTemplateVariableException;
|
||||
use App\Models\Template;
|
||||
use App\Services\Email\EmailTemplateRenderer;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmailTemplateRendererTest extends TestCase
|
||||
{
|
||||
private function makeTemplate(string $subject, string $message, array $variables): Template
|
||||
{
|
||||
$template = new Template([
|
||||
'scenario' => 'unit_test_scenario',
|
||||
'subject' => $subject,
|
||||
'message' => $message,
|
||||
'metadata' => ['variables' => $variables],
|
||||
]);
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
public function test_it_replaces_known_placeholders_in_subject_and_html(): void
|
||||
{
|
||||
$template = $this->makeTemplate(
|
||||
'Xin chào {{full_name}}',
|
||||
'<p>Chào mừng {{full_name}} đến với {{app_name}}</p>',
|
||||
[
|
||||
['key' => 'full_name', 'label' => 'Họ tên', 'type' => 'string'],
|
||||
['key' => 'app_name', 'label' => 'Tên app', 'type' => 'string'],
|
||||
],
|
||||
);
|
||||
|
||||
$result = (new EmailTemplateRenderer())->render($template, [
|
||||
'full_name' => 'Nguyễn Văn A',
|
||||
'app_name' => 'Thế Giới Số',
|
||||
]);
|
||||
|
||||
$this->assertSame('Xin chào Nguyễn Văn A', $result['subject']);
|
||||
$this->assertSame('<p>Chào mừng Nguyễn Văn A đến với Thế Giới Số</p>', $result['html']);
|
||||
}
|
||||
|
||||
public function test_it_falls_back_to_example_value_when_data_missing(): void
|
||||
{
|
||||
$template = $this->makeTemplate(
|
||||
'Mã: {{code}}',
|
||||
'<p>{{code}}</p>',
|
||||
[['key' => 'code', 'label' => 'Mã', 'type' => 'string', 'example' => '000000']],
|
||||
);
|
||||
|
||||
$result = (new EmailTemplateRenderer())->render($template, []);
|
||||
|
||||
$this->assertSame('Mã: 000000', $result['subject']);
|
||||
$this->assertSame('<p>000000</p>', $result['html']);
|
||||
}
|
||||
|
||||
public function test_it_html_escapes_string_type_by_default(): void
|
||||
{
|
||||
$template = $this->makeTemplate(
|
||||
'subj',
|
||||
'<p>{{name}}</p>',
|
||||
[['key' => 'name', 'label' => 'Tên', 'type' => 'string']],
|
||||
);
|
||||
|
||||
$result = (new EmailTemplateRenderer())->render($template, ['name' => '<script>alert(1)</script>']);
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $result['html']);
|
||||
$this->assertStringContainsString('<script>', $result['html']);
|
||||
}
|
||||
|
||||
public function test_it_rejects_invalid_url_type(): void
|
||||
{
|
||||
$template = $this->makeTemplate(
|
||||
'subj',
|
||||
'<a href="{{link}}">go</a>',
|
||||
[['key' => 'link', 'label' => 'Link', 'type' => 'url']],
|
||||
);
|
||||
|
||||
$result = (new EmailTemplateRenderer())->render($template, ['link' => 'javascript:alert(1)']);
|
||||
|
||||
$this->assertStringNotContainsString('javascript:', $result['html']);
|
||||
}
|
||||
|
||||
public function test_it_throws_for_undeclared_variable(): void
|
||||
{
|
||||
$template = $this->makeTemplate(
|
||||
'subj',
|
||||
'<p>{{full_name}} {{secret_admin_flag}}</p>',
|
||||
[['key' => 'full_name', 'label' => 'Họ tên', 'type' => 'string']],
|
||||
);
|
||||
|
||||
$this->expectException(InvalidTemplateVariableException::class);
|
||||
$this->expectExceptionMessage('secret_admin_flag');
|
||||
|
||||
(new EmailTemplateRenderer())->render($template, ['full_name' => 'A']);
|
||||
}
|
||||
|
||||
public function test_it_never_executes_placeholder_content_as_code(): void
|
||||
{
|
||||
$template = $this->makeTemplate(
|
||||
'subj',
|
||||
'<p>{{note}}</p>',
|
||||
[['key' => 'note', 'label' => 'Note', 'type' => 'string']],
|
||||
);
|
||||
|
||||
$result = (new EmailTemplateRenderer())->render($template, [
|
||||
'note' => '{{ 7*7 }} @php(system("id")) <?php echo 1; ?>',
|
||||
]);
|
||||
|
||||
$this->assertStringNotContainsString('49', $result['html']);
|
||||
$this->assertStringContainsString('<?php', $result['html']);
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Enums\Order\OrderStatus;
|
||||
use App\Jobs\VirtualMachine\ProcessOrderPaymentJob;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProcessOrderPaymentJobTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** @test */
|
||||
public function it_can_process_order_payment_with_vm_creation()
|
||||
{
|
||||
// Arrange
|
||||
$customer = Customer::factory()->create(['credit' => 1000000]);
|
||||
$order = Order::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 500000,
|
||||
'status' => OrderStatus::PENDING->value
|
||||
]);
|
||||
$invoice = Invoice::factory()->create([
|
||||
'order_id' => $order->id,
|
||||
'amount' => 500000
|
||||
]);
|
||||
$product = Product::factory()->create(['product_type' => 4]); // VPS product
|
||||
$orderItem = OrderItem::factory()->create([
|
||||
'order_id' => $order->id,
|
||||
'product_id' => $product->id,
|
||||
'product_type' => 4
|
||||
]);
|
||||
|
||||
// Act
|
||||
$job = new ProcessOrderPaymentJob($order);
|
||||
$job->handle(
|
||||
app(\App\Http\Services\Invoice\InvoiceService::class),
|
||||
app(\App\Http\Services\Discord::class)
|
||||
);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals(500000, $customer->fresh()->credit); // Credit should be deducted
|
||||
$this->assertEquals(OrderStatus::ACTIVE->value, $order->fresh()->status);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_insufficient_credit()
|
||||
{
|
||||
// Arrange
|
||||
$customer = Customer::factory()->create(['credit' => 100000]); // Insufficient credit
|
||||
$order = Order::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 500000,
|
||||
'status' => OrderStatus::PENDING->value
|
||||
]);
|
||||
$invoice = Invoice::factory()->create([
|
||||
'order_id' => $order->id,
|
||||
'amount' => 500000
|
||||
]);
|
||||
|
||||
// Act
|
||||
$job = new ProcessOrderPaymentJob($order);
|
||||
$job->handle(
|
||||
app(\App\Http\Services\Invoice\InvoiceService::class),
|
||||
app(\App\Http\Services\Discord::class)
|
||||
);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals(100000, $customer->fresh()->credit); // Credit should not be deducted
|
||||
$this->assertEquals(OrderStatus::PENDING->value, $order->fresh()->status);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_missing_invoice()
|
||||
{
|
||||
// Arrange
|
||||
$customer = Customer::factory()->create(['credit' => 1000000]);
|
||||
$order = Order::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 500000,
|
||||
'status' => OrderStatus::PENDING->value
|
||||
]);
|
||||
// No invoice created
|
||||
|
||||
// Act
|
||||
$job = new ProcessOrderPaymentJob($order);
|
||||
$job->handle(
|
||||
app(\App\Http\Services\Invoice\InvoiceService::class),
|
||||
app(\App\Http\Services\Discord::class)
|
||||
);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals(1000000, $customer->fresh()->credit); // Credit should not be deducted
|
||||
$this->assertEquals(OrderStatus::PENDING->value, $order->fresh()->status);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_non_vps_products()
|
||||
{
|
||||
// Arrange
|
||||
$customer = Customer::factory()->create(['credit' => 1000000]);
|
||||
$order = Order::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 500000,
|
||||
'status' => OrderStatus::PENDING->value
|
||||
]);
|
||||
$invoice = Invoice::factory()->create([
|
||||
'order_id' => $order->id,
|
||||
'amount' => 500000
|
||||
]);
|
||||
$product = Product::factory()->create(['product_type' => 1]); // Non-VPS product
|
||||
$orderItem = OrderItem::factory()->create([
|
||||
'order_id' => $order->id,
|
||||
'product_id' => $product->id,
|
||||
'product_type' => 1
|
||||
]);
|
||||
|
||||
// Act
|
||||
$job = new ProcessOrderPaymentJob($order);
|
||||
$job->handle(
|
||||
app(\App\Http\Services\Invoice\InvoiceService::class),
|
||||
app(\App\Http\Services\Discord::class)
|
||||
);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals(1000000, $customer->fresh()->credit); // Credit should not be deducted for non-VPS
|
||||
$this->assertEquals(OrderStatus::PENDING->value, $order->fresh()->status);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user