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', now()->subHour(), now()->addDay(), ); $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->starts_at->equalTo($item->minimum_use_date)); $this->assertTrue($ticket->expires_at->equalTo($item->maximum_use_date)); } } 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_it_generates_a_ticket_when_its_maximum_use_date_was_reached(): void { $item = $this->createTicketableItem('expired', maximumUseDate: now()); $tickets = $this->service->generate($item, $this->user); $this->assertCount(1, $tickets); $this->assertTrue($tickets->first()->expires_at->equalTo($item->maximum_use_date)); } public function test_variant_dates_override_and_inherit_catalog_item_dates(): void { $item = $this->createTicketableItem( 'variant-dates', now()->subDay(), now()->addMonth(), ); $inventory = Inventory::query()->create(); $variant = $item->variants()->create([ 'inventory_id' => $inventory->id, 'maximum_use_date' => now()->addWeek(), ]); $ticket = $this->service ->generate($item, $this->user, sourceVariantId: $variant->id) ->firstOrFail(); $this->assertTrue($ticket->starts_at->equalTo($item->minimum_use_date)); $this->assertTrue($ticket->expires_at->equalTo($variant->maximum_use_date)); } public function test_it_generates_tickets_for_every_bundle_component_and_quantity(): void { $first = $this->createTicketableItem('first', maximumUseDate: now()->addDay()); $second = $this->createTicketableItem('second', maximumUseDate: now()->addDays(2)); $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_uses_its_variant_dates(): void { $component = $this->createTicketableItem( 'variant-component', now()->subDay(), now()->addMonth(), ); $inventory = Inventory::query()->create(); $variant = $component->variants()->create([ 'inventory_id' => $inventory->id, 'minimum_use_date' => now()->addDay(), 'maximum_use_date' => now()->addWeek(), ]); $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); $this->assertTrue($ticket->starts_at->equalTo($variant->minimum_use_date)); $this->assertTrue($ticket->expires_at->equalTo($variant->maximum_use_date)); } 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_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_maximum_use_date_was_reached(): void { $item = $this->createTicketableItem('expired-paid-ticket', maximumUseDate: now()); $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, mixed $minimumUseDate = null, mixed $maximumUseDate = null, ): 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, 'minimum_use_date' => $minimumUseDate, 'maximum_use_date' => $maximumUseDate, ]); } 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, ]); } }