app->register(DomPdfServiceProvider::class); $this->seed(AuthorizationSeeder::class); WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); } public function test_authentication_is_required(): void { $this->getJson('/api/v1/adminapp/tenant/tickets')->assertUnauthorized(); } public function test_the_tenant_must_have_the_tickets_menu(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); Sanctum::actingAs($this->createAdminAppUser($tenant)); $this->getJson('/api/v1/adminapp/tenant/tickets')->assertNotFound(); } public function test_it_lists_only_tickets_from_the_authenticated_tenant(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); $otherTenant = $this->createTenant('other'); $admin = $this->createAdminAppUser($tenant); $otherUser = $this->createAdminAppUser($otherTenant); $this->grantTicketsMenu($tenant); Sanctum::actingAs($admin); $ticket = $this->createTicket($tenant, $admin); $this->createTicket($otherTenant, $otherUser); $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $ticket->id) ->assertJsonPath('data.0.tenant_code', $tenant->codigo) ->assertJsonPath('data.0.values.id', $ticket->id) ->assertJsonPath('data.0.values.status', Ticket::STATUS_ACTIVE) ->assertJsonMissingPath('data.0.values.ticket') ->assertJsonPath('data.0.values.date', '-') ->assertJsonPath('data.0.values.size', '-') ->assertJsonMissingPath('data.0.date') ->assertJsonPath('meta.total', 1); } public function test_it_searches_by_id_and_does_not_search_by_uuid(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); $admin = $this->createAdminAppUser($tenant); $this->grantTicketsMenu($tenant); Sanctum::actingAs($admin); $matching = $this->createTicket($tenant, $admin); $this->createTicket($tenant, $admin); $this->getJson("/api/v1/adminapp/tenant/tickets?q={$matching->id}") ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $matching->id); $this->getJson('/api/v1/adminapp/tenant/tickets?q='.substr($matching->ticket, 0, 8)) ->assertOk() ->assertJsonCount(0, 'data'); } public function test_it_searches_by_visible_client_and_scanner_names(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); $admin = $this->createAdminAppUser($tenant); $client = User::factory()->create([ 'tenant_codigo' => $tenant->codigo, 'nombre_apellido' => 'María Cliente', ]); $scanner = $this->createAdminAppUser($tenant); $scanner->update(['nombre_apellido' => 'Carlos Inspector']); $this->grantTicketsMenu($tenant); Sanctum::actingAs($admin); $clientTicket = $this->createTicket($tenant, $client); $scannerTicket = $this->createTicket($tenant, $admin, [ 'scanner_user_id' => $scanner->id, 'used_at' => now(), ]); $this->createTicket($tenant, $admin); $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query(['q' => 'maría'])) ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $clientTicket->id); $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query(['q' => 'INSPECTOR'])) ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $scannerTicket->id); } public function test_it_searches_by_order_number_and_formatted_unit_amount(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); $admin = $this->createAdminAppUser($tenant); $this->grantTicketsMenu($tenant); Sanctum::actingAs($admin); foreach (range(1, 10) as $number) { $this->createPurchase($tenant, $admin, now()->subMinutes($number)->toDateTimeString()); } $item = CatalogItem::query()->create([ 'tenant_code' => $tenant->codigo, 'slug' => 'entrada-busqueda', 'nombre' => 'Entrada búsqueda', 'precio' => '1234.50', ]); $purchase = Purchase::query()->create([ 'tenant_codigo' => $tenant->codigo, 'user_id' => $admin->id, 'status' => Purchase::STATUS_PAID, 'nombre_apellido' => 'Cliente de la compra', 'total' => '1234.50', ]); $purchaseItem = PurchaseItem::query()->create([ 'compra_id' => $purchase->id, 'source_catalog_item_id' => $item->id, 'nombre' => $item->nombre, 'descripcion' => '', 'slug' => $item->slug, 'item_nombre' => $item->nombre, 'cantidad' => 1, 'precio_unitario' => '1234.50', 'total' => '1234.50', ]); $matching = $this->createTicket($tenant, $admin, [ 'source_purchase_item_id' => $purchaseItem->id, 'source_catalog_item_id' => $item->id, ]); $this->createTicket($tenant, $admin); $this->getJson("/api/v1/adminapp/tenant/tickets?q={$purchase->id}") ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $matching->id); $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query(['q' => 'cliente de la compra'])) ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $matching->id); foreach (['1234,50', '$1.234,50', '1,234.50'] as $amount) { $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query(['q' => $amount])) ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $matching->id); } } public function test_it_sorts_by_ticket_id_before_paginating(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); $admin = $this->createAdminAppUser($tenant); $this->grantTicketsMenu($tenant); Sanctum::actingAs($admin); $ids = collect(range(1, 4)) ->map(fn (): int => $this->createTicket($tenant, $admin)->id) ->all(); $query = http_build_query([ 'sort_by' => 'id', 'sort_direction' => 'asc', 'per_page' => 2, ]); $this->getJson('/api/v1/adminapp/tenant/tickets?'.$query) ->assertOk() ->assertJsonPath('data.0.values.id', $ids[0]) ->assertJsonPath('data.1.values.id', $ids[1]) ->assertJsonPath('meta.total', 4) ->assertJsonPath('meta.last_page', 2); $this->getJson('/api/v1/adminapp/tenant/tickets?'.$query.'&page=2') ->assertOk() ->assertJsonPath('data.0.values.id', $ids[2]) ->assertJsonPath('data.1.values.id', $ids[3]); } public function test_it_rejects_sort_columns_not_enabled_for_the_tenant(): void { $tenant = $this->createTenant('other'); $this->grantTicketsMenu($tenant); Sanctum::actingAs($this->createAdminAppUser($tenant)); $this->getJson('/api/v1/adminapp/tenant/tickets?sort_by=category&sort_direction=sideways') ->assertUnprocessable() ->assertJsonValidationErrors(['sort_by', 'sort_direction']); } public function test_it_rejects_product_and_type_sorting_for_fiesta_futbol_infantil(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); $this->grantTicketsMenu($tenant); Sanctum::actingAs($this->createAdminAppUser($tenant)); foreach (['product', 'type'] as $column) { $this->getJson('/api/v1/adminapp/tenant/tickets?sort_by='.$column) ->assertUnprocessable() ->assertJsonValidationErrors('sort_by'); } } public function test_it_includes_tenant_scanned_and_total_ticket_counts(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); $otherTenant = $this->createTenant('other'); $admin = $this->createAdminAppUser($tenant); $otherUser = $this->createAdminAppUser($otherTenant); $this->grantTicketsMenu($tenant); Sanctum::actingAs($admin); $this->createTicket($tenant, $admin)->update(['used_at' => now()]); $this->createTicket($tenant, $admin); $this->createTicket($otherTenant, $otherUser)->update(['used_at' => now()]); $this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match') ->assertOk() ->assertJsonCount(0, 'data') ->assertJsonPath('scanned_tickets', 0) ->assertJsonPath('total_tickets', 0); $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() ->assertJsonPath('scanned_tickets', 1) ->assertJsonPath('total_tickets', 2); } public function test_it_returns_structured_variant_properties(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); $admin = $this->createAdminAppUser($tenant); $this->grantTicketsMenu($tenant); Sanctum::actingAs($admin); $item = CatalogItem::query()->create([ 'tenant_code' => $tenant->codigo, 'slug' => 'remera', 'nombre' => 'Remera', 'precio' => '8000.00', 'has_tickets' => true, ]); $attribute = Attribute::query()->create([ 'tenant_codigo' => $tenant->codigo, 'codigo' => 'size', 'nombre' => 'Talle', 'type' => FieldType::Select, ]); $attribute->options()->create(['value' => 'xl', 'label' => 'XL']); $itemAttribute = $item->itemAttributes()->create([ 'attribute_id' => $attribute->id, 'sort_order' => 1, ]); $variant = Variant::query()->create([ 'catalog_item_id' => $item->id, 'inventory_id' => Inventory::query()->create()->id, ]); $variant->definitions()->create([ 'item_attribute_id' => $itemAttribute->id, 'value' => 'xl', ]); $purchase = Purchase::query()->create([ 'tenant_codigo' => $tenant->codigo, 'user_id' => $admin->id, 'status' => Purchase::STATUS_PAID, 'nombre_apellido' => 'Nombre Apellido', 'total' => '8000.00', ]); $purchaseItem = PurchaseItem::query()->create([ 'compra_id' => $purchase->id, 'source_catalog_item_id' => $item->id, 'source_variant_id' => $variant->id, 'nombre' => 'Remera', 'descripcion' => '', 'slug' => 'remera', 'item_nombre' => 'Remera', 'cantidad' => 1, 'precio_unitario' => '8000.00', 'total' => '8000.00', ]); $ticket = $this->createTicket($tenant, $admin, [ 'source_purchase_item_id' => $purchaseItem->id, 'source_catalog_item_id' => $item->id, 'source_variant_id' => $variant->id, 'scanner_user_id' => $admin->id, 'used_at' => now(), ]); $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() ->assertJsonPath('data.0.id', $ticket->id) ->assertJsonPath('data.0.source_purchase_item_id', $purchaseItem->id) ->assertJsonPath('data.0.order_number', $purchase->id) ->assertJsonPath('data.0.product', 'Remera') ->assertJsonPath('data.0.amount', '8000.00') ->assertJsonPath('data.0.status', Ticket::STATUS_USED) ->assertJsonPath('data.0.scanned_by', $admin->nombre_apellido) ->assertJsonPath('data.0.variant_properties.0.code', 'size') ->assertJsonPath('data.0.variant_properties.0.label', 'Talle') ->assertJsonPath('data.0.variant_properties.0.values.0.value', 'xl') ->assertJsonPath('data.0.variant_properties.0.values.0.label', 'XL'); } public function test_it_applies_the_ticket_filter_form_values(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); $admin = $this->createAdminAppUser($tenant); $this->grantTicketsMenu($tenant); $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); Sanctum::actingAs($admin); $date = $tenant->eventDates()->orderByDesc('date')->firstOrFail(); $food = CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('slug', 'comida') ->with([ 'variants.definitions.itemAttribute.attribute.options', 'variants.eventDates', 'variants.eventDate', ]) ->firstOrFail(); $dinner = $food->variants->first(fn (Variant $variant): bool => $variant ->selectedEventDates()->contains('id', $date->id) && $variant->selectionValues()->get('horario') === 'Cena'); $lunch = $food->variants->first(fn (Variant $variant): bool => $variant ->selectedEventDates()->contains('id', $date->id) && $variant->selectionValues()->get('horario') === 'Almuerzo'); $this->assertNotNull($dinner); $this->assertNotNull($lunch); $dinnerService = $dinner->selectionValues()->get('servicio'); $otherDate = $tenant->eventDates()->where('id', '!=', $date->id)->firstOrFail(); $otherDinner = $food->variants->first(fn (Variant $variant): bool => $variant ->selectedEventDates()->contains('id', $otherDate->id) && $variant->selectionValues()->get('horario') === 'Cena' && $variant->selectionValues()->get('servicio') === $dinnerService); $this->assertNotNull($otherDinner); $matchingPurchase = $this->createPurchase($tenant, $admin, '2026-08-20 10:00:00'); $otherPurchase = $this->createPurchase($tenant, $admin, '2026-08-21 10:00:00'); $dinnerItem = $this->createPurchaseItem($matchingPurchase, $food, $dinner); $lunchItem = $this->createPurchaseItem($matchingPurchase, $food, $lunch); $otherDinnerItem = $this->createPurchaseItem($otherPurchase, $food, $otherDinner); $matching = $this->createTicket($tenant, $admin, [ 'source_purchase_item_id' => $dinnerItem->id, 'source_catalog_item_id' => $food->id, 'source_variant_id' => $dinner->id, 'used_at' => now(), ]); $this->createTicket($tenant, $admin, [ 'source_purchase_item_id' => $lunchItem->id, 'source_catalog_item_id' => $food->id, 'source_variant_id' => $lunch->id, 'used_at' => now(), ]); $this->createTicket($tenant, $admin, [ 'source_purchase_item_id' => $otherDinnerItem->id, 'source_catalog_item_id' => $food->id, 'source_variant_id' => $otherDinner->id, 'used_at' => now(), ]); $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query([ 'category' => 'comidas', 'product' => 'Cena', 'type' => $dinnerService, 'date' => $date->date->format('Y-m-d'), 'status' => Ticket::STATUS_USED, ])) ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $matching->id) ->assertJsonPath('data.0.values.product', 'Cena') ->assertJsonPath('data.0.values.type', $dinnerService) ->assertJsonPath('data.0.values.date', $date->date->format('d/m')) ->assertJsonPath('scanned_tickets', 1) ->assertJsonPath('total_tickets', 1); } public function test_it_exposes_and_filters_merchandise_color_and_size(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); $admin = $this->createAdminAppUser($tenant); $this->grantTicketsMenu($tenant); $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); Sanctum::actingAs($admin); $merchandise = CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('slug', 'camiseta') ->with([ 'variants.definitions.itemAttribute.attribute.options', 'variants.eventDates', 'variants.eventDate', ]) ->firstOrFail(); $greenMedium = $merchandise->variants->first(fn (Variant $variant): bool => $variant->selectionValues()->get('color') === 'Verde' && $variant->selectionValues()->get('talle') === 'M'); $whiteMedium = $merchandise->variants->first(fn (Variant $variant): bool => $variant->selectionValues()->get('color') === 'Blanco' && $variant->selectionValues()->get('talle') === 'M'); $this->assertNotNull($greenMedium); $this->assertNotNull($whiteMedium); $matching = $this->createTicket($tenant, $admin, [ 'source_catalog_item_id' => $merchandise->id, 'source_variant_id' => $greenMedium->id, ]); $this->createTicket($tenant, $admin, [ 'source_catalog_item_id' => $merchandise->id, 'source_variant_id' => $whiteMedium->id, ]); $this->getJson('/api/v1/adminapp/tenant/tickets?'.http_build_query([ 'category' => 'merchandising', 'product' => 'camiseta', 'type' => 'Verde', 'size' => 'M', ])) ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $matching->id) ->assertJsonPath('data.0.values.product', 'Camiseta') ->assertJsonPath('data.0.values.type', 'Verde') ->assertJsonPath('data.0.values.date', '-') ->assertJsonPath('data.0.values.size', 'M'); } public function test_it_filters_computed_active_and_expired_statuses(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); $admin = $this->createAdminAppUser($tenant); $this->grantTicketsMenu($tenant); $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); Sanctum::actingAs($admin); $food = CatalogItem::query() ->where('tenant_code', $tenant->codigo) ->where('slug', 'comida') ->with('variants') ->firstOrFail(); $expired = $this->createTicket($tenant, $admin, [ 'source_catalog_item_id' => $food->id, 'source_variant_id' => $food->variants->firstOrFail()->id, ]); $active = $this->createTicket($tenant, $admin); $this->travelTo('2026-10-20 12:00:00'); $this->getJson('/api/v1/adminapp/tenant/tickets?status=expired') ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $expired->id); $this->getJson('/api/v1/adminapp/tenant/tickets?status=active') ->assertOk() ->assertJsonCount(1, 'data') ->assertJsonPath('data.0.id', $active->id); } public function test_it_downloads_filtered_ticket_reports(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); $admin = $this->createAdminAppUser($tenant); $this->grantTicketsMenu($tenant); Sanctum::actingAs($admin); $ticket = $this->createTicket($tenant, $admin); $query = http_build_query([ 'q' => (string) $ticket->id, 'timezone' => 'America/Argentina/Buenos_Aires', ]); $this->get('/api/v1/adminapp/tenant/tickets/pdf?'.$query) ->assertOk() ->assertHeader('content-type', 'application/pdf'); $this->get('/api/v1/adminapp/tenant/tickets/excel?'.$query) ->assertOk() ->assertHeader( 'content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ); } public function test_ticket_reports_require_a_valid_timezone(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); $this->grantTicketsMenu($tenant); Sanctum::actingAs($this->createAdminAppUser($tenant)); $this->getJson('/api/v1/adminapp/tenant/tickets/pdf')->assertUnprocessable(); $this->getJson('/api/v1/adminapp/tenant/tickets/excel?timezone=Invalid') ->assertUnprocessable(); } private function createTenant(string $code): Tenant { $headerLogo = $this->createAttachment("{$code}-header.png"); $footerLogo = $this->createAttachment("{$code}-footer.png"); return Tenant::query()->create([ 'codigo' => $code, 'nombre' => ucfirst($code), 'dominio' => "{$code}.test", 'website_type_code' => 'onticket', 'primary_color' => '#111111', 'secondary_color' => '#222222', 'danger_color' => '#333333', 'success_color' => '#444444', 'header_bg_color' => '#ffffff', 'footer_bg_color' => '#ffffff', 'header_logo_id' => $headerLogo->id, 'footer_logo_id' => $footerLogo->id, ]); } private function createAttachment(string $filename): Attachment { return Attachment::query()->create([ 'path' => "test/{$filename}", 'filename' => $filename, 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); } private function createAdminAppUser(Tenant $tenant): User { return User::factory()->create([ 'rol_codigo' => RoleCode::AdminApp->value, 'tenant_codigo' => $tenant->codigo, ]); } private function createPurchase(Tenant $tenant, User $user, string $createdAt): Purchase { $purchase = Purchase::query()->create([ 'tenant_codigo' => $tenant->codigo, 'user_id' => $user->id, 'status' => Purchase::STATUS_PAID, 'nombre_apellido' => $user->nombre_apellido, 'total' => '0.00', ]); $purchase->forceFill(['created_at' => $createdAt, 'updated_at' => $createdAt])->saveQuietly(); return $purchase; } private function createPurchaseItem(Purchase $purchase, CatalogItem $item, Variant $variant): PurchaseItem { return $purchase->items()->create([ 'source_catalog_item_id' => $item->id, 'source_variant_id' => $variant->id, 'nombre' => $item->nombre, 'descripcion' => $item->descripcion, 'slug' => $item->slug, 'item_nombre' => $item->nombre, 'variant_attributes' => [], 'cantidad' => 1, 'precio_unitario' => 0, 'total' => 0, ]); } private function grantTicketsMenu(Tenant $tenant): void { $menu = Menu::query()->create([ 'code' => 'adminapp.tickets', 'label' => 'Tickets', 'route' => '/admin/tickets', ]); $tenant->menues()->attach($menu->code); } /** @param array $attributes */ private function createTicket(Tenant $tenant, User $user, array $attributes = []): Ticket { return Ticket::query()->create(array_merge([ 'tenant_code' => $tenant->codigo, 'ticket' => (string) Str::uuid(), 'user_id' => $user->id, ], $attributes)); } }