Files
portal-api/tests/Feature/Api/Client/ClientTicketTest.php
T
baodev0 0b08689ea4 feat: harden admin auth and ticket workflows
- add rotating admin refresh tokens and password gates\n- expand ticket galleries, exports, and client resources\n- improve IP allocation auditing and dashboard data\n- enforce encrypted client access tokens on public routes
2026-10-16 17:00:55 +07:00

171 lines
7.2 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Client;
use App\Models\Customer;
use App\Models\Ticket;
use App\Mail\Admin\TicketAdminNotification;
use App\Mail\TicketCreated;
use App\Models\TicketReply;
use App\Models\User;
use App\Support\TicketGallery;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Tests\TestCase;
class ClientTicketTest extends TestCase
{
use RefreshDatabase;
private Customer $customer;
protected function setUp(): void
{
parent::setUp();
$this->customer = Customer::factory()->create(['status' => 1]);
}
private function makeTicket(Customer $owner, array $attributes = []): Ticket
{
return Ticket::create(array_merge([
'uuid' => (string) Str::uuid(),
'ticket_num' => 'TKT-' . Str::upper(Str::random(6)),
'subject' => 'Subject',
'description' => 'Description',
'status' => 'open',
'room' => 'technical',
'customer_id' => $owner->id,
], $attributes));
}
private function as(Customer $customer): static
{
return $this->actingAs($customer, 'client')->withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
}
private function list(array $query = [])
{
return $this->as($this->customer)->getJson('/v1/client/tickets?' . http_build_query($query));
}
public function test_paginates_with_requested_page_size(): void
{
foreach (range(1, 12) as $i) {
$this->makeTicket($this->customer, ['subject' => "Ticket {$i}"]);
}
$this->makeTicket(Customer::factory()->create(['status' => 1]));
$response = $this->list(['per_page' => 5, 'page' => 3])->assertOk();
$this->assertSame(12, $response->json('data.total'));
$this->assertSame(3, $response->json('data.last_page'));
$this->assertCount(2, $response->json('data.items'));
}
public function test_filters_by_search_room_and_status(): void
{
$this->makeTicket($this->customer, ['subject' => 'Lỗi VPS không khởi động', 'ticket_num' => 'TKT-VPS']);
$this->makeTicket($this->customer, ['subject' => 'Báo giá hosting', 'room' => 'sales']);
$this->makeTicket($this->customer, ['subject' => 'VPS chậm', 'status' => 'closed']);
$this->makeTicket($this->customer, ['subject' => '100% CPU']);
$this->assertSame(['TKT-VPS'], collect($this->list(['search' => 'khởi động'])->json('data.items'))->pluck('ticket_num')->all());
$this->assertCount(1, $this->list(['search' => 'tkt-vps'])->json('data.items'));
$this->assertCount(1, $this->list(['search' => '100%'])->json('data.items'));
$this->assertCount(1, $this->list(['room' => 'sales'])->json('data.items'));
$this->assertCount(1, $this->list(['search' => 'VPS', 'status' => 'open'])->json('data.items'));
}
public function test_rejects_oversized_page(): void
{
$this->list(['per_page' => 500])->assertStatus(422);
}
public function test_client_payload_hides_internal_ids_and_storage_urls(): void
{
Storage::fake('public');
Storage::disk('public')->put('tickets/legacy.webp', 'legacy');
$ticket = $this->makeTicket($this->customer, [
'ticket_num' => 'TKT-LEGACY',
'data' => json_encode(['priority' => 'high']),
'gallery' => ['https://api.portal.test/storage/tickets/legacy.webp'],
]);
TicketReply::create(['ticket_id' => $ticket->id, 'message' => 'Hi', 'user_id' => User::factory()->create()->id]);
$data = $this->as($this->customer)->getJson('/v1/client/tickets/TKT-LEGACY')->assertOk()->json('data');
foreach (['id', 'uuid', 'customer_id', 'data'] as $key) {
$this->assertArrayNotHasKey($key, $data);
}
$this->assertSame(['fullname'], array_keys($data['customer']));
$this->assertSame('high', $data['priority']);
$this->assertSame(['/v1/client/tickets/TKT-LEGACY/images/0'], $data['gallery']);
$this->assertSame(['message', 'sender_type', 'sender_name', 'sender_role', 'created_at'], array_keys($data['replies'][0]));
$this->assertStringNotContainsString('storage', json_encode($data));
$this->as($this->customer)->get('/v1/client/tickets/TKT-LEGACY/images/0')->assertOk();
$this->as($this->customer)->get('/v1/client/tickets/TKT-LEGACY/images/1')->assertNotFound();
}
public function test_uploaded_images_are_private_and_owner_only(): void
{
Storage::fake('local');
Mail::fake();
Queue::fake();
$ticketNum = $this->as($this->customer)->post('/v1/client/tickets', [
'subject' => 'Cần hỗ trợ VPS',
'description' => 'Mô tả chi tiết vấn đề đang gặp phải',
'images' => [UploadedFile::fake()->image('shot.png')],
])->assertCreated()->json('data.ticket_num');
$path = Ticket::where('ticket_num', $ticketNum)->value('gallery')[0];
$this->assertStringStartsWith("ticket-attachments/{$this->customer->id}/", $path);
Storage::disk('local')->assertExists($path);
$this->as($this->customer)->get("/v1/client/tickets/{$ticketNum}/images/0")->assertOk();
$this->as(Customer::factory()->create(['status' => 1]))->get("/v1/client/tickets/{$ticketNum}/images/0")->assertNotFound();
$this->get("/api/v1/administrator/tickets/{$ticketNum}/images/0")->assertForbidden();
$signed = URL::temporarySignedRoute('admin.tickets.image', now()->addMinute(), ['ticket' => $ticketNum, 'index' => 0]);
$this->get($signed)->assertOk();
}
public function test_moves_legacy_public_images_to_private_disk(): void
{
Storage::fake('public');
Storage::fake('local');
Storage::disk('public')->put('tickets/old.webp', 'old');
$ticket = $this->makeTicket($this->customer, ['gallery' => ['https://api.portal.test/storage/tickets/old.webp']]);
$this->assertTrue(TicketGallery::moveLegacyToPrivate($ticket));
$path = "ticket-attachments/{$this->customer->id}/old.webp";
$this->assertSame([$path], $ticket->fresh()->gallery);
Storage::disk('local')->assertExists($path);
Storage::disk('public')->assertMissing('tickets/old.webp');
$this->assertFalse(TicketGallery::moveLegacyToPrivate($ticket->fresh()));
}
public function test_ticket_emails_show_customer_fullname_and_room_label(): void
{
$this->seed(\Database\Seeders\TemplateSeeder::class);
$this->customer->update(['fullname' => 'Nguyễn Văn A']);
$ticket = $this->makeTicket($this->customer, ['ticket_num' => 'TKT-MAIL']);
foreach ([new TicketCreated($ticket, $this->customer), new TicketAdminNotification($ticket, $this->customer)] as $mail) {
$html = $mail->render();
$this->assertStringContainsString('Nguyễn Văn A', $html, $mail::class);
$this->assertStringContainsString('Kỹ thuật', $html, $mail::class);
$this->assertStringNotContainsString('N/A', $html, $mail::class);
}
}
}