service = app(TicketGeneratorService::class); $this->tenant = $this->createTenant(); $this->user = User::factory()->create(); } protected function tearDown(): void { Carbon::setTestNow(); parent::tearDown(); } public function test_it_generates_tickets_from_a_standard_catalog_item(): void { $item = $this->createTicketableItem('single-day'); $tickets = $this->service->generate($item, $this->user, 2); $this->assertCount(2, $tickets); foreach ($tickets as $ticket) { $this->assertTrue(Str::isUuid($ticket->ticket)); $this->assertSame($this->tenant->codigo, $ticket->tenant_code); $this->assertSame($this->user->id, $ticket->user_id); $this->assertSame($item->nombre, $ticket->name); $this->assertSame($item->descripcion, $ticket->description); $this->assertSame($item->id, $ticket->source_catalog_item_id); $this->assertNull($ticket->source_variant_id); $this->assertTrue($ticket->validityGroups->isEmpty()); } } public function test_it_rejects_an_item_without_tickets_enabled(): void { $item = $this->createTicketableItem('disabled'); $item->update(['has_tickets' => false]); $this->expectException(TicketGenerationException::class); $this->expectExceptionMessage('no tiene tickets habilitados'); $this->service->generate($item->fresh(), $this->user); } public function test_variant_properties_are_appended_to_the_ticket_name(): void { $item = $this->createTicketableItem('shirt'); $color = Attribute::query()->create([ 'tenant_codigo' => $this->tenant->codigo, 'codigo' => 'color', 'nombre' => 'Color', 'type' => FieldType::Select, ]); $color->options()->create([ 'value' => 'black', 'label' => 'Negro', ]); $size = Attribute::query()->create([ 'tenant_codigo' => $this->tenant->codigo, 'codigo' => 'size', 'nombre' => 'Talle', 'type' => FieldType::Select, ]); $size->options()->create([ 'value' => 'xl', 'label' => 'XL', ]); $itemColor = $item->itemAttributes()->create([ 'attribute_id' => $color->id, 'sort_order' => 1, ]); $itemSize = $item->itemAttributes()->create([ 'attribute_id' => $size->id, 'sort_order' => 2, ]); $variant = $item->variants()->create([ 'inventory_id' => Inventory::query()->create()->id, ]); $variant->definitions()->createMany([ ['item_attribute_id' => $itemColor->id, 'value' => 'black'], ['item_attribute_id' => $itemSize->id, 'value' => 'xl'], ]); $ticket = $this->service ->generate($item, $this->user, 1, $variant->id) ->sole(); $this->assertSame('Shirt (Negro, XL)', $ticket->name); } public function test_it_generates_tickets_for_every_bundle_component_and_quantity(): void { $first = $this->createTicketableItem('first'); $second = $this->createTicketableItem('second'); $bundle = $this->createBundle('bundle'); $bundle->bundleComponents()->createMany([ ['component_catalog_item_id' => $first->id, 'quantity' => 2], ['component_catalog_item_id' => $second->id, 'quantity' => 1], ]); $tickets = $this->service->generate($bundle, $this->user, 2); $this->assertCount(6, $tickets); $this->assertCount(4, $tickets->where('source_catalog_item_id', $first->id)); $this->assertCount(2, $tickets->where('source_catalog_item_id', $second->id)); $this->assertCount(4, $tickets->where('name', $first->nombre)); $this->assertCount(2, $tickets->where('name', $second->nombre)); } public function test_bundle_component_preserves_its_source_variant(): void { $component = $this->createTicketableItem('variant-component'); $inventory = Inventory::query()->create(); $variant = $component->variants()->create([ 'inventory_id' => $inventory->id, ]); $bundle = $this->createBundle('variant-bundle'); $bundle->bundleComponents()->create([ 'component_catalog_item_id' => $component->id, 'component_variant_id' => $variant->id, 'quantity' => 1, ]); $ticket = $this->service ->generate($bundle, $this->user) ->firstOrFail(); $this->assertSame($component->id, $ticket->source_catalog_item_id); $this->assertSame($variant->id, $ticket->source_variant_id); } public function test_bundle_generation_is_rolled_back_when_a_component_is_invalid(): void { $valid = $this->createTicketableItem('valid'); $invalid = $this->createTicketableItem('invalid'); $invalid->update(['has_tickets' => false]); $bundle = $this->createBundle('invalid-bundle'); $bundle->bundleComponents()->createMany([ ['component_catalog_item_id' => $valid->id, 'quantity' => 1], ['component_catalog_item_id' => $invalid->id, 'quantity' => 1], ]); try { $this->service->generate($bundle, $this->user); $this->fail('La generación debería haber fallado.'); } catch (TicketGenerationException) { $this->assertDatabaseCount('tickets', 0); } } public function test_marking_a_purchase_as_paid_generates_its_tickets_once(): void { Event::fake([TicketsAvailable::class]); $item = $this->createTicketableItem('paid-ticket'); $purchase = $this->createPurchase($item, 2); $purchase->setRelation('items', new EloquentCollection); $purchase->markAsPaid(); $this->assertSame(Purchase::STATUS_PAID, $purchase->status); $this->assertDatabaseCount('tickets', 2); Event::assertDispatchedTimes(TicketsAvailable::class, 1); $this->actingAs($this->user, 'sanctum') ->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.tickets_count', 2) ->assertJsonPath('data.has_generated_tickets', true); $purchase->markAsPaid(); $this->assertDatabaseCount('tickets', 2); Event::assertDispatchedTimes(TicketsAvailable::class, 1); } public function test_a_ticket_generated_from_a_purchase_keeps_its_source_ids(): void { $item = $this->createTicketableItem('sourced-ticket'); $inventory = Inventory::query()->create(); $variant = $item->variants()->create(['inventory_id' => $inventory->id]); $purchase = $this->createPurchase($item, 1, $variant->id); $purchase->markAsPaid(); $this->assertDatabaseHas('tickets', [ 'source_purchase_id' => $purchase->id, 'source_catalog_item_id' => $item->id, 'source_variant_id' => $variant->id, ]); } public function test_event_date_and_time_window_are_combined_in_the_same_and_group(): void { $item = $this->createTicketableItem('scheduled-meal'); $eventDate = EventDate::query()->create([ 'tenant_code' => $this->tenant->codigo, 'date' => '2026-08-20', 'time_start' => '09:00:00', 'time_end' => '23:59:59', ]); $timeWindow = ValidityTime::query()->create([ 'type' => ValidityTimeType::TimeWindow, 'start_time' => '22:00:00', 'end_time' => '02:00:00', ]); $schedule = Attribute::query()->create([ 'tenant_codigo' => $this->tenant->codigo, 'codigo' => 'schedule', 'nombre' => 'Schedule', 'type' => FieldType::Select, ]); $schedule->options()->create([ 'value' => 'night', 'label' => '22:00 - 02:00', 'validity_time_id' => $timeWindow->id, ]); $itemSchedule = $item->itemAttributes()->create([ 'attribute_id' => $schedule->id, ]); $inventory = Inventory::query()->create(); $variant = $item->variants()->create([ 'event_date_id' => $eventDate->id, 'inventory_id' => $inventory->id, ]); $variant->definitions()->create([ 'item_attribute_id' => $itemSchedule->id, 'value' => 'night', ]); $tickets = $this->service->generate($item, $this->user, 2, $variant->id); $this->assertCount(2, $tickets); $this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 1)); $this->assertTrue($tickets->every( fn ($ticket): bool => $ticket->validityGroups->sole()->validityTimes ->pluck('id') ->sort() ->values() ->all() === collect([$eventDate->validity_time_id, $timeWindow->id])->sort()->values()->all() )); $this->assertSame( '2026-08-20 22:00:00', $tickets->first()->getEffectiveStartsAt()->format('Y-m-d H:i:s'), ); $this->assertSame( '2026-08-20 23:59:59', $tickets->first()->getEffectiveExpiresAt()->format('Y-m-d H:i:s'), ); } public function test_a_multi_date_variant_generates_one_ticket_for_each_selected_date(): void { $item = $this->createTicketableItem('multi-date-pass'); $dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([ 'tenant_code' => $this->tenant->codigo, 'date' => $date, 'time_start' => '00:00:00', 'time_end' => '23:59:59', ])); $variant = $item->variants()->create([ 'inventory_id' => Inventory::query()->create()->id, ]); $variant->eventDates()->sync($dates->pluck('id')); $tickets = $this->service->generate($item, $this->user, 1, $variant->id); $tickets->each->loadMissing('validityGroups.validityTimes'); $this->assertCount(2, $tickets); $this->assertSame( $dates->pluck('validity_time_id')->all(), $tickets->map(fn ($ticket): int => $ticket->validityGroups->sole()->validityTimes->sole()->id)->all(), ); $this->assertSame( ['2026-08-20 00:00:00', '2026-08-21 00:00:00'], $tickets->map(fn ($ticket): string => $ticket->validityGroups->sole()->validityTimes->sole()->fixed_starts_at->format('Y-m-d H:i:s'))->all(), ); $this->assertSame( ['Multi-date-pass (20/08/2026)', 'Multi-date-pass (21/08/2026)'], $tickets->pluck('name')->all(), ); $this->assertSame([$variant->id], $tickets->pluck('source_variant_id')->unique()->values()->all()); } public function test_one_per_unit_policy_generates_one_ticket_covering_all_selected_dates(): void { $item = $this->createTicketableItem('multi-date-pass'); $item->update([ 'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit, ]); $dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([ 'tenant_code' => $this->tenant->codigo, 'date' => $date, 'time_start' => '00:00:00', 'time_end' => '23:59:59', ])); $variant = $item->variants()->create([ 'inventory_id' => Inventory::query()->create()->id, ]); $variant->eventDates()->sync($dates->pluck('id')); $tickets = $this->service->generate($item, $this->user, 2, $variant->id); $tickets->each->loadMissing('validityGroups.validityTimes'); $this->assertCount(2, $tickets); $this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 2)); $this->assertTrue($tickets->every( fn ($ticket): bool => $ticket->validityGroups->every( fn ($group): bool => $group->validityTimes->count() === 1 ) )); $this->assertEqualsCanonicalizing( $dates->pluck('validity_time_id')->all(), $tickets->flatMap(fn ($ticket) => $ticket->allValidityTimes())->pluck('id')->unique()->all(), ); $this->assertTrue($tickets->every( fn ($ticket): bool => $ticket->name === 'Multi-date-pass (20/08/2026, 21/08/2026)' )); $this->assertTrue($tickets->every( fn ($ticket): bool => $ticket->allValidityTimes() ->map(fn (ValidityTime $validityTime): array => [ $validityTime->fixed_starts_at->format('Y-m-d H:i:s'), $validityTime->fixed_expires_at->format('Y-m-d H:i:s'), ]) ->all() === [ ['2026-08-20 00:00:00', '2026-08-20 23:59:59'], ['2026-08-21 00:00:00', '2026-08-21 23:59:59'], ] )); } public function test_common_schedule_is_anded_into_each_alternative_event_date_group(): void { $item = $this->createTicketableItem('multi-date-lunch'); $item->update(['ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit]); $dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([ 'tenant_code' => $this->tenant->codigo, 'date' => $date, 'time_start' => '09:00:00', 'time_end' => '18:00:00', ])); $lunch = ValidityTime::query()->create([ 'type' => ValidityTimeType::TimeWindow, 'start_time' => '12:00:00', 'end_time' => '15:00:00', ]); $schedule = Attribute::query()->create([ 'tenant_codigo' => $this->tenant->codigo, 'codigo' => 'schedule', 'nombre' => 'Schedule', 'type' => FieldType::Select, ]); $schedule->options()->create([ 'value' => 'lunch', 'label' => 'Lunch', 'validity_time_id' => $lunch->id, ]); $itemSchedule = $item->itemAttributes()->create(['attribute_id' => $schedule->id]); $variant = $item->variants()->create([ 'inventory_id' => Inventory::query()->create()->id, ]); $variant->eventDates()->sync($dates->pluck('id')); $variant->definitions()->create([ 'item_attribute_id' => $itemSchedule->id, 'value' => 'lunch', ]); $ticket = $this->service->generate($item, $this->user, 1, $variant->id)->sole(); $ticket->loadMissing('validityGroups.validityTimes'); $this->assertCount(2, $ticket->validityGroups); $this->assertTrue($ticket->validityGroups->every( fn ($group): bool => $group->validityTimes->count() === 2 && $group->validityTimes->contains($lunch) )); $this->assertEqualsCanonicalizing( $dates->pluck('validity_time_id')->all(), $ticket->validityGroups ->flatMap->validityTimes ->reject(fn (ValidityTime $validityTime): bool => $validityTime->is($lunch)) ->pluck('id') ->all(), ); $this->assertSame('2026-08-20 12:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); $this->assertSame('2026-08-21 15:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); Carbon::setTestNow('2026-08-20 13:00:00'); $this->assertTrue($ticket->isValid()); Carbon::setTestNow('2026-08-20 16:00:00'); $this->assertFalse($ticket->isValid()); $this->assertFalse($ticket->is_expired); } public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void { Event::fake([TicketsAvailable::class]); $item = $this->createTicketableItem('regular-product'); $item->update(['has_tickets' => false]); $purchase = $this->createPurchase($item->fresh(), 1); $purchase->markAsPaid(); $this->assertSame(Purchase::STATUS_PAID, $purchase->status); $this->assertDatabaseCount('tickets', 0); Event::assertNotDispatched(TicketsAvailable::class); $this->actingAs($this->user, 'sanctum') ->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.tickets_count', 0) ->assertJsonPath('data.has_generated_tickets', false); } public function test_paid_status_is_confirmed_when_ticket_is_generated(): void { $item = $this->createTicketableItem('paid-ticket'); $purchase = $this->createPurchase($item, 1); $purchase->markAsPaid(); $this->assertSame(Purchase::STATUS_PAID, $purchase->fresh()->status); $this->assertDatabaseHas('tickets', [ 'source_catalog_item_id' => $item->id, 'user_id' => $purchase->user_id, ]); } private function createTicketableItem(string $slug): CatalogItem { return CatalogItem::query()->create([ 'tenant_code' => $this->tenant->codigo, 'slug' => $slug, 'nombre' => ucfirst($slug), 'descripcion' => "Descripción de {$slug}", 'precio' => 10, 'has_tickets' => true, ]); } private function createBundle(string $slug): CatalogItem { return CatalogItem::query()->create([ 'tenant_code' => $this->tenant->codigo, 'type' => CatalogItemType::Bundle, 'inventory_policy' => null, 'slug' => $slug, 'nombre' => ucfirst($slug), 'descripcion' => "Descripción de {$slug}", 'precio' => 20, ]); } private function createPurchase( CatalogItem $catalogItem, int $quantity, ?int $sourceVariantId = null, ): Purchase { $purchase = Purchase::query()->create([ 'tenant_codigo' => $this->tenant->codigo, 'user_id' => $this->user->id, 'status' => Purchase::STATUS_PENDING_PAYMENT, 'payment_method' => 'transfer', 'total' => 10 * $quantity, ]); $purchase->items()->create([ 'source_catalog_item_id' => $catalogItem->id, 'source_variant_id' => $sourceVariantId, 'image_attachment_id' => null, 'nombre' => $catalogItem->nombre, 'descripcion' => $catalogItem->descripcion, 'slug' => $catalogItem->slug, 'item_nombre' => $catalogItem->nombre, 'variant_attributes' => [], 'cantidad' => $quantity, 'precio_unitario' => 10, 'discount_total' => null, 'tax_total' => null, 'total' => 10 * $quantity, ]); return $purchase; } private function createTenant(): Tenant { $header = Attachment::query()->create([ 'path' => 'test/ticket-header.png', 'filename' => 'header.png', 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); $footer = Attachment::query()->create([ 'path' => 'test/ticket-footer.png', 'filename' => 'footer.png', 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); return Tenant::query()->create([ 'codigo' => 'ticket-tenant', 'nombre' => 'Ticket Tenant', 'dominio' => 'ticket.local', 'primary_color' => '#000000', 'secondary_color' => '#000000', 'danger_color' => '#000000', 'success_color' => '#000000', 'header_bg_color' => '#000000', 'footer_bg_color' => '#000000', 'header_logo_id' => $header->id, 'footer_logo_id' => $footer->id, ]); } }