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