feat: email gửi tay/nhắc gia hạn, gói hosting, sửa dữ liệu sản phẩm
- Gửi email theo template cho 1 khách, tab lịch sử email, nhắc gia hạn dịch vụ hàng loạt - Mail lưu from/bcc/sent_by; mẫu MARKETING và mẫu cũ trong TemplateSeeder - Gói hosting: giới hạn, trạng thái, lấy plan Plesk qua CLI gateway - Backfill payment_method và ngày đơn hàng còn thiếu - Bổ sung field CPU/RAM/Disk/Băng thông cho loại sản phẩm vps và cloud Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8651dde6b1
commit
30898ea46f
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin\Customer;
|
||||
|
||||
use App\Mail\Customer\ManualCustomerEmail;
|
||||
use App\Mail\GenericTemplateMail;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Template;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SendTemplateEmailTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
private Customer $customer;
|
||||
private Template $template;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->assignRole('Full Administrator');
|
||||
|
||||
$this->customer = Customer::factory()->create(['fullname' => 'Nguyễn Văn A']);
|
||||
|
||||
$this->template = Template::create([
|
||||
'type_id' => 0,
|
||||
'format' => 'html',
|
||||
'name' => 'Khuyến mãi',
|
||||
'subject' => 'Chào {{client_name}}',
|
||||
'message' => '<p>Xin chào {{$client_name}}, mã {{promo_code}}</p>',
|
||||
'metadata' => [
|
||||
'variables' => [
|
||||
['key' => 'client_name', 'label' => 'Tên', 'type' => 'string', 'example' => 'Quý khách'],
|
||||
['key' => 'promo_code', 'label' => 'Mã', 'type' => 'string', 'example' => 'TGS10'],
|
||||
],
|
||||
'mail' => ['cc' => ['sale@example.com']],
|
||||
],
|
||||
'status' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_preview_renders_template_for_customer(): void
|
||||
{
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/email-preview", [
|
||||
'template_id' => $this->template->id,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.subject', 'Chào Nguyễn Văn A')
|
||||
->assertJsonPath('data.html', '<p>Xin chào Nguyễn Văn A, mã TGS10</p>')
|
||||
->assertJsonPath('data.cc', ['sale@example.com']);
|
||||
}
|
||||
|
||||
public function test_send_uses_edited_html_without_template_cc(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/send-email", [
|
||||
'template_id' => $this->template->id,
|
||||
'subject' => 'Tiêu đề đã sửa',
|
||||
'message' => '<p>Nội dung đã sửa</p>',
|
||||
'to' => $this->customer->email,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
Mail::assertSent(GenericTemplateMail::class, fn (GenericTemplateMail $mail) => $mail->hasTo($this->customer->email)
|
||||
&& $mail->renderedSubject === 'Tiêu đề đã sửa'
|
||||
&& $mail->renderedHtml === '<p>Nội dung đã sửa</p>'
|
||||
&& !array_key_exists('cc', $mail->mailConfig));
|
||||
|
||||
$this->assertDatabaseHas('mail', ['customer_id' => $this->customer->id, 'message' => '<p>Nội dung đã sửa</p>']);
|
||||
}
|
||||
|
||||
public function test_email_layout_wraps_content_with_header_and_footer(): void
|
||||
{
|
||||
$html = $this->actingAs($this->admin, 'admin')
|
||||
->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/email-layout", [
|
||||
'message' => '<p>Xin chào {{client_name}}</p>',
|
||||
])
|
||||
->assertOk()
|
||||
->json('data.html');
|
||||
|
||||
$this->assertStringContainsString('<p>Xin chào Nguyễn Văn A</p>', $html);
|
||||
$this->assertStringContainsString('class="Header"', $html);
|
||||
$this->assertStringContainsString('class="Footer"', $html);
|
||||
}
|
||||
|
||||
public function test_manual_email_with_inserted_layout_is_not_wrapped_again(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/send-email", [
|
||||
'subject' => 'Thông báo',
|
||||
'message' => '<table class="Header"></table><p>Nội dung</p>',
|
||||
'to' => $this->customer->email,
|
||||
'with_layout' => false,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
Mail::assertQueued(ManualCustomerEmail::class, function (ManualCustomerEmail $mail) {
|
||||
$html = $mail->render();
|
||||
|
||||
return substr_count($html, 'class="Header"') === 1 && str_contains($html, '<p>Nội dung</p>');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_sent_emails_are_listed_with_sender_and_bcc(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$base = "/api/v1/administrator/customers/{$this->customer->uuid}";
|
||||
|
||||
$this->actingAs($this->admin, 'admin')->postJson("{$base}/send-email", [
|
||||
'subject' => 'Thông tin VPS',
|
||||
'message' => '<p>Thông tin</p>',
|
||||
'to' => $this->customer->email,
|
||||
'bcc' => 'audit@example.com',
|
||||
])->assertOk();
|
||||
|
||||
$item = $this->getJson("{$base}/emails")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.total', 1)
|
||||
->assertJsonPath('data.items.0.bcc', 'audit@example.com')
|
||||
->assertJsonPath('data.items.0.to', $this->customer->email)
|
||||
->assertJsonPath('data.items.0.sent_by', $this->admin->name ?: $this->admin->username)
|
||||
->assertJsonMissingPath('data.items.0.html')
|
||||
->json('data.items.0');
|
||||
|
||||
$this->getJson("{$base}/emails/{$item['id']}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.html', '<p>Thông tin</p>');
|
||||
|
||||
$other = Customer::factory()->create();
|
||||
$this->getJson("/api/v1/administrator/customers/{$other->uuid}/emails/{$item['id']}")->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_send_reports_error_when_mail_is_disabled(): void
|
||||
{
|
||||
\App\Models\Setting::updateOrCreate(['key' => 'email_settings'], ['value' => json_encode(['mail_enabled' => false])]);
|
||||
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/send-email", [
|
||||
'subject' => 'Thông báo',
|
||||
'message' => '<p>Nội dung</p>',
|
||||
])
|
||||
->assertStatus(422);
|
||||
|
||||
$this->assertDatabaseMissing('mail', ['customer_id' => $this->customer->id]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin\Customer;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Service;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UpdateServiceBillingCycleTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_updates_order_item_billing_cycle_and_months(): void
|
||||
{
|
||||
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Full Administrator');
|
||||
|
||||
Schema::disableForeignKeyConstraints();
|
||||
$customer = Customer::factory()->create();
|
||||
$item = OrderItem::create(['order_id' => 1, 'product_id' => 1, 'billing_cycle' => 'monthly', 'months' => 1, 'quantity' => 1, 'price' => 100000]);
|
||||
$service = Service::create(['customer_id' => $customer->id, 'order_item_id' => $item->id, 'status' => 'active']);
|
||||
|
||||
$url = "/api/v1/administrator/customers/{$customer->uuid}/services/{$service->id}";
|
||||
|
||||
$this->actingAs($admin, 'admin')->putJson($url, ['billing_cycle' => 'annually'])->assertOk();
|
||||
$item->refresh();
|
||||
$this->assertSame('annually', $item->billing_cycle);
|
||||
$this->assertSame(12, (int) $item->months);
|
||||
|
||||
$this->actingAs($admin, 'admin')->putJson($url, ['billing_cycle' => 'onetime'])->assertStatus(422);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin\Hosting;
|
||||
|
||||
use App\Models\DnsServer;
|
||||
use App\Models\ServicePlan;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ServicePlanTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->assignRole('Full Administrator');
|
||||
}
|
||||
|
||||
private function server(string $type = 'hosting'): DnsServer
|
||||
{
|
||||
return DnsServer::create([
|
||||
'name' => 'plesk-01',
|
||||
'ip_address' => '10.0.0.5',
|
||||
'username' => 'admin',
|
||||
'password' => Crypt::encryptString('secret'),
|
||||
'status' => 'activated',
|
||||
'type' => $type,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_and_update_persist_limits_features_and_plesk_mapping(): void
|
||||
{
|
||||
$payload = [
|
||||
'title' => 'Hosting Basic',
|
||||
'description' => 'Cho website nhỏ',
|
||||
'vcpu' => 1,
|
||||
'ram' => 1,
|
||||
'disk' => 10,
|
||||
'domain' => 1,
|
||||
'bandwidth' => null,
|
||||
'databases' => 2,
|
||||
'mailboxes' => 5,
|
||||
'mailbox_quota' => 1024,
|
||||
'features' => ['SSL miễn phí', 'Backup hàng ngày'],
|
||||
'is_active' => false,
|
||||
'sort_order' => 3,
|
||||
'plesk_plan_name' => 'basic',
|
||||
];
|
||||
|
||||
$id = $this->actingAs($this->admin, 'admin')
|
||||
->postJson('/api/v1/administrator/service-plans', $payload)
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.features', ['SSL miễn phí', 'Backup hàng ngày'])
|
||||
->assertJsonPath('data.is_active', false)
|
||||
->assertJsonPath('data.plesk.plesk_plan_name', 'basic')
|
||||
->json('data.id');
|
||||
|
||||
$plan = ServicePlan::findOrFail($id);
|
||||
$this->assertNull($plan->bandwidth);
|
||||
$this->assertSame(5, $plan->mailboxes);
|
||||
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->putJson("/api/v1/administrator/service-plans/{$id}", [...$payload, 'is_active' => true, 'plesk_plan_name' => null])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.is_active', true)
|
||||
->assertJsonPath('data.plesk', null);
|
||||
}
|
||||
|
||||
public function test_rejects_negative_limits_and_duplicate_features(): void
|
||||
{
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->postJson('/api/v1/administrator/service-plans', [
|
||||
'title' => 'X', 'vcpu' => 1, 'ram' => 1, 'disk' => 1, 'domain' => 1,
|
||||
'databases' => -1,
|
||||
'features' => ['SSH', 'SSH'],
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['databases', 'features.0']);
|
||||
}
|
||||
|
||||
public function test_lists_plesk_plan_names_from_cli_gateway(): void
|
||||
{
|
||||
$server = $this->server();
|
||||
Http::fake([
|
||||
'https://10.0.0.5:8443/api/v2/cli/service_plan/call' => Http::response([
|
||||
'code' => 0, 'stdout' => "Default Domain\r\nbasic\n\nbasic\n", 'stderr' => '',
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->getJson("/api/v1/administrator/service-plans/plesk-plans/{$server->id}")
|
||||
->assertOk()
|
||||
->assertExactJson(['error' => false, 'data' => ['Default Domain', 'basic']]);
|
||||
|
||||
Http::assertSent(fn ($request) => $request['params'] === ['--list']);
|
||||
}
|
||||
|
||||
public function test_plesk_plans_fails_for_cli_error_and_non_hosting_server(): void
|
||||
{
|
||||
$server = $this->server();
|
||||
Http::fake(['*' => Http::response(['code' => 1, 'stdout' => '', 'stderr' => 'denied'])]);
|
||||
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->getJson("/api/v1/administrator/service-plans/plesk-plans/{$server->id}")
|
||||
->assertStatus(502);
|
||||
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->getJson('/api/v1/administrator/service-plans/plesk-plans/' . $this->server('domain')->id)
|
||||
->assertNotFound();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin\Service;
|
||||
|
||||
use App\Mail\ServiceRenewalReminderMail;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Service;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RenewalReminderTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const BASE = '/api/v1/administrator/services/renewal-reminders';
|
||||
|
||||
private User $admin;
|
||||
private Service $soon;
|
||||
private Service $later;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(\Database\Seeders\TemplateSeeder::class);
|
||||
|
||||
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->assignRole('Full Administrator');
|
||||
|
||||
$customer = Customer::factory()->create();
|
||||
$this->soon = Service::create(['customer_id' => $customer->id, 'status' => 'active', 'expires_at' => now()->addDays(5)]);
|
||||
$this->later = Service::create(['customer_id' => $customer->id, 'status' => 'active', 'expires_at' => now()->addDays(40)]);
|
||||
Service::create(['customer_id' => $customer->id, 'status' => 'suspended', 'expires_at' => now()->addDays(3)]);
|
||||
}
|
||||
|
||||
public function test_lists_active_services_expiring_within_days(): void
|
||||
{
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->getJson(self::BASE . '?days=30')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $this->soon->id)
|
||||
->assertJsonPath('data.0.days_left', 5);
|
||||
}
|
||||
|
||||
public function test_send_queues_reminders_bypassing_auto_disabled_scenario(): void
|
||||
{
|
||||
Mail::fake();
|
||||
Setting::updateOrCreate(['key' => 'email_settings'], ['value' => json_encode(['auto_disabled' => ['service_renewal_reminder']])]);
|
||||
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->postJson(self::BASE, ['service_ids' => [$this->soon->id, $this->later->id]])
|
||||
->assertOk();
|
||||
|
||||
Mail::assertQueued(ServiceRenewalReminderMail::class, 2);
|
||||
$this->assertDatabaseCount('mail', 2);
|
||||
$this->assertDatabaseHas('mail', ['customer_id' => $this->soon->customer_id, 'sent_by' => $this->admin->id]);
|
||||
}
|
||||
|
||||
public function test_send_is_rejected_when_mail_is_disabled(): void
|
||||
{
|
||||
Mail::fake();
|
||||
Setting::updateOrCreate(['key' => 'email_settings'], ['value' => json_encode(['mail_enabled' => false])]);
|
||||
|
||||
$this->actingAs($this->admin, 'admin')
|
||||
->postJson(self::BASE, ['service_ids' => [$this->soon->id]])
|
||||
->assertStatus(422);
|
||||
|
||||
Mail::assertNothingQueued();
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,35 @@ class InvoicePaymentServiceTest extends TestCase
|
||||
$this->assertSame(400_000, (int) $customer->fresh()->total);
|
||||
}
|
||||
|
||||
public function test_admin_payment_records_source_and_note(): void
|
||||
{
|
||||
$customer = Customer::factory()->create(['total' => 0]);
|
||||
$invoice = $this->makeInvoice($customer, 100_000);
|
||||
|
||||
$invoice = app(InvoicePaymentService::class)->markPaidByAdmin(
|
||||
$invoice,
|
||||
null,
|
||||
method: 'manual',
|
||||
receivedAmount: 40_000,
|
||||
note: 'Khách chuyển khoản đợt 1',
|
||||
);
|
||||
|
||||
$this->assertSame(40_000, (int) $invoice->amount_paid);
|
||||
$this->assertSame('Khách chuyển khoản đợt 1', $invoice->note);
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'customer_id' => $customer->id,
|
||||
'received_amount' => 40_000,
|
||||
]);
|
||||
$this->assertStringContainsString(
|
||||
'Khách chuyển khoản đợt 1',
|
||||
(string) Transaction::where('customer_id', $customer->id)->value('note'),
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
'(manual)',
|
||||
(string) Transaction::where('customer_id', $customer->id)->value('note'),
|
||||
);
|
||||
}
|
||||
|
||||
/** Hoàn tiền: cộng lại số dư, ghi Transaction âm, hoá đơn sang REFUNDED. */
|
||||
public function test_refund_returns_balance_and_records_a_negative_transaction(): void
|
||||
{
|
||||
|
||||
@@ -30,9 +30,18 @@ class TemplateSeederTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function test_legacy_templates_use_the_shared_shell(): void
|
||||
{
|
||||
foreach (TemplateSeeder::legacyTemplates() as $item) {
|
||||
$this->assertNull(EmailScenarioRegistry::find($item['scenario']), $item['scenario']);
|
||||
$this->assertStringContainsString('class="Header"', $item['message'], $item['scenario']);
|
||||
$this->assertStringContainsString('class="Footer"', $item['message'], $item['scenario']);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_every_placeholder_has_a_variable_definition(): void
|
||||
{
|
||||
foreach (TemplateSeeder::templates() as $item) {
|
||||
foreach ([...TemplateSeeder::templates(), ...TemplateSeeder::legacyTemplates()] as $item) {
|
||||
$unknown = array_diff(MailPlaceholders::extract($item['subject'], $item['message']), array_keys(TemplateVariables::DEFINITIONS));
|
||||
$this->assertSame([], array_values($unknown), $item['scenario']);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user