setRawAttributes([ 'source_catalog_item_id' => '20', 'source_variant_id' => '30', 'used_at' => null, 'disabled_at' => '2026-09-10 10:00:00', 'cancelled_at' => null, 'refunded_at' => null, 'scanner_user_id' => '15', 'user_id' => '10', ]); $this->assertSame('tickets', $ticket->getTable()); $this->assertFalse($ticket->usesTimestamps()); $this->assertSame(20, $ticket->source_catalog_item_id); $this->assertSame(30, $ticket->source_variant_id); $this->assertNull($ticket->used_at); $this->assertSame('2026-09-10 10:00:00', $ticket->disabled_at->format('Y-m-d H:i:s')); $this->assertNull($ticket->cancelled_at); $this->assertNull($ticket->refunded_at); $this->assertSame(15, $ticket->scanner_user_id); $this->assertSame(10, $ticket->user_id); $this->assertInstanceOf(Tenant::class, $ticket->tenant()->getRelated()); $this->assertInstanceOf(User::class, $ticket->user()->getRelated()); $this->assertInstanceOf(User::class, $ticket->scannerUser()->getRelated()); $this->assertInstanceOf(CatalogItem::class, $ticket->sourceCatalogItem()->getRelated()); $this->assertInstanceOf(Variant::class, $ticket->sourceVariant()->getRelated()); } public function test_unused_ticket_without_validity_time_is_valid(): void { $ticket = new Ticket; $this->assertTrue($ticket->isValid()); $this->assertSame(Ticket::STATUS_ACTIVE, $ticket->status); } public function test_it_exposes_admin_action_capabilities(): void { $tenant = new Tenant([ 'allow_ticket_refund' => true, 'allow_ticket_total_refund' => true, ]); $active = (new Ticket)->setRelation('tenant', $tenant); $this->assertTrue($active->is_active()); $this->assertTrue($active->can_cancel()); $this->assertTrue($active->can_refund()); $active->used_at = now(); $this->assertFalse($active->is_active()); $this->assertFalse($active->can_cancel()); $this->assertFalse($active->can_refund()); } public function test_fixed_window_controls_ticket_validity(): void { Carbon::setTestNow('2026-07-21 10:00:00'); $ticket = $this->ticketWithValidityTime(new ValidityTime([ 'type' => ValidityTimeType::FixedWindow, 'fixed_starts_at' => now()->subHour(), 'fixed_expires_at' => now()->addHour(), ])); $this->assertTrue($ticket->isValid()); $this->assertFalse($ticket->is_expired); } public function test_time_window_is_resolved_for_current_date(): void { Carbon::setTestNow('2026-07-21 12:00:00'); $ticket = $this->ticketWithValidityGroups([[new ValidityTime([ 'type' => ValidityTimeType::TimeWindow, 'start_time' => '10:00:00', 'end_time' => '14:00:00', ])]]); $this->assertSame('2026-07-21 10:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); $this->assertSame('2026-07-21 14:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); $this->assertTrue($ticket->isValid()); } public function test_ticket_is_invalid_when_validity_time_expires(): void { Carbon::setTestNow('2026-07-21 10:00:00'); $ticket = $this->ticketWithValidityTime(new ValidityTime([ 'type' => ValidityTimeType::FixedWindow, 'fixed_expires_at' => now(), ])); $this->assertFalse($ticket->isValid()); $this->assertTrue($ticket->is_expired); $this->assertSame(Ticket::STATUS_EXPIRED, $ticket->status); } public function test_ticket_is_valid_when_any_validity_time_is_active(): void { Carbon::setTestNow('2026-07-21 10:00:00'); $ticket = $this->ticketWithValidityGroups([ [new ValidityTime([ 'type' => ValidityTimeType::FixedWindow, 'fixed_starts_at' => now()->subDays(2), 'fixed_expires_at' => now()->subDay(), ])], [new ValidityTime([ 'type' => ValidityTimeType::FixedWindow, 'fixed_starts_at' => now()->subHour(), 'fixed_expires_at' => now()->addHour(), ])], ]); $this->assertTrue($ticket->isValid()); $this->assertFalse($ticket->is_expired); $this->assertSame('2026-07-19 10:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); $this->assertSame('2026-07-21 11:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); } public function test_ticket_is_invalid_but_not_expired_between_validity_times(): void { Carbon::setTestNow('2026-07-21 10:00:00'); $ticket = $this->ticketWithValidityGroups([ [new ValidityTime([ 'type' => ValidityTimeType::FixedWindow, 'fixed_starts_at' => now()->subDays(2), 'fixed_expires_at' => now()->subDay(), ])], [new ValidityTime([ 'type' => ValidityTimeType::FixedWindow, 'fixed_starts_at' => now()->addDay(), 'fixed_expires_at' => now()->addDays(2), ])], ]); $this->assertFalse($ticket->isValid()); $this->assertFalse($ticket->is_expired); $this->assertSame(Ticket::STATUS_ACTIVE, $ticket->status); } public function test_used_ticket_is_invalid_and_not_reported_as_expired(): void { $ticket = $this->ticketWithValidityTime(new ValidityTime([ 'type' => ValidityTimeType::FixedWindow, 'fixed_expires_at' => now()->subHour(), ])); $ticket->used_at = now()->subMinute(); $this->assertFalse($ticket->is_valid); $this->assertFalse($ticket->is_expired); $this->assertTrue($ticket->is_used); $this->assertSame(Ticket::STATUS_USED, $ticket->status); } public function test_persisted_terminal_statuses_make_the_ticket_invalid(): void { foreach ([ 'disabled_at' => Ticket::STATUS_DISABLED, 'cancelled_at' => Ticket::STATUS_CANCELLED, 'refunded_at' => Ticket::STATUS_REFUNDED, ] as $timestamp => $status) { $ticket = new Ticket([$timestamp => now()]); $this->assertSame($status, $ticket->status); $this->assertFalse($ticket->is_valid); $this->assertFalse($ticket->is_expired); } } public function test_it_exposes_every_supported_status(): void { $this->assertSame([ Ticket::STATUS_ACTIVE, Ticket::STATUS_USED, Ticket::STATUS_EXPIRED, Ticket::STATUS_DISABLED, Ticket::STATUS_CANCELLED, Ticket::STATUS_REFUNDED, ], Ticket::statuses()); $this->assertSame('Inhabilitado', Ticket::statusLabel(Ticket::STATUS_DISABLED)); $this->assertSame('Cancelado', Ticket::statusLabel(Ticket::STATUS_CANCELLED)); $this->assertSame('Reembolsado', Ticket::statusLabel(Ticket::STATUS_REFUNDED)); } public function test_refunded_has_priority_when_multiple_state_timestamps_exist(): void { $ticket = new Ticket([ 'disabled_at' => now()->subHours(2), 'cancelled_at' => now()->subHour(), 'refunded_at' => now(), ]); $this->assertSame(Ticket::STATUS_REFUNDED, $ticket->status); } public function test_it_marks_tickets_with_terminal_statuses(): void { Carbon::setTestNow('2026-09-10 12:00:00'); $disabled = new Ticket; $disabled->markAsDisabled(); $cancelled = new Ticket; $cancelled->markAsCancelled(); $refunded = new Ticket; $refunded->markAsRefunded(); $this->assertSame(Ticket::STATUS_DISABLED, $disabled->status); $this->assertSame('2026-09-10 12:00:00', $disabled->disabled_at->format('Y-m-d H:i:s')); $this->assertSame(Ticket::STATUS_CANCELLED, $cancelled->status); $this->assertSame('2026-09-10 12:00:00', $cancelled->cancelled_at->format('Y-m-d H:i:s')); $this->assertSame(Ticket::STATUS_REFUNDED, $refunded->status); $this->assertSame('2026-09-10 12:00:00', $refunded->refunded_at->format('Y-m-d H:i:s')); } public function test_it_does_not_allow_a_transition_between_terminal_statuses(): void { $ticket = new Ticket; $ticket->markAsDisabled(); $this->expectException(ValidationException::class); $ticket->markAsRefunded(); } public function test_all_validity_times_in_the_same_group_must_be_active(): void { Carbon::setTestNow('2026-08-20 13:00:00'); $ticket = $this->ticketWithValidityGroups([[ new ValidityTime([ 'type' => ValidityTimeType::FixedWindow, 'fixed_starts_at' => '2026-08-20 09:00:00', 'fixed_expires_at' => '2026-08-20 18:00:00', ]), new ValidityTime([ 'type' => ValidityTimeType::TimeWindow, 'start_time' => '12:00:00', 'end_time' => '15:00:00', ]), ]]); $this->assertTrue($ticket->isValid()); $this->assertSame('2026-08-20 12:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); $this->assertSame('2026-08-20 15:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); Carbon::setTestNow('2026-08-20 16:00:00'); $this->assertFalse($ticket->isValid()); $this->assertTrue($ticket->is_expired); } private function ticketWithValidityTime(ValidityTime $validityTime): Ticket { return $this->ticketWithValidityGroups([[$validityTime]]); } /** @param array> $validityGroups */ private function ticketWithValidityGroups(array $validityGroups): Ticket { $ticket = new Ticket; $resolved = new ResolvedTicketValidity( collect($validityGroups)->map( fn (array $validityTimes): ResolvedValidityGroup => new ResolvedValidityGroup(collect($validityTimes)) ) ); $this->app->instance( TicketValidityResolver::class, new class($resolved) extends TicketValidityResolver { public function __construct(private readonly ResolvedTicketValidity $resolved) {} public function resolveTicket(Ticket $ticket): ResolvedTicketValidity { return $this->resolved; } } ); return $ticket; } }