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_rejects_an_item_when_its_maximum_use_date_was_reached(): void { $item = $this->createTicketableItem('expired', maximumUseDate: now()); $this->expectException(TicketGenerationException::class); $this->expectExceptionMessage('alcanzó su fecha máxima de uso'); $this->service->generate($item, $this->user); } 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(6, $tickets->where('source_catalog_item_id', $bundle->id)); $this->assertCount(4, $tickets->where('name', $first->nombre)); $this->assertCount(2, $tickets->where('name', $second->nombre)); } 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); $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'); $purchase = $this->createPurchase($item, 1, 1234); $purchase->markAsPaid(); $this->assertDatabaseHas('tickets', [ 'source_catalog_item_id' => $item->id, 'source_variant_id' => 1234, ]); } 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); } public function test_paid_status_is_rolled_back_when_ticket_generation_fails(): void { $item = $this->createTicketableItem('expired-paid-ticket', maximumUseDate: now()); $purchase = $this->createPurchase($item, 1); try { $purchase->markAsPaid(); $this->fail('La generación debería haber fallado.'); } catch (TicketGenerationException) { $this->assertSame(Purchase::STATUS_PENDING_PAYMENT, $purchase->fresh()->status); $this->assertDatabaseCount('tickets', 0); } } 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, ]); } }