seed(AuthorizationSeeder::class); WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); $headerLogo = $this->createAttachment('header.png'); $footerLogo = $this->createAttachment('footer.png'); $this->tenant = Tenant::query()->create([ 'codigo' => 'acme', 'nombre' => 'Acme', 'dominio' => 'acme.test', 'website_type_code' => 'onticket', 'primary_color' => '#111111', 'secondary_color' => '#222222', 'danger_color' => '#cc0000', 'success_color' => '#008800', 'header_bg_color' => '#ffffff', 'footer_bg_color' => '#ffffff', 'header_logo_id' => $headerLogo->id, 'footer_logo_id' => $footerLogo->id, ]); $this->admin = User::factory()->create([ 'rol_codigo' => RoleCode::AdminApp->value, 'tenant_codigo' => $this->tenant->codigo, ]); } public function test_staff_email_can_be_shared_with_adminapp_and_customers(): void { Sanctum::actingAs($this->admin); User::factory()->create(['email' => $this->admin->email]); $payload = ['nombre_apellido' => 'Shared', 'dni' => '12345678', 'email' => strtoupper($this->admin->email), 'category_ids' => [$this->createCategory('Tickets')->id]]; $response = $this->postJson('/api/v1/adminapp/tenant/staff', $payload)->assertSuccessful(); $this->putJson('/api/v1/adminapp/tenant/staff/'.$response->json('data.id'), $payload)->assertOk(); $this->postJson('/api/v1/adminapp/tenant/staff', $payload)->assertUnprocessable()->assertJsonValidationErrors('email'); } public function test_adminapp_can_create_update_list_and_delete_staff_with_categories(): void { Sanctum::actingAs($this->admin); $firstCategory = $this->createCategory('Bebidas'); $secondCategory = $this->createCategory('Comidas'); $response = $this->postJson('/api/v1/adminapp/tenant/staff', [ 'nombre_apellido' => 'Ada Lovelace', 'dni' => '12345678', 'email' => 'ADA@example.test', 'category_ids' => [$firstCategory->id], ])->assertSuccessful() ->assertJsonPath('data.email', 'ada@example.test') ->assertJsonPath('data.role.codigo', RoleCode::Scanner->value) ->assertJsonPath('data.categories.0.id', $firstCategory->id); $staffId = $response->json('data.id'); $this->assertDatabaseHas('category_scanners', [ 'user_id' => $staffId, 'categoria_id' => $firstCategory->id, ]); $this->assertDatabaseHas('reset_password_attempts', [ 'user_id' => $staffId, 'reason' => ResetPasswordAttempt::REASON_STAFF_CREATED, 'status' => ResetPasswordAttempt::STATUS_PENDING, ]); Event::assertDispatched( PasswordResetRequested::class, fn (PasswordResetRequested $event): bool => $event->tenantCode === $this->tenant->codigo && $event->channel === PasswordResetRequested::CHANNEL_SCANNER, ); $this->getJson('/api/v1/adminapp/tenant/staff?search=ada') ->assertOk() ->assertJsonCount(1, 'data'); $this->putJson("/api/v1/adminapp/tenant/staff/{$staffId}", [ 'nombre_apellido' => 'Ada Byron', 'dni' => '12345678', 'email' => 'ada@example.test', 'category_ids' => [$secondCategory->id], ])->assertOk() ->assertJsonPath('data.nombre_apellido', 'Ada Byron') ->assertJsonPath('data.categories.0.id', $secondCategory->id); $this->assertDatabaseMissing('category_scanners', [ 'user_id' => $staffId, 'categoria_id' => $firstCategory->id, ]); $ticket = Ticket::query()->create([ 'tenant_code' => $this->tenant->codigo, 'ticket' => (string) Str::uuid(), 'user_id' => $this->admin->id, 'used_at' => now(), 'scanner_user_id' => $staffId, ]); $accessTokenId = User::query() ->findOrFail($staffId) ->createToken('scanner', ['scanner']) ->accessToken ->getKey(); $this->deleteJson("/api/v1/adminapp/tenant/staff/{$staffId}")->assertNoContent(); $this->assertSoftDeleted('users', ['id' => $staffId]); $this->assertDatabaseHas('users', [ 'id' => $staffId, 'email' => 'ada@example.test', 'active_email' => null, ]); $this->assertDatabaseMissing('personal_access_tokens', ['id' => $accessTokenId]); $this->assertDatabaseHas('category_scanners', [ 'user_id' => $staffId, 'categoria_id' => $secondCategory->id, ]); $this->assertSame($staffId, $ticket->fresh()->scanner_user_id); $this->assertSame('Ada Byron', $ticket->fresh()->scannerUser?->nombre_apellido); $this->getJson('/api/v1/adminapp/tenant/staff') ->assertOk() ->assertJsonCount(0, 'data'); $replacementResponse = $this->postJson('/api/v1/adminapp/tenant/staff', [ 'nombre_apellido' => 'Nueva Ada', 'dni' => '11223344', 'email' => 'ADA@example.test', 'category_ids' => [$firstCategory->id], ])->assertSuccessful() ->assertJsonPath('data.email', 'ada@example.test'); $replacementStaffId = $replacementResponse->json('data.id'); $this->assertNotSame($staffId, $replacementStaffId); $this->assertSame('Ada Byron', $ticket->fresh()->scannerUser?->nombre_apellido); $this->assertDatabaseHas('users', [ 'id' => $replacementStaffId, 'email' => 'ada@example.test', 'active_email' => 'ada@example.test', 'deleted_at' => null, ]); } public function test_admin_cannot_assign_another_tenants_category(): void { Sanctum::actingAs($this->admin); $otherTenant = Tenant::query()->create([ 'codigo' => 'other', 'nombre' => 'Other', 'dominio' => 'other.test', 'website_type_code' => 'onticket', 'primary_color' => '#111111', 'secondary_color' => '#222222', 'danger_color' => '#cc0000', 'success_color' => '#008800', 'header_bg_color' => '#ffffff', 'footer_bg_color' => '#ffffff', 'header_logo_id' => $this->tenant->header_logo_id, 'footer_logo_id' => $this->tenant->footer_logo_id, ]); $foreignCategory = Category::query()->create([ 'tenant_code' => $otherTenant->codigo, 'nombre' => 'Privada', ]); $payload = [ 'nombre_apellido' => 'Grace Hopper', 'dni' => '87654321', 'email' => 'grace@example.test', 'category_ids' => [$foreignCategory->id], ]; $this->postJson('/api/v1/adminapp/tenant/staff', $payload) ->assertUnprocessable() ->assertJsonValidationErrors('category_ids'); $parent = $this->createCategory('Local'); $child = Category::query()->create([ 'tenant_code' => $this->tenant->codigo, 'categoria_id' => $parent->id, 'nombre' => 'SubcategorĂ­a', ]); $payload['category_ids'] = [$child->id]; $this->postJson('/api/v1/adminapp/tenant/staff', $payload) ->assertUnprocessable() ->assertJsonValidationErrors('category_ids'); } public function test_categories_are_required_by_default_for_scanner_staff(): void { Sanctum::actingAs($this->admin); $this->postJson('/api/v1/adminapp/tenant/staff', [ 'nombre_apellido' => 'Grace Hopper', 'dni' => '87654321', 'email' => 'grace@example.test', 'category_ids' => [], ])->assertUnprocessable() ->assertJsonValidationErrors('category_ids'); } public function test_admin_can_manage_staff_without_categories_when_tenant_disables_validation(): void { $this->tenant->update(['scanner_category_validation_enabled' => false]); Sanctum::actingAs($this->admin); $response = $this->postJson('/api/v1/adminapp/tenant/staff', [ 'nombre_apellido' => 'Grace Hopper', 'dni' => '87654321', 'email' => 'grace@example.test', ])->assertCreated() ->assertJsonCount(0, 'data.categories'); $staffId = $response->json('data.id'); $category = $this->createCategory('Entradas'); $this->putJson("/api/v1/adminapp/tenant/staff/{$staffId}", [ 'nombre_apellido' => 'Grace Murray Hopper', 'dni' => '87654321', 'email' => 'grace@example.test', 'category_ids' => [$category->id], ])->assertOk() ->assertJsonCount(0, 'data.categories'); $this->assertDatabaseMissing('category_scanners', [ 'user_id' => $staffId, ]); } public function test_customer_cannot_manage_staff(): void { Sanctum::actingAs(User::factory()->create([ 'rol_codigo' => RoleCode::User->value, ])); $this->getJson('/api/v1/adminapp/tenant/staff')->assertForbidden(); } public function test_adminapp_can_search_staff_scan_attempts_by_ticket_id_category_and_date(): void { $scanner = User::factory()->create([ 'rol_codigo' => RoleCode::Scanner->value, 'tenant_codigo' => $this->tenant->codigo, ]); $otherScanner = User::factory()->create([ 'rol_codigo' => RoleCode::Scanner->value, 'tenant_codigo' => $this->tenant->codigo, ]); $category = $this->createCategory('Alojamiento'); $catalogItem = CatalogItem::query()->create([ 'tenant_code' => $this->tenant->codigo, 'category_id' => $category->id, 'slug' => 'alojamiento-test', 'nombre' => 'Hotel', 'precio' => 100, ]); $ticket = Ticket::query()->create([ 'tenant_code' => $this->tenant->codigo, 'ticket' => (string) Str::uuid(), 'source_catalog_item_id' => $catalogItem->id, 'user_id' => $this->admin->id, ]); $matching = $this->createScanAttempt($scanner, 'matching-qr', [ 'ticket_id' => $ticket->id, 'created_at' => '2026-08-11 14:30:00', ]); $this->createScanAttempt($scanner, 'another-qr', [ 'created_at' => '2026-08-22 22:22:22', ]); $this->createScanAttempt($otherScanner, 'matching-qr', [ 'ticket_id' => $ticket->id, 'created_at' => '2026-08-11 14:30:00', ]); Sanctum::actingAs($this->admin); $this->getJson( "/api/v1/adminapp/tenant/staff/{$scanner->id}/scan-attempts?q={$ticket->id}" )->assertOk() ->assertJsonFragment([ 'id' => $matching->id, 'ticket_id' => $ticket->id, ]); $assertSingleMatchingAttempt = function (string $search) use ($scanner, $matching, $ticket): void { $this->getJson( "/api/v1/adminapp/tenant/staff/{$scanner->id}/scan-attempts?q=".urlencode($search).'&per_page=1' ) ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $matching->id) ->assertJsonPath('data.0.ticket_id', $ticket->id) ->assertJsonPath('data.0.category', 'Alojamiento') ->assertJsonPath('data.0.result_label', 'Verificado') ->assertJsonPath('meta.current_page', 1) ->assertJsonPath('meta.per_page', 1) ->assertJsonPath('meta.total', 1); }; $assertSingleMatchingAttempt('alojamiento'); $assertSingleMatchingAttempt('11/08/26'); } public function test_adminapp_cannot_list_scan_attempts_for_non_scanner_staff(): void { $customer = User::factory()->create([ 'rol_codigo' => RoleCode::User->value, 'tenant_codigo' => $this->tenant->codigo, ]); Sanctum::actingAs($this->admin); $this->getJson("/api/v1/adminapp/tenant/staff/{$customer->id}/scan-attempts") ->assertNotFound(); } public function test_adminapp_can_search_staff_scan_attempts_by_day_and_month_across_years(): void { $scanner = User::factory()->create([ 'rol_codigo' => RoleCode::Scanner->value, 'tenant_codigo' => $this->tenant->codigo, ]); $firstMatch = $this->createScanAttempt($scanner, 'first-match', [ 'created_at' => '2024-09-07 10:00:00', ]); $secondMatch = $this->createScanAttempt($scanner, 'second-match', [ 'created_at' => '2026-09-07 10:00:00', ]); $this->createScanAttempt($scanner, 'different-day', [ 'created_at' => '2026-09-08 10:00:00', ]); Sanctum::actingAs($this->admin); $this->getJson("/api/v1/adminapp/tenant/staff/{$scanner->id}/scan-attempts?q=07%2F09") ->assertOk() ->assertJsonCount(2, 'data') ->assertJsonPath('data.0.id', $secondMatch->id) ->assertJsonPath('data.1.id', $firstMatch->id) ->assertJsonPath('meta.total', 2); } public function test_adminapp_datetime_search_matches_text_in_any_datetime_component(): void { $scanner = User::factory()->create([ 'rol_codigo' => RoleCode::Scanner->value, 'tenant_codigo' => $this->tenant->codigo, ]); $matches = [ $this->createScanAttempt($scanner, 'year-match', [ 'created_at' => '2007-11-12 10:00:08', ]), $this->createScanAttempt($scanner, 'month-match', [ 'created_at' => '2026-07-12 10:00:08', ]), $this->createScanAttempt($scanner, 'day-match', [ 'created_at' => '2026-11-07 10:00:08', ]), $this->createScanAttempt($scanner, 'seconds-match', [ 'created_at' => '2026-11-12 10:00:07', ]), ]; $this->createScanAttempt($scanner, 'no-match', [ 'created_at' => '2026-11-12 10:00:08', ]); Sanctum::actingAs($this->admin); $response = $this->getJson( "/api/v1/adminapp/tenant/staff/{$scanner->id}/scan-attempts?q=07" )->assertOk() ->assertJsonCount(4, 'data') ->assertJsonPath('meta.total', 4); foreach ($matches as $match) { $response->assertJsonFragment(['id' => $match->id]); } } /** @param array $attributes */ private function createScanAttempt(User $scanner, string $data, array $attributes = []): ScanAttempt { $attempt = new ScanAttempt; $attempt->forceFill(array_merge([ 'tenant_code' => $this->tenant->codigo, 'scanner_user_id' => $scanner->id, 'data' => $data, 'result' => ScanAttemptResult::Accepted, 'resolved_at' => now(), ], $attributes)); $attempt->save(); return $attempt; } private function createAttachment(string $filename): Attachment { return Attachment::query()->create([ 'path' => "test/{$filename}", 'filename' => $filename, 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); } private function createCategory(string $name): Category { return Category::query()->create([ 'tenant_code' => $this->tenant->codigo, 'nombre' => $name, ]); } }