Files
portal-api/tests/Feature/Api/Admin/IpPool/IpAllocationTest.php
T
baodev0andClaude Opus 5.5 46aa138eec 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>
2026-10-15 19:58:36 +07:00

252 lines
11 KiB
PHP

<?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');
}
}