feat(admin): IP pool allocation, billing exports, service attachments

- IP pool: sheet import (xlsx/csv) matched by address, bulk allocate/revoke
  with notification email (ip_allocated/ip_revoked), ip_pool_logs history.
- Orders/invoices: Excel export on branded template (BrandedReportWriter),
  shared billing filters, order item service info in list responses.
- Web design quote Excel export; activity log export.
- Service attachments with copy-to-invoice; deployment invoice attachments.
- Customer email attachments stored on disk and attached by path.
- Admin password change endpoint; user.fullname backfill migration;
  orders.user_id backfill from logs.
- Disable deploy workflow until CI/CD is set up.
- Ignore stored customer email and service attachment uploads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-15 19:58:36 +07:00
co-authored by Claude Opus 5.5
parent ea5697cb81
commit 46aa138eec
95 changed files with 4247 additions and 454 deletions
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\ActivityLog;
use App\Models\ActivityLog;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class ActivityLogManagementTest 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');
}
public function test_export_uses_branded_template_and_selected_filters(): void
{
$this->createLog(['log_name' => 'orders', 'description' => 'Tạo đơn hàng']);
$this->createLog(['log_name' => 'customers', 'description' => 'Cập nhật khách hàng']);
$response = $this->actingAs($this->admin, 'admin')
->get('/api/v1/administrator/activity-logs/export?log_name=orders')
->assertOk();
$sheet = IOFactory::load($response->baseResponse->getFile()->getPathname())->getActiveSheet();
$this->assertStringStartsWith('CÔNG TY', $sheet->getCell('C1')->getValue());
$this->assertCount(1, $sheet->getDrawingCollection());
$this->assertSame('LỊCH SỬ HOẠT ĐỘNG', $sheet->getCell('A6')->getValue());
$this->assertSame('ID', $sheet->getCell('A7')->getValue());
$this->assertSame('orders', $sheet->getCell('B8')->getValue());
$this->assertSame(8, $sheet->getHighestDataRow());
}
public function test_delete_by_date_range_keeps_logs_outside_range(): void
{
$selected = $this->createLog(['log_name' => 'orders']);
$remaining = $this->createLog(['log_name' => 'customers']);
$remaining->forceFill(['created_at' => now()->subDays(10)])->saveQuietly();
$this->actingAs($this->admin, 'admin')
->deleteJson('/api/v1/administrator/activity-logs', [
'mode' => 'date_range',
'date_from' => now()->toDateString(),
'date_to' => now()->toDateString(),
])
->assertOk();
$this->assertDatabaseMissing('activity_logs', ['id' => $selected->id]);
$this->assertDatabaseHas('activity_logs', ['id' => $remaining->id]);
}
public function test_delete_all_removes_every_log(): void
{
$this->createLog();
$this->createLog();
$total = ActivityLog::query()->count();
$this->actingAs($this->admin, 'admin')
->deleteJson('/api/v1/administrator/activity-logs', ['mode' => 'all'])
->assertOk()
->assertJsonPath('data.deleted', $total);
$this->assertDatabaseCount('activity_logs', 0);
}
public function test_delete_date_range_requires_valid_dates(): void
{
$this->actingAs($this->admin, 'admin')
->deleteJson('/api/v1/administrator/activity-logs', [
'mode' => 'date_range',
'date_from' => '2026-10-15',
'date_to' => '2026-10-14',
])
->assertUnprocessable();
}
private function createLog(array $attributes = []): ActivityLog
{
return ActivityLog::query()->create(array_merge([
'log_name' => 'system',
'event' => 'updated',
'description' => 'Thay đổi dữ liệu',
'causer_type' => 'admin',
'causer_id' => $this->admin->id,
'causer_name' => $this->admin->name,
'ip_address' => '127.0.0.1',
], $attributes));
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Billing;
use App\Models\Customer;
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class OrderInvoiceExportTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private Customer $customer;
protected function setUp(): void
{
parent::setUp();
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$this->admin = User::factory()->create();
$this->admin->assignRole('Full Administrator');
$customer = $this->customer = Customer::factory()->create();
$product = Product::factory()->create(['product_type' => 'domain']);
foreach (['first.vn', 'second.vn'] as $serverName) {
$this->actingAs($this->admin, 'admin')->postJson(
"/api/v1/administrator/customers/{$customer->uuid}/services",
['product_id' => $product->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'amount_paid' => 0],
)->assertCreated();
}
}
public static function exports(): array
{
return [
'orders' => ['/api/v1/administrator/orders/export', 'Mã đơn hàng', 'DANH SÁCH ĐƠN HÀNG'],
'invoices' => ['/api/v1/administrator/invoices/export', 'Mã hoá đơn', 'DANH SÁCH HOÁ ĐƠN'],
];
}
#[DataProvider('exports')]
public function test_exports_every_row_into_branded_template(string $url, string $firstHeading, string $title): void
{
$response = $this->actingAs($this->admin, 'admin')->get("{$url}?per_page=1&page=1")->assertOk();
$sheet = IOFactory::load($response->baseResponse->getFile()->getPathname())->getActiveSheet();
$this->assertStringStartsWith('CÔNG TY', $sheet->getCell('C1')->getValue());
$this->assertCount(1, $sheet->getDrawingCollection());
$this->assertSame($title, $sheet->getCell('A6')->getValue());
$this->assertSame($firstHeading, $sheet->getCell('A7')->getValue());
$this->assertSame('A8', $sheet->getFreezePane());
$this->assertSame(9, $sheet->getHighestDataRow());
$services = collect($sheet->toArray())->flatten()->implode(' ');
$this->assertStringContainsString('first.vn', $services);
$this->assertStringContainsString('second.vn', $services);
}
#[DataProvider('exports')]
public function test_export_applies_selected_filters(string $url): void
{
$this->assertSame(9, $this->exportedLastRow("{$url}?customer_id={$this->customer->id}"));
$this->assertSame(7, $this->exportedLastRow("{$url}?customer_id=" . ($this->customer->id + 1)));
$this->assertSame(7, $this->exportedLastRow("{$url}?status[]=0&status[]=3"));
$this->assertSame(8, $this->exportedLastRow("{$url}?filter=first"));
$this->assertSame(7, $this->exportedLastRow("{$url}?date_from=" . now()->addDay()->toDateString()));
}
public function test_invoice_list_filters_by_numeric_status(): void
{
$this->actingAs($this->admin, 'admin')->getJson('/api/v1/administrator/invoices?status=2')
->assertOk()->assertJsonPath('data.total', 2);
$this->actingAs($this->admin, 'admin')->getJson('/api/v1/administrator/invoices?status=1')
->assertOk()->assertJsonPath('data.total', 0);
}
public function test_export_rejects_unknown_filter_shapes(): void
{
$this->actingAs($this->admin, 'admin')
->getJson('/api/v1/administrator/invoices/export?status=abc')
->assertStatus(422);
}
private function exportedLastRow(string $url): int
{
$response = $this->actingAs($this->admin, 'admin')->get($url)->assertOk();
return IOFactory::load($response->baseResponse->getFile()->getPathname())->getActiveSheet()->getHighestDataRow();
}
}
@@ -10,7 +10,9 @@ use App\Models\Customer;
use App\Models\Template;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
@@ -158,4 +160,36 @@ class SendTemplateEmailTest extends TestCase
$this->assertDatabaseMissing('mail', ['customer_id' => $this->customer->id]);
}
public function test_send_attaches_uploaded_files(): void
{
Mail::fake();
Storage::fake('local');
$base = "/api/v1/administrator/customers/{$this->customer->uuid}/send-email";
$this->actingAs($this->admin, 'admin')
->post($base, [
'subject' => 'Hợp đồng',
'message' => '<p>Gửi hợp đồng</p>',
'to' => $this->customer->email,
'attachments' => [
UploadedFile::fake()->create('hop-dong.pdf', 100, 'application/pdf'),
UploadedFile::fake()->create('phu-luc.docx', 50),
],
], ['Accept' => 'application/json'])
->assertOk();
Mail::assertQueued(ManualCustomerEmail::class, function (ManualCustomerEmail $mail) {
$names = array_map(fn ($attachment) => $attachment->as, $mail->attachments());
return $names === ['hop-dong.pdf', 'phu-luc.docx'];
});
$this->assertCount(2, Storage::disk('local')->allFiles("customer-emails/{$this->customer->id}"));
$this->post($base, [
'subject' => 'Hợp đồng',
'message' => '<p>Gửi hợp đồng</p>',
'attachments' => [UploadedFile::fake()->create('script.php', 1)],
], ['Accept' => 'application/json'])->assertStatus(422);
}
}
@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\IpPool;
use App\Mail\IpAllocationMail;
use App\Models\Customer;
use App\Models\IpPool;
use App\Models\IpPoolLog;
use App\Models\Mail as MailLog;
use App\Models\ModuleIpAddress;
use App\Models\ModuleNetwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class IpAllocationTest extends TestCase
{
use RefreshDatabase;
private const BASE = '/api/v1/administrator/ip-pool';
private User $admin;
private Customer $customer;
private ModuleNetwork $network;
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(['username' => 'kythuat']);
$this->admin->assignRole('Full Administrator');
$this->customer = Customer::factory()->create(['fullname' => 'Công ty A', 'email' => 'a@example.com']);
$this->network = ModuleNetwork::create(['name' => 'VM Network 56']);
}
private function pool(string $ip, array $attributes = []): IpPool
{
return IpPool::create([
'ip_address' => $ip, 'module_network_id' => $this->network->id, 'cidr' => 22,
'gateway' => '10.0.0.1', 'status' => 'free', ...$attributes,
]);
}
private function allocation(array $ids): array
{
return [
'ids' => $ids, 'customer_uuid' => $this->customer->uuid, 'to' => 'a@example.com',
'cc' => 'kythuat@tgs.com.vn', 'date' => '2026-10-15', 'service_name' => 'Colo <b>1U</b>',
];
}
public function test_preview_lists_ips_without_changing_them(): void
{
$pool = $this->pool('10.0.0.5');
$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');
$this->assertStringContainsString('10.0.0.5', $response->json('data.html'));
$this->assertStringContainsString('Colo &lt;b&gt;1U&lt;/b&gt;', $response->json('data.html'));
$this->assertSame('free', $pool->fresh()->status);
}
public function test_allocate_assigns_ips_logs_and_emails_customer(): void
{
Mail::fake();
$first = $this->pool('10.0.0.5');
$second = $this->pool('10.0.0.6');
$this->actingAs($this->admin, 'admin')
->postJson(self::BASE . '/allocate', $this->allocation([$first->id, $second->id]))
->assertOk();
$first->refresh();
$this->assertSame('assigned', $first->status);
$this->assertSame($this->customer->id, $first->customer_id);
$this->assertSame('Công ty A', $first->customer_name);
$this->assertSame('2026-10-15', $first->allocated_at->toDateString());
$this->assertSame('ALLOCATED_SENT', $first->last_email_type);
$this->assertSame('kythuat', $first->updated_by);
$log = IpPoolLog::where('ip_pool_id', $first->id)->sole();
$this->assertSame([IpPoolLog::ALLOCATED, 'free', 'assigned', $this->admin->id], [$log->action, $log->status_from, $log->status_to, $log->user_id]);
$this->assertSame(['2026-10-15', 'Colo <b>1U</b>'], [$log->effective_date->toDateString(), $log->service_name]);
Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => $mail->type === IpAllocationMail::ALLOCATED
&& count($mail->ips) === 2
&& $mail->hasTo('a@example.com')
&& $mail->hasCc('kythuat@tgs.com.vn'));
$this->assertSame(1, MailLog::where('customer_id', $this->customer->id)->count());
}
public function test_each_ip_can_have_its_own_service(): void
{
Mail::fake();
$first = $this->pool('10.0.0.5');
$second = $this->pool('10.0.0.6');
$payload = [...$this->allocation([$first->id, $second->id]), 'service_name' => 'Dịch vụ IP', 'services' => [$second->id => 'CLOUD VPS SILVER']];
$html = $this->actingAs($this->admin, 'admin')
->postJson(self::BASE . '/allocation/preview', $payload)
->assertOk()
->json('data.html');
$this->assertStringContainsString('CLOUD VPS SILVER', $html);
$this->assertStringContainsString('Dịch vụ IP', $html);
$this->actingAs($this->admin, 'admin')
->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');
$this->assertSame('Dịch vụ IP', $first->fresh()->service_name);
$this->assertSame('CLOUD VPS SILVER', $second->fresh()->service_name);
}
public function test_admin_can_override_email_subject(): void
{
Mail::fake();
$pool = $this->pool('10.0.0.5');
$this->actingAs($this->admin, 'admin')
->postJson(self::BASE . '/allocate', [...$this->allocation([$pool->id]), 'subject' => 'Bàn giao IP cho Công ty A'])
->assertOk();
Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => $mail->envelope()->subject === 'Bàn giao IP cho Công ty A');
$this->assertSame('Bàn giao IP cho Công ty A', MailLog::where('customer_id', $this->customer->id)->value('subject'));
}
public function test_allocation_email_carries_uploaded_attachments(): void
{
Mail::fake();
Storage::fake('local');
$pool = $this->pool('10.0.0.5');
$this->actingAs($this->admin, 'admin')
->post(self::BASE . '/allocate', [
...$this->allocation([$pool->id]),
'attachments' => [UploadedFile::fake()->createWithContent('bien-ban.pdf', "%PDF-1.4\n\xff\xfe\x00\x9c binary")],
], ['Accept' => 'application/json'])
->assertOk();
Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => count($mail->storedFiles) === 1
&& $mail->storedFiles[0]['name'] === 'bien-ban.pdf'
&& Storage::disk('local')->exists($mail->storedFiles[0]['path'])
&& count($mail->attachments()) === 1
&& json_encode(serialize($mail)) !== false);
}
public function test_failed_email_dispatch_rolls_back_the_allocation(): void
{
$pool = $this->pool('10.0.0.5');
Mail::shouldReceive('to')->andThrow(new \RuntimeException('queue down'));
$this->actingAs($this->admin, 'admin')
->postJson(self::BASE . '/allocate', $this->allocation([$pool->id]))
->assertStatus(500);
$this->assertSame('free', $pool->fresh()->status);
$this->assertNull($pool->fresh()->customer_id);
$this->assertSame(0, IpPoolLog::count());
$this->assertSame(0, MailLog::count());
}
public function test_allocate_rejects_ips_that_are_not_free(): void
{
Mail::fake();
$free = $this->pool('10.0.0.5');
$taken = $this->pool('10.0.0.6', ['status' => 'assigned']);
$this->actingAs($this->admin, 'admin')
->postJson(self::BASE . '/allocate', $this->allocation([$free->id, $taken->id]))
->assertStatus(422)
->assertJsonFragment(['ids' => ['Chỉ cấp phát được IP đang trống. IP không hợp lệ: 10.0.0.6']]);
$this->assertSame('free', $free->fresh()->status);
Mail::assertNothingQueued();
}
public function test_revoke_groups_by_customer_and_frees_ips(): void
{
Mail::fake();
$other = Customer::factory()->create(['fullname' => 'Công ty B']);
$a = $this->pool('10.0.0.5', ['status' => 'assigned', 'customer_id' => $this->customer->id, 'customer_name' => 'Công ty A', 'service_name' => 'CLOUD VPS SILVER', 'contact_email' => 'a@example.com']);
$b = $this->pool('10.0.0.6', ['status' => 'assigned', 'customer_id' => $other->id, 'customer_name' => 'Công ty B']);
$groups = $this->actingAs($this->admin, 'admin')
->postJson(self::BASE . '/revocation/preview', ['ids' => [$a->id, $b->id], 'date' => '2026-10-31'])
->assertOk()
->assertJsonCount(2, 'data')
->json('data');
$missingEmail = collect($groups)->firstWhere('customer_name', 'Công ty B');
$this->assertNull($missingEmail['to']);
$this->actingAs($this->admin, 'admin')
->postJson(self::BASE . '/revoke', [
'ids' => [$a->id, $b->id], 'date' => '2026-10-31',
'recipients' => collect($groups)->map(fn ($g) => ['key' => $g['key'], 'to' => $g['to']])->all(),
])
->assertStatus(422);
$this->actingAs($this->admin, 'admin')
->postJson(self::BASE . '/revoke', [
'ids' => [$a->id, $b->id], 'date' => '2026-10-31',
'recipients' => collect($groups)->map(fn ($g) => ['key' => $g['key'], 'to' => $g['to'] ?? 'b@example.com'])->all(),
])
->assertOk();
$a->refresh();
$this->assertSame('free', $a->status);
$this->assertNull($a->customer_id);
$this->assertSame('2026-10-31', $a->revoked_at->toDateString());
$log = IpPoolLog::where('ip_pool_id', $a->id)->sole();
$this->assertSame([IpPoolLog::REVOKED, 'Công ty A', $this->customer->id], [$log->action, $log->customer_name, $log->customer_id]);
$this->assertSame(['2026-10-31', 'CLOUD VPS SILVER'], [$log->effective_date->toDateString(), $log->service_name]);
$this->assertNull($a->service_name);
Mail::assertQueuedCount(2);
}
public function test_revoke_refuses_ips_used_by_a_vm(): void
{
$pool = $this->pool('10.0.0.7', ['status' => 'assigned']);
ModuleIpAddress::create(['ip' => '10.0.0.7', 'module_network_id' => $this->network->id, 'status' => 1]);
$this->actingAs($this->admin, 'admin')
->postJson(self::BASE . '/revocation/preview', ['ids' => [$pool->id], 'date' => '2026-10-31'])
->assertStatus(422);
}
public function test_logs_endpoint_returns_history(): void
{
$pool = $this->pool('10.0.0.8');
IpPoolLog::record($pool, IpPoolLog::ALLOCATED, 'free', ['customer_name' => 'Công ty A']);
$this->actingAs($this->admin, 'admin')
->getJson(self::BASE . "/{$pool->id}/logs")
->assertOk()
->assertJsonPath('data.0.action', IpPoolLog::ALLOCATED)
->assertJsonPath('data.0.customer_name', 'Công ty A');
}
}
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\IpPool;
use App\Models\IpPool;
use App\Models\ModuleIpAddress;
use App\Models\ModuleNetwork;
use App\Models\User;
use App\Services\IpPool\IpPoolSheetImporter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class IpPoolSheetImportTest extends TestCase
{
use RefreshDatabase;
private const HEADER = [
'TEN_MANG', 'CIDR', 'OWNER_TYPE', 'IP_ADDRESS', 'IP_ROLE', 'PARENT_IP', 'SUBNET_MASK', 'GATEWAY',
'TEN_KHACH_HANG', 'TEN_DICH_VU', 'NGAY_CAP_PHAT', 'NGAY_THU_HOI', 'TRANG_THAI', 'CONTACT_EMAIL',
'CONTACT_CC', 'CONTACT_PHONE', 'MONTHLY_COST', 'SELLING_PRICE', 'THONG_TIN_BO_SUNG',
'LAST_EMAIL_TYPE', 'LAST_EMAIL_AT', 'UPDATED_AT', 'UPDATED_BY', 'GHI_CHU', 'NOTE',
];
private function admin(): User
{
$role = Role::create(['name' => 'role-' . uniqid(), 'guard_name' => 'admin']);
$role->givePermissionTo(Permission::firstOrCreate(['name' => 'module-vcenter.manager', 'guard_name' => 'admin']));
$admin = User::factory()->create();
$admin->assignRole($role);
return $admin;
}
private function row(array $values): array
{
return array_map(fn ($header) => $values[$header] ?? null, self::HEADER);
}
private function xlsx(array $rows): UploadedFile
{
$spreadsheet = new Spreadsheet();
$spreadsheet->getActiveSheet()->setTitle('CONFIG');
$sheet = $spreadsheet->createSheet()->setTitle('IP_POOL');
$sheet->fromArray([self::HEADER, ...$rows]);
$sheet->setCellValue('Y2', '=XLOOKUP(D2,CONFIG!A:A,CONFIG!B:B)');
$path = tempnam(sys_get_temp_dir(), 'ip') . '.xlsx';
(new Xlsx($spreadsheet))->setPreCalculateFormulas(false)->save($path);
return new UploadedFile($path, 'ip.xlsx', null, null, true);
}
public function test_status_follows_sheet_unless_the_system_owns_the_ip(): void
{
$this->assertSame('assigned', IpPoolSheetImporter::resolveStatus(null, false, 'ALLOCATED'));
$this->assertSame('assigned', IpPoolSheetImporter::resolveStatus('free', false, 'ALLOCATED'));
$this->assertSame('free', IpPoolSheetImporter::resolveStatus('assigned', false, 'FREE'));
$this->assertSame('assigned', IpPoolSheetImporter::resolveStatus('assigned', true, 'FREE'));
$this->assertSame('reserved', IpPoolSheetImporter::resolveStatus('reserved', true, 'ALLOCATED'));
$this->assertSame('free', IpPoolSheetImporter::resolveStatus(null, false, null));
}
public function test_preview_reports_changes_without_writing_and_import_applies_them(): void
{
$network = ModuleNetwork::create(['name' => 'VM Network 56']);
IpPool::create([
'ip_address' => '10.0.0.5', 'module_network_id' => $network->id, 'cidr' => 24,
'gateway' => '10.0.0.1', 'status' => 'free',
]);
IpPool::create([
'ip_address' => '10.0.0.6', 'module_network_id' => $network->id, 'cidr' => 24, 'status' => 'free',
]);
ModuleIpAddress::create(['ip' => '10.0.0.6', 'module_network_id' => $network->id, 'status' => 1]);
$file = $this->xlsx([
$this->row([
'TEN_MANG' => "THEGIOISO-VN\t\t", 'CIDR' => '/22', 'OWNER_TYPE' => 'thế giới số',
'IP_ADDRESS' => '10.0.0.5', 'TEN_KHACH_HANG' => 'Khách A', 'TRANG_THAI' => 'ALLOCATED',
'NGAY_CAP_PHAT' => ExcelDate::PHPToExcel(new \DateTime('2026-04-15')),
'CONTACT_EMAIL' => "\ta@example.com; b@example.com", 'CONTACT_PHONE' => 835123959,
'MONTHLY_COST' => 60000,
]),
$this->row(['TEN_MANG' => 'THEGIOISO-VN', 'IP_ADDRESS' => '10.0.0.6', 'TRANG_THAI' => 'FREE']),
$this->row(['TEN_MANG' => 'HOITUSO-VN', 'IP_ADDRESS' => '10.0.1.9', 'TRANG_THAI' => 'ALLOCATED']),
$this->row(['CONTACT_CC' => 'kythuat@tgs.com.vn']),
$this->row(['TEN_MANG' => 'HOITUSO-VN', 'IP_ADDRESS' => '10.0.1.10', 'CONTACT_EMAIL' => 'not-an-email']),
]);
$admin = $this->admin();
$this->actingAs($admin, 'admin')
->post('/api/v1/administrator/ip-pool/import/preview', ['file' => $file], ['Accept' => 'application/json'])
->assertOk()
->assertJsonPath('data.valid', 3)
->assertJsonPath('data.skipped_blank', 1)
->assertJsonPath('data.error_count', 1)
->assertJsonPath('data.errors.0.row', 6)
->assertJsonPath('data.create', 1)
->assertJsonPath('data.update', 2)
->assertJsonPath('data.to_assigned', 2)
->assertJsonPath('data.kept_by_system', 1);
$this->assertSame(2, IpPool::count());
$this->actingAs($admin, 'admin')
->post('/api/v1/administrator/ip-pool/import', ['file' => $file], ['Accept' => 'application/json'])
->assertOk();
$updated = IpPool::where('ip_address', '10.0.0.5')->sole();
$this->assertSame($network->id, $updated->module_network_id);
$this->assertSame('assigned', $updated->status);
$this->assertSame('THEGIOISO-VN', $updated->ip_block);
$this->assertSame('THẾ GIỚI SỐ', $updated->owner_type);
$this->assertSame(22, $updated->cidr);
$this->assertSame('10.0.0.1', $updated->gateway);
$this->assertSame('a@example.com, b@example.com', $updated->contact_email);
$this->assertSame('0835123959', $updated->contact_phone);
$this->assertSame('2026-04-15', $updated->allocated_at->toDateString());
$this->assertSame('60000.00', $updated->monthly_cost);
$this->assertSame('free', IpPool::where('ip_address', '10.0.0.6')->value('status'));
$created = IpPool::where('ip_address', '10.0.1.9')->sole();
$this->assertNull($created->module_network_id);
$this->assertSame('assigned', $created->status);
$this->assertFalse(IpPool::where('ip_address', '10.0.1.10')->exists());
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\IpPool;
use App\Models\Customer;
use App\Models\IpPool;
use App\Models\ModuleIpAddress;
use App\Models\ModuleNetwork;
use App\Models\ModuleVmware;
use App\Models\Service;
use App\Models\User;
use App\Models\VirtualMachines;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class IpPoolUsageTest extends TestCase
{
use RefreshDatabase;
public function test_list_links_ips_to_their_vm_service_customer_and_vcenter_vm(): void
{
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$admin = User::factory()->create();
$admin->assignRole('Full Administrator');
$network = ModuleNetwork::create(['name' => 'VM Network 56']);
$customer = Customer::factory()->create(['fullname' => 'Công ty A']);
foreach (['10.0.0.5', '10.0.0.6', '10.0.0.7'] as $ip) {
IpPool::create(['ip_address' => $ip, 'module_network_id' => $network->id, 'cidr' => 24, 'status' => 'assigned']);
ModuleIpAddress::create(['ip' => $ip, 'module_network_id' => $network->id, 'status' => 1, 'vm_name' => "{$ip}_vm"]);
}
$vmware = ModuleVmware::create(['name' => '10.0.0.5_customer-vm', 'ip' => '10.0.0.5', 'customer_id' => $customer->id]);
$service = Service::create(['customer_id' => $customer->id, 'status' => 'active', 'server_name' => 'vps-a', 'vm_id' => $vmware->id]);
Schema::disableForeignKeyConstraints();
$vcenterVm = VirtualMachines::create(['uuid' => (string) Str::uuid(), 'vm_id' => 'vm-1', 'name' => '10.0.0.6_test-vm', 'datacenter_id' => 1, 'host_id' => 1]);
$items = collect($this->actingAs($admin, 'admin')
->getJson('/api/v1/administrator/ip-pool?all=1')
->assertOk()
->json('data.items'))
->keyBy('ip_address');
$this->assertSame($service->id, $items['10.0.0.5']['usage']['service_id']);
$this->assertSame('vps-a', $items['10.0.0.5']['usage']['service_name']);
$this->assertSame($customer->uuid, $items['10.0.0.5']['usage']['customer_uuid']);
$this->assertSame($vcenterVm->id, $items['10.0.0.6']['usage']['vcenter_vm_id']);
$this->assertNull($items['10.0.0.6']['usage']['service_id']);
$this->assertSame('10.0.0.7_vm', $items['10.0.0.7']['usage']['vm_name']);
$this->assertNull($items['10.0.0.7']['usage']['vcenter_vm_id']);
}
}
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Service;
use App\Enums\Invoice\InvoiceStatus;
use App\Enums\Order\OrderStatus;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\InvoiceAttachment;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class ServiceAttachmentTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private Service $service;
private Invoice $invoice;
private Invoice $foreignInvoice;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$this->admin = User::factory()->create();
$this->admin->assignRole('Full Administrator');
$customer = Customer::factory()->create();
$order = $this->makeOrder($customer);
$item = OrderItem::create(['order_id' => $order->id, 'product_id' => null, 'billing_cycle' => 'monthly', 'months' => 1, 'quantity' => 1, 'price' => 100000]);
$this->service = Service::create(['customer_id' => $customer->id, 'order_item_id' => $item->id, 'status' => 'active']);
$this->invoice = $this->makeInvoice($customer, $order);
$this->foreignInvoice = $this->makeInvoice($customer, $this->makeOrder($customer));
}
public function test_upload_list_copy_to_invoice_and_delete(): void
{
$base = "/api/v1/administrator/services/{$this->service->id}/attachments";
$admin = $this->actingAs($this->admin, 'admin');
$id = $admin->post($base, ['file' => UploadedFile::fake()->create('spec.pdf', 10, 'application/pdf')], ['Accept' => 'application/json'])
->assertCreated()
->json('data.id');
$admin->getJson($base)
->assertOk()
->assertJsonPath('data.attachments.0.filename', 'spec.pdf')
->assertJsonCount(1, 'data.invoices')
->assertJsonPath('data.invoices.0.id', $this->invoice->id);
$admin->postJson("$base/$id/copy-to-invoice", ['invoice_id' => $this->foreignInvoice->id])->assertNotFound();
$admin->postJson("$base/$id/copy-to-invoice", ['invoice_id' => $this->invoice->id])->assertCreated();
$admin->postJson("$base/$id/copy-to-invoice", ['invoice_id' => $this->invoice->id])->assertCreated();
$copy = InvoiceAttachment::where('invoice_id', $this->invoice->id)->sole();
$this->assertSame(InvoiceAttachment::TYPE_DEPLOYMENT, $copy->type);
Storage::disk('public')->assertExists($copy->path);
$admin->deleteJson("$base/$id")->assertOk();
$this->assertDatabaseCount('service_attachments', 0);
Storage::disk('public')->assertExists($copy->path);
}
public function test_list_and_detail_expose_linked_order_and_invoices(): void
{
$admin = $this->actingAs($this->admin, 'admin');
$admin->getJson('/api/v1/administrator/services')
->assertOk()
->assertJsonPath('data.items.0.order_item.order.order_num', $this->invoice->order->order_num)
->assertJsonCount(1, 'data.items.0.linked_invoices')
->assertJsonPath('data.items.0.linked_invoices.0.invoice_number', $this->invoice->invoice_number);
$admin->getJson("/api/v1/administrator/services/{$this->service->id}")
->assertOk()
->assertJsonPath('data.linked_invoices.0.id', $this->invoice->id);
}
public function test_rejects_unsupported_file_type(): void
{
$this->actingAs($this->admin, 'admin')
->post("/api/v1/administrator/services/{$this->service->id}/attachments", ['file' => UploadedFile::fake()->create('x.exe', 10)], ['Accept' => 'application/json'])
->assertStatus(422);
}
private function makeOrder(Customer $customer): Order
{
return Order::create(['order_num' => 'ORD-' . uniqid(), 'customer_id' => $customer->id, 'sub_total' => 100_000, 'total' => 100_000, 'status' => OrderStatus::PENDING->value]);
}
private function makeInvoice(Customer $customer, Order $order): Invoice
{
return 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,
'due_date' => now()->addDays(5)->toDateString(),
]);
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class UpdatePasswordTest extends TestCase
{
use RefreshDatabase;
public function test_admin_can_change_own_password(): void
{
$user = User::factory()->create(['password' => Hash::make('OldPassword1')]);
$this->actingAs($user, 'admin')
->putJson('/api/v1/administrator/password', [
'current_password' => 'OldPassword1',
'password' => 'NewPassword2',
'password_confirmation' => 'NewPassword2',
])
->assertOk()
->assertJsonPath('error', false);
$this->assertTrue(Hash::check('NewPassword2', $user->fresh()->password));
}
public function test_admin_must_provide_the_correct_current_password(): void
{
$user = User::factory()->create(['password' => Hash::make('OldPassword1')]);
$this->actingAs($user, 'admin')
->putJson('/api/v1/administrator/password', [
'current_password' => 'WrongPassword',
'password' => 'NewPassword2',
'password_confirmation' => 'NewPassword2',
])
->assertUnprocessable()
->assertJsonValidationErrors('current_password');
$this->assertTrue(Hash::check('OldPassword1', $user->fresh()->password));
}
public function test_new_password_must_be_confirmed_and_different(): void
{
$user = User::factory()->create(['password' => Hash::make('OldPassword1')]);
$this->actingAs($user, 'admin')
->putJson('/api/v1/administrator/password', [
'current_password' => 'OldPassword1',
'password' => 'OldPassword1',
'password_confirmation' => 'DifferentPassword2',
])
->assertUnprocessable()
->assertJsonValidationErrors('password');
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UpdateProfileTest extends TestCase
{
use RefreshDatabase;
public function test_admin_can_update_fullname(): void
{
$user = User::factory()->create(['fullname' => 'Old Name']);
$this->actingAs($user, 'admin')
->putJson('/api/v1/administrator/profile', [
'fullname' => 'Nguyễn Văn A',
'phone' => '0901234567',
])
->assertOk()
->assertJsonPath('data.user.fullname', 'Nguyễn Văn A');
$this->assertSame('Nguyễn Văn A', $user->fresh()->fullname);
$this->assertSame('0901234567', $user->fresh()->phone);
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\WebDesign;
use App\Models\User;
use App\Models\WebDesignQuote;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class WebDesignQuoteXlsxTest extends TestCase
{
use RefreshDatabase;
public function test_downloads_quote_into_pricing_template_with_live_totals(): void
{
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$admin = User::factory()->create();
$admin->assignRole('Full Administrator');
$quote = WebDesignQuote::create([
'quote_number' => 'BG-WEB-202610-0001',
'status' => 'draft',
'customer_name' => 'Nguyễn Văn A',
'website_name' => 'shop.vn',
'website_category' => 'Thương mại điện tử',
'platform' => 'wordpress_elementor',
'items' => [
['name' => 'Thiết kế UI', 'description' => '<ul><li>Trang chủ</li><li>Sản phẩm</li></ul>', 'quantity' => 1, 'unit_price' => 8000000],
['name' => '=HYPERLINK("x")', 'description' => null, 'quantity' => 2, 'unit_price' => 1000000],
],
'subtotal' => 10000000,
'discount' => 500000,
'vat_percent' => 8,
'total' => 10260000,
'currency' => 'VND',
]);
$response = $this->actingAs($admin, 'admin')
->get("/api/v1/administrator/web-design-quotes/{$quote->id}/xlsx")
->assertOk();
$book = IOFactory::load($response->baseResponse->getFile()->getPathname());
$sheet = $book->getActiveSheet();
$cells = collect($sheet->toArray(null, true, false, true));
$this->assertSame(1, $book->getSheetCount());
$this->assertCount(1, $sheet->getDrawingCollection());
$this->assertSame('BÁO GIÁ THIẾT KẾ WEBSITE SHOP.VN', $sheet->getCell('A6')->getValue());
$itemRow = $cells->search(fn (array $row) => $row['B'] === 'Thiết kế UI');
$this->assertSame("- Trang chủ\n- Sản phẩm", $sheet->getCell("C{$itemRow}")->getValue());
$this->assertSame('=HYPERLINK("x")', $sheet->getCell('B' . ($itemRow + 1))->getCalculatedValue());
$totalRow = $cells->search(fn (array $row) => $row['A'] === 'Tổng giá trị báo giá');
$this->assertEquals($quote->total, $sheet->getCell("F{$totalRow}")->getCalculatedValue());
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Mail;
use App\Http\Facade\SignRSA;
use App\Models\Setting;
use App\Support\MailSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class MailSettingsQueueTest extends TestCase
{
use RefreshDatabase;
private function saveSmtp(string $host): void
{
Setting::updateOrCreate(['key' => 'setting'], ['value' => json_encode(['company' => 'Thế Giới Số'])]);
Setting::updateOrCreate(['key' => 'smtp'], ['value' => json_encode([
'host' => $host, 'port' => 465, 'user' => 'noreply@tgs.com.vn',
'password' => SignRSA::EncryptFunc('secret'), 'encryption' => 'ssl',
'from_address' => 'noreply@tgs.com.vn', 'from_name' => 'TGS',
])]);
Cache::forget(MailSettings::cacheKey());
}
private function hostSeenByQueuedJob(): ?string
{
dispatch(fn () => Cache::put('test:queued-mail-host', config('mail.mailers.smtp.host')));
return Cache::pull('test:queued-mail-host');
}
public function test_queued_jobs_use_smtp_from_settings_and_pick_up_changes(): void
{
config(['queue.default' => 'sync']);
$this->saveSmtp('smtp.settings-one.test');
$this->assertSame('smtp.settings-one.test', $this->hostSeenByQueuedJob());
$this->assertSame('noreply@tgs.com.vn', config('mail.from.address'));
$this->assertSame('secret', config('mail.mailers.smtp.password'));
$this->saveSmtp('smtp.settings-two.test');
$this->assertSame('smtp.settings-two.test', $this->hostSeenByQueuedJob());
}
}