feat(portal): lead module, WHMCS sync report/contacts/mapping, service lifecycle email templates

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-21 16:55:19 +07:00
co-authored by Claude Sonnet 5.5
parent 3c7710f1de
commit a443ec3bf6
66 changed files with 2032 additions and 124 deletions
@@ -65,7 +65,7 @@ class IpAllocationTest extends TestCase
$response = $this->actingAs($this->admin, 'admin')
->postJson(self::BASE . '/allocation/preview', $this->allocation([$pool->id]))
->assertOk()
->assertJsonPath('data.subject', 'Thông báo cấp phát 1 địa chỉ IP');
->assertJsonPath('data.subject', 'V/v: Thế Giới Số cấp phát 1 địa chỉ IP | Công ty A');
$this->assertStringContainsString('10.0.0.5', $response->json('data.html'));
$this->assertStringContainsString('Colo &lt;b&gt;1U&lt;/b&gt;', $response->json('data.html'));
@@ -122,7 +122,7 @@ class IpAllocationTest extends TestCase
->postJson(self::BASE . '/allocate', $payload)
->assertOk();
Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => $mail->envelope()->subject === 'Thông báo cấp phát 2 địa chỉ IP');
Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => $mail->envelope()->subject === 'V/v: Thế Giới Số cấp phát 2 địa chỉ IP | Công ty A');
$this->assertSame('Dịch vụ IP', $first->fresh()->service_name);
$this->assertSame('CLOUD VPS SILVER', $second->fresh()->service_name);
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Modules\Lead\App\Models\Lead;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class LeadApiTest extends TestCase
{
use RefreshDatabase;
private const BASE = '/api/v1/administrator/leads';
private function admin(array $permissions): User
{
$role = Role::create(['name' => 'role-' . uniqid(), 'guard_name' => 'admin']);
foreach ($permissions as $name) {
$role->givePermissionTo(Permission::firstOrCreate(['name' => $name, 'guard_name' => 'admin']));
}
$admin = User::factory()->create();
$admin->assignRole($role);
return $admin;
}
public function test_crud_flow_and_email_is_normalized_and_unique(): void
{
$admin = $this->admin(['lead.view', 'lead.create', 'lead.edit', 'lead.destroy']);
$uuid = $this->actingAs($admin, 'admin')
->postJson(self::BASE, ['fullname' => 'Khách A', 'email' => ' Khach.A@Example.com ', 'status' => 'new'])
->assertCreated()
->assertJsonPath('data.email', 'khach.a@example.com')
->json('data.uuid');
$this->actingAs($admin, 'admin')
->postJson(self::BASE, ['email' => 'khach.a@example.com'])
->assertStatus(422);
$this->actingAs($admin, 'admin')
->putJson(self::BASE . "/{$uuid}", ['email' => 'khach.a@example.com', 'status' => 'contacted', 'phone' => '0901234567'])
->assertOk()
->assertJsonPath('data.status', 'contacted');
$this->actingAs($admin, 'admin')->getJson(self::BASE . '?filter=0901')->assertOk()->assertJsonPath('data.total', 1);
$this->actingAs($admin, 'admin')->getJson(self::BASE . "/{$uuid}")->assertOk()->assertJsonPath('data.phone', '0901234567');
$this->actingAs($admin, 'admin')->deleteJson(self::BASE . "/{$uuid}")->assertOk();
$this->assertSame(0, Lead::count());
}
public function test_permissions_are_enforced(): void
{
$viewer = $this->admin(['lead.view']);
$this->actingAs($viewer, 'admin')->getJson(self::BASE)->assertOk();
$this->actingAs($viewer, 'admin')->postJson(self::BASE, ['email' => 'x@example.com'])->assertForbidden();
$this->actingAs($viewer, 'admin')->deleteJson(self::BASE . '/' . uniqid())->assertForbidden();
}
public function test_bulk_status_and_bulk_delete_only_touch_given_uuids(): void
{
$admin = $this->admin(['lead.view', 'lead.edit', 'lead.destroy']);
$leads = collect(['a', 'b', 'c'])->map(fn ($name) => Lead::create(['email' => "{$name}@example.com"]));
$this->actingAs($admin, 'admin')
->postJson(self::BASE . '/bulk-status', ['uuids' => [$leads[0]->uuid, $leads[1]->uuid], 'status' => 'contacted'])
->assertOk()
->assertJsonPath('data.affected', 2);
$this->assertSame(2, Lead::where('status', 'contacted')->count());
$this->actingAs($admin, 'admin')
->postJson(self::BASE . '/bulk-status', ['uuids' => [$leads[2]->uuid], 'status' => 'bogus'])
->assertStatus(422);
$this->actingAs($admin, 'admin')
->postJson(self::BASE . '/bulk-delete', ['uuids' => [$leads[0]->uuid, $leads[2]->uuid]])
->assertOk()
->assertJsonPath('data.affected', 2);
$this->assertSame([$leads[1]->uuid], Lead::pluck('uuid')->all());
$this->actingAs($this->admin(['lead.view']), 'admin')
->postJson(self::BASE . '/bulk-delete', ['uuids' => [$leads[1]->uuid]])
->assertForbidden();
}
}
@@ -86,4 +86,22 @@ class MarketingEmailOptOutTest extends TestCase
Queue::assertNotPushed(SendMailCampaignRecipient::class);
}
public function test_campaign_with_template_uses_edited_message_when_given(): void
{
Queue::fake();
$admin = $this->admin();
$customer = Customer::factory()->create(['email_marketing' => 1]);
$this->actingAs($admin, 'admin')
->postJson('/api/v1/administrator/mail-campaigns', [
'template_id' => $this->template(null)->id,
'message' => '<p>Nội dung đã sửa</p>',
'recipient_type' => 'selected',
'uuids' => [$customer->uuid],
])
->assertOk();
$this->assertSame('<p>Nội dung đã sửa</p>', \App\Models\MailCampaign::latest('id')->first()->message);
}
}
@@ -63,8 +63,8 @@ class HostingAccountCreatedInvoiceTest extends TestCase
$this->seed(\Database\Seeders\TemplateSeeder::class);
$subject = fn (array $data) => (new HostingAccountCreated($data + ['server_name' => 'HOST-01']))->envelope()->subject;
$this->assertSame('V/v: '.\App\Support\MailGlobals::values()['company_name'].' gửi thông tin truy cập - HOST-01 - Nguyen Van A', $subject(['customer_name' => 'Nguyen Van A']));
$this->assertStringEndsWith(' - Cong ty ABC', $subject(['customer_name' => ' ', 'customer_company' => 'Cong ty ABC']));
$this->assertStringEndsWith(' - Công ty', $subject([]));
$this->assertSame('V/v: Thế Giới Số gửi thông tin truy cập Hosting - HOST-01 | Nguyen Van A', $subject(['customer_name' => 'Nguyen Van A']));
$this->assertStringEndsWith(' | Cong ty ABC', $subject(['customer_name' => ' ', 'customer_company' => 'Cong ty ABC']));
$this->assertStringEndsWith(' | Công ty', $subject([]));
}
}
@@ -0,0 +1,23 @@
<?php
namespace Tests\Feature;
use App\Support\MailPlaceholders;
use Tests\TestCase;
class MailPlaceholdersConditionalTest extends TestCase
{
public function test_block_is_kept_only_when_variable_has_value(): void
{
$content = 'A{{#if kyc_url}}<a href="{{kyc_url}}">KYC</a>{{else}}none{{/if}}B';
$this->assertSame('AnoneB', MailPlaceholders::fill($content, ['kyc_url' => ' ']));
$this->assertSame('AnoneB', MailPlaceholders::fill($content, []));
$this->assertSame('A<a href="https://x.vn/k?a=1&amp;b=2">KYC</a>B', MailPlaceholders::fill($content, ['kyc_url' => 'https://x.vn/k?a=1&b=2']));
}
public function test_extract_ignores_conditional_keywords(): void
{
$this->assertSame(['kyc_url'], MailPlaceholders::extract('{{#if kyc_url}}{{kyc_url}}{{else}}x{{/if}}'));
}
}
+17
View File
@@ -8,6 +8,7 @@ use App\Services\Email\EmailTemplateService;
use App\Support\EmailScenarioRegistry;
use App\Support\MailPlaceholders;
use Database\Seeders\EmailTemplates\TemplateVariables;
use Database\Seeders\EmailTemplates\ServiceLifecycleTemplates;
use Database\Seeders\TemplateSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
@@ -39,6 +40,22 @@ class TemplateSeederTest extends TestCase
}
}
public function test_service_lifecycle_templates_are_seeded_once_as_marketing(): void
{
$items = ServiceLifecycleTemplates::all();
$this->assertCount(27, $items);
$this->seed(TemplateSeeder::class);
$this->seed(TemplateSeeder::class);
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->assertStringContainsString('class="Footer"', $template->message, $item['name']);
}
}
public function test_every_placeholder_has_a_variable_definition(): void
{
foreach ([...TemplateSeeder::templates(), ...TemplateSeeder::legacyTemplates()] as $item) {
+19
View File
@@ -61,4 +61,23 @@ class AdminProductResourceTest extends TestCase
$this->assertSame('onetime', $data['billing_type']);
$this->assertSame('15000000.00', $data['price_onetime']);
}
public function test_custom_prices_serialize_as_json_object_even_when_empty(): void
{
$product = new Product(['name' => 'No custom cycles', 'is_active' => true, 'setup_fee' => 0]);
$product->id = 6;
$product->setRelation('pricings', new Collection([
new ProductPricings(['billing_cycle' => 'annually', 'price' => 1000000, 'currency' => 'VND']),
new ProductPricings(['billing_cycle' => 'months_60', 'price' => 5000000, 'currency' => 'VND']),
]));
$withCustom = json_decode((new ProductResource($product))->toResponse(request())->getContent());
$this->assertIsObject($withCustom->data->custom_prices ?? $withCustom->custom_prices);
$product->setRelation('pricings', new Collection([
new ProductPricings(['billing_cycle' => 'annually', 'price' => 1000000, 'currency' => 'VND']),
]));
$empty = json_decode((new ProductResource($product))->toResponse(request())->getContent());
$this->assertIsObject($empty->data->custom_prices ?? $empty->custom_prices);
}
}