seed(AuthorizationSeeder::class); WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); $this->tenant = $this->createTenant('acme'); $this->scanner = User::factory()->create([ 'rol_codigo' => RoleCode::Scanner->value, 'tenant_codigo' => $this->tenant->codigo, ]); $this->ticketOwner = User::factory()->create(); $this->category = Category::query()->create([ 'tenant_code' => $this->tenant->codigo, 'nombre' => 'Entradas', ]); $this->scanner->scanCategories()->attach($this->category); } public function test_scanner_routes_require_authentication_and_scan_permission(): void { $this->getJson('/api/v1/scanner/attempts')->assertUnauthorized(); Sanctum::actingAs(User::factory()->create([ 'rol_codigo' => RoleCode::User->value, 'tenant_codigo' => $this->tenant->codigo, ])); $this->getJson('/api/v1/scanner/attempts')->assertForbidden(); } public function test_scanner_can_list_only_its_scan_attempts(): void { $olderTicket = $this->createTicket('11111111-1111-4111-8111-111111111111'); $newerTicket = $this->createTicket('22222222-2222-4222-8222-222222222222'); $older = $this->createScanAttempt($olderTicket->ticket, [ 'ticket_id' => $olderTicket->id, 'created_at' => now()->subMinutes(2), ]); $newer = $this->createScanAttempt($newerTicket->ticket, [ 'ticket_id' => $newerTicket->id, 'created_at' => now()->subMinute(), ]); $otherScanner = User::factory()->create([ 'rol_codigo' => RoleCode::Scanner->value, 'tenant_codigo' => $this->tenant->codigo, ]); $this->createScanAttempt('not-this-scanner', [], $otherScanner); Sanctum::actingAs($this->scanner); $this->getJson('/api/v1/scanner/attempts') ->assertOk() ->assertJsonCount(2, 'data') ->assertJsonPath('data.0.id', $newer->id) ->assertJsonPath('data.0.data', $newer->data) ->assertJsonPath('data.0.ticket_id', $newerTicket->id) ->assertJsonPath('data.0.ticket', $newerTicket->ticket) ->assertJsonPath('data.0.resolved_at', $newer->resolved_at->toJSON()) ->assertJsonPath('data.0.result', ScanAttemptResult::Accepted->value) ->assertJsonPath('data.0.result_label', 'Verificado') ->assertJsonPath('data.0.result_detail_label', 'Verificado') ->assertJsonPath('data.0.can_view_ticket', true) ->assertJsonPath('data.1.id', $older->id) ->assertJsonPath('meta.current_page', 1) ->assertJsonPath('meta.total', 2); } public function test_scanner_attempt_history_supports_data_search_and_pagination(): void { $matching = $this->createScanAttempt('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); $this->createScanAttempt('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'); Sanctum::actingAs($this->scanner); $this->getJson('/api/v1/scanner/attempts?q=aaaaaaaa&per_page=1') ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.data', $matching->data) ->assertJsonPath('meta.current_page', 1) ->assertJsonPath('meta.per_page', 1) ->assertJsonPath('meta.total', 1); } public function test_scanner_attempt_history_can_be_searched_by_database_id(): void { $matching = $this->createScanAttempt('cccccccc-cccc-4ccc-8ccc-cccccccccccc'); $this->createScanAttempt('dddddddd-dddd-4ddd-8ddd-dddddddddddd'); Sanctum::actingAs($this->scanner); $this->getJson("/api/v1/scanner/attempts?q={$matching->id}") ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $matching->id); } public function test_scanner_attempt_history_can_be_searched_by_attempt_date(): void { $matching = $this->createScanAttempt('eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', [ 'created_at' => '2026-08-11 14:30:00', ]); $this->createScanAttempt('ffffffff-ffff-4fff-8fff-ffffffffffff', [ 'created_at' => '2026-08-10 14:30:00', ]); Sanctum::actingAs($this->scanner); $this->getJson('/api/v1/scanner/attempts?q=11%2F08%2F26') ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $matching->id); } public function test_scanner_can_read_its_scan_attempt_detail(): void { $ticket = $this->createTicket('abababab-abab-4bab-8bab-abababababab'); $scanAttempt = $this->createScanAttempt($ticket->ticket, [ 'ticket_id' => $ticket->id, ]); Sanctum::actingAs($this->scanner); $this->getJson("/api/v1/scanner/attempts/{$scanAttempt->id}") ->assertOk() ->assertJsonPath('data.scan_attempt.id', $scanAttempt->id) ->assertJsonPath('data.scan_attempt.ticket_id', $ticket->id) ->assertJsonPath('data.scan_attempt.result', ScanAttemptResult::Accepted->value) ->assertJsonPath('data.scan_attempt.result_label', 'Verificado') ->assertJsonPath('data.scan_attempt.result_detail_label', 'Verificado') ->assertJsonPath('data.ticket.id', $ticket->id) ->assertJsonPath('data.ticket.ticket', $ticket->ticket) ->assertJsonPath('data.ticket.client', $this->ticketOwner->nombre_apellido) ->assertJsonPath('data.client.id', $this->ticketOwner->id) ->assertJsonPath('data.client.nombre_apellido', $this->ticketOwner->nombre_apellido); } public function test_scanner_cannot_read_another_scanners_attempt_detail(): void { $otherScanner = User::factory()->create([ 'rol_codigo' => RoleCode::Scanner->value, 'tenant_codigo' => $this->tenant->codigo, ]); $scanAttempt = $this->createScanAttempt('not-this-scanner', [], $otherScanner); Sanctum::actingAs($this->scanner); $this->getJson("/api/v1/scanner/attempts/{$scanAttempt->id}") ->assertNotFound(); } public function test_scan_attempt_detail_returns_null_ticket_and_client_when_unassociated(): void { $scanAttempt = $this->createScanAttempt('not-a-ticket', [ 'result' => ScanAttemptResult::TicketNotFound, ]); Sanctum::actingAs($this->scanner); $this->getJson("/api/v1/scanner/attempts/{$scanAttempt->id}") ->assertOk() ->assertJsonPath('data.scan_attempt.id', $scanAttempt->id) ->assertJsonPath('data.scan_attempt.result_label', 'Error') ->assertJsonPath('data.scan_attempt.result_detail_label', 'Error') ->assertJsonPath('data.ticket', null) ->assertJsonPath('data.client', null); } public function test_scan_attempt_detail_returns_specific_invalid_qr_label(): void { $scanAttempt = $this->createScanAttempt('not-a-valid-qr', [ 'result' => ScanAttemptResult::InvalidQr, ]); Sanctum::actingAs($this->scanner); $this->getJson("/api/v1/scanner/attempts/{$scanAttempt->id}") ->assertOk() ->assertJsonPath('data.scan_attempt.result_label', 'Error') ->assertJsonPath('data.scan_attempt.result_detail_label', 'QR no pertenece al evento'); } public function test_scanner_can_read_an_authorized_ticket_detail_by_uuid(): void { $ticket = $this->createTicket('44444444-4444-4444-8444-444444444444'); Sanctum::actingAs($this->scanner); $this->getJson("/api/v1/scanner/tickets/{$ticket->ticket}") ->assertOk() ->assertJsonPath('data.id', $ticket->id) ->assertJsonPath('data.ticket', $ticket->ticket) ->assertJsonPath('data.name', $ticket->name) ->assertJsonPath('data.client', $this->ticketOwner->nombre_apellido) ->assertJsonPath('data.category', $this->category->nombre) ->assertJsonPath('data.is_valid', true) ->assertJsonPath('data.is_used', false); } public function test_scanner_cannot_read_a_ticket_from_an_unassigned_category_or_tenant(): void { $otherCategory = Category::query()->create([ 'tenant_code' => $this->tenant->codigo, 'nombre' => 'Comidas', ]); $unassignedTicket = $this->createTicket( '55555555-5555-4555-8555-555555555555', [], $otherCategory, ); $otherTenant = $this->createTenant('other'); $foreignCategory = Category::query()->create([ 'tenant_code' => $otherTenant->codigo, 'nombre' => 'Externas', ]); $foreignTicket = $this->createTicket( '66666666-6666-4666-8666-666666666666', [], $foreignCategory, $otherTenant, ); Sanctum::actingAs($this->scanner); $this->getJson("/api/v1/scanner/tickets/{$unassignedTicket->ticket}") ->assertNotFound(); $this->getJson("/api/v1/scanner/tickets/{$foreignTicket->ticket}") ->assertNotFound(); } public function test_scanner_can_scan_an_authorized_ticket_by_uuid(): void { $ticket = $this->createTicket('77777777-7777-4777-8777-777777777777'); Sanctum::actingAs($this->scanner); $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket]) ->assertOk() ->assertJsonPath('data.scan_attempt.data', $ticket->ticket) ->assertJsonPath('data.scan_attempt.ticket_id', $ticket->id) ->assertJsonPath('data.scan_attempt.ticket', $ticket->ticket) ->assertJsonPath('data.scan_attempt.result', ScanAttemptResult::Accepted->value) ->assertJsonPath('data.scan_attempt.result_label', 'Verificado') ->assertJsonPath('data.scan_attempt.result_detail_label', 'Verificado') ->assertJsonPath('data.ticket.ticket', $ticket->ticket) ->assertJsonPath('data.ticket.scanner_user_id', $this->scanner->id) ->assertJsonPath('data.ticket.is_valid', false) ->assertJsonPath('data.ticket.is_used', true) ->assertJsonPath('data.ticket.client', $this->ticketOwner->nombre_apellido) ->assertJsonPath('data.client.id', $this->ticketOwner->id) ->assertJsonPath('data.client.nombre_apellido', $this->ticketOwner->nombre_apellido); $this->assertDatabaseHas('tickets', [ 'id' => $ticket->id, 'scanner_user_id' => $this->scanner->id, ]); $this->assertNotNull($ticket->fresh()->used_at); $scanAttempt = ScanAttempt::query()->sole(); $this->assertSame($ticket->ticket, $scanAttempt->data); $this->assertSame(ScanAttemptResult::Accepted, $scanAttempt->result); $this->assertTrue($scanAttempt->scanner->is($this->scanner)); $this->assertTrue($scanAttempt->ticket->is($ticket)); $this->assertTrue($scanAttempt->tenant->is($this->tenant)); $this->assertNotNull($scanAttempt->resolved_at); } public function test_scan_returns_an_attempt_for_invalid_qr_data(): void { Sanctum::actingAs($this->scanner); $this->postJson('/api/v1/scanner/tickets/scan') ->assertOk() ->assertJsonPath('data.scan_attempt.id', fn (mixed $id): bool => is_int($id)) ->assertJsonPath('data.scan_attempt.result', ScanAttemptResult::InvalidQr->value); $this->postJson('/api/v1/scanner/tickets/scan', ['data' => 'not-a-uuid']) ->assertOk() ->assertJsonPath('data.scan_attempt.id', fn (mixed $id): bool => is_int($id)) ->assertJsonPath('data.scan_attempt.result', ScanAttemptResult::InvalidQr->value); $scanAttempts = ScanAttempt::query()->orderBy('id')->get(); $this->assertCount(2, $scanAttempts); $this->assertNull($scanAttempts[0]->data); $this->assertSame('not-a-uuid', $scanAttempts[1]->data); $this->assertTrue($scanAttempts->every( fn (ScanAttempt $scanAttempt): bool => $scanAttempt->result === ScanAttemptResult::InvalidQr && $scanAttempt->scanner_user_id === $this->scanner->id && $scanAttempt->resolved_at !== null )); } public function test_ticket_cannot_be_scanned_twice(): void { $ticket = $this->createTicket('88888888-8888-4888-8888-888888888888'); Sanctum::actingAs($this->scanner); $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket])->assertOk(); $otherScanner = User::factory()->create([ 'rol_codigo' => RoleCode::Scanner->value, 'tenant_codigo' => $this->tenant->codigo, ]); $otherScanner->scanCategories()->attach($this->category); Sanctum::actingAs($otherScanner); $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket]) ->assertOk() ->assertJsonPath('data.scan_attempt.id', fn (mixed $id): bool => is_int($id)) ->assertJsonPath('data.scan_attempt.result', ScanAttemptResult::AlreadyScanned->value); $this->assertSame($this->scanner->id, $ticket->fresh()->scanner_user_id); $this->assertDatabaseHas('scan_attempts', [ 'scanner_user_id' => $otherScanner->id, 'ticket_id' => $ticket->id, 'result' => ScanAttemptResult::AlreadyScanned->value, ]); } public function test_scanner_cannot_scan_a_ticket_from_an_unassigned_category(): void { $otherCategory = Category::query()->create([ 'tenant_code' => $this->tenant->codigo, 'nombre' => 'Comidas', ]); $ticket = $this->createTicket( '99999999-9999-4999-8999-999999999999', [], $otherCategory, ); Sanctum::actingAs($this->scanner); $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket]) ->assertOk() ->assertJsonPath('data.scan_attempt.id', fn (mixed $id): bool => is_int($id)) ->assertJsonPath('data.scan_attempt.result', ScanAttemptResult::CategoryForbidden->value); $this->assertNull($ticket->fresh()->used_at); $this->assertDatabaseHas('scan_attempts', [ 'scanner_user_id' => $this->scanner->id, 'ticket_id' => $ticket->id, 'result' => ScanAttemptResult::CategoryForbidden->value, ]); } public function test_scanner_can_read_and_scan_any_category_when_tenant_disables_validation(): void { $this->tenant->update(['scanner_category_validation_enabled' => false]); $this->scanner->scanCategories()->detach(); $otherCategory = Category::query()->create([ 'tenant_code' => $this->tenant->codigo, 'nombre' => 'Comidas', ]); $ticket = $this->createTicket( 'aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa', [], $otherCategory, ); Sanctum::actingAs($this->scanner); $this->getJson("/api/v1/scanner/tickets/{$ticket->ticket}") ->assertOk() ->assertJsonPath('data.id', $ticket->id); $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket]) ->assertOk() ->assertJsonPath('data.ticket.scanner_user_id', $this->scanner->id); } public function test_disabled_category_validation_does_not_allow_scanning_another_tenant(): void { $this->tenant->update(['scanner_category_validation_enabled' => false]); $otherTenant = $this->createTenant('other'); $foreignCategory = Category::query()->create([ 'tenant_code' => $otherTenant->codigo, 'nombre' => 'Externas', ]); $foreignTicket = $this->createTicket( 'bbbbbbbb-1111-4111-8111-bbbbbbbbbbbb', [], $foreignCategory, $otherTenant, ); Sanctum::actingAs($this->scanner); $this->getJson("/api/v1/scanner/tickets/{$foreignTicket->ticket}") ->assertNotFound(); $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $foreignTicket->ticket]) ->assertOk() ->assertJsonPath('data.scan_attempt.id', fn (mixed $id): bool => is_int($id)) ->assertJsonPath('data.scan_attempt.result', ScanAttemptResult::TicketNotFound->value); $this->assertDatabaseHas('scan_attempts', [ 'tenant_code' => $this->tenant->codigo, 'scanner_user_id' => $this->scanner->id, 'ticket_id' => null, 'data' => $foreignTicket->ticket, 'result' => ScanAttemptResult::TicketNotFound->value, ]); } public function test_adminapp_cannot_access_scanner_routes_even_with_scan_permission(): void { $admin = User::factory()->create([ 'rol_codigo' => RoleCode::AdminApp->value, 'tenant_codigo' => $this->tenant->codigo, ]); $admin->role()->firstOrFail()->permissions()->attach(PermissionCode::ScanTickets->value); $ticket = $this->createTicket((string) Str::uuid()); Sanctum::actingAs($admin); $this->getJson('/api/v1/scanner/attempts')->assertForbidden(); $this->getJson("/api/v1/scanner/tickets/{$ticket->ticket}")->assertForbidden(); $this->postJson('/api/v1/scanner/tickets/scan', ['data' => $ticket->ticket])->assertForbidden(); $this->assertNull($ticket->fresh()->used_at); $this->assertDatabaseCount('scan_attempts', 0); } /** @param array $attributes */ private function createScanAttempt( ?string $data, array $attributes = [], ?User $scanner = null, ): ScanAttempt { $scanAttempt = new ScanAttempt; $scanAttempt->forceFill(array_merge([ 'tenant_code' => $this->tenant->codigo, 'scanner_user_id' => ($scanner ?? $this->scanner)->id, 'data' => $data, 'result' => ScanAttemptResult::Accepted, 'resolved_at' => now(), ], $attributes)); $scanAttempt->save(); return $scanAttempt; } /** @param array $attributes */ private function createTicket( string $uuid, array $attributes = [], ?Category $category = null, ?Tenant $tenant = null, ): Ticket { $tenant ??= $this->tenant; $category ??= $this->category; $catalogItem = CatalogItem::query()->create([ 'tenant_code' => $tenant->codigo, 'category_id' => $category->id, 'slug' => "ticket-{$uuid}", 'nombre' => 'Entrada general', 'descripcion' => 'Acceso general', 'precio' => 100, 'has_tickets' => true, ]); return Ticket::query()->create(array_merge([ 'tenant_code' => $tenant->codigo, 'ticket' => $uuid, 'source_catalog_item_id' => $catalogItem->id, 'user_id' => $this->ticketOwner->id, ], $attributes)); } private function createTenant(string $code): Tenant { $logo = Attachment::query()->create([ 'key' => (string) Str::uuid(), 'path' => "tests/{$code}-logo.png", 'filename' => "{$code}-logo.png", 'type' => 'image', 'mime_type' => 'image/png', 'extension' => 'png', 'size' => 1, ]); return Tenant::query()->create([ 'codigo' => $code, 'nombre' => ucfirst($code), 'dominio' => "{$code}.test", 'primary_color' => '#ff7006', 'secondary_color' => '#777777', 'danger_color' => '#e04a4a', 'success_color' => '#81bc73', 'header_bg_color' => '#313131', 'footer_bg_color' => '#313131', 'header_logo_id' => $logo->id, 'footer_logo_id' => $logo->id, 'website_type_code' => 'onticket', ]); } }