createTicketReservationSchema(); } private function service(bool $failSecond = false): EntryReservationService { $generator = Mockery::mock(TicketGeneratorService::class); $generator->shouldReceive('generate')->andReturnUsing(function ($item, $user, $quantity, $variantId) use ($failSecond) { if ($failSecond && $item->id === 2) { throw new \RuntimeException('Ticket generation failed'); } return collect([Ticket::create([ 'tenant_code' => $item->tenant_code, 'user_id' => $user->id, 'source_catalog_item_id' => $item->id, 'source_variant_id' => $variantId, 'ticket' => (string) Str::uuid(), ])]); }); return new EntryReservationService($generator); } private function rows(): array { return [ ['catalog_item_id' => 1, 'variant_id' => 1, 'tipo_pago' => 'sin_cargo'], ['catalog_item_id' => 2, 'tipo_pago' => 'otro_metodo'], ]; } public function test_reserves_multiple_items_with_and_without_variants_and_replays_the_batch(): void { $service = $this->service(); $user = User::findOrFail(1); $key = (string) Str::uuid(); $result = $service->reserve($user, $key, $this->rows()); $this->assertSame([1, 2], $result->pluck('catalog_item_id')->all()); $this->assertSame([1, null], $result->pluck('variant_id')->all()); $this->assertSame(['0.00', '700.00'], $result->pluck('importe')->all()); $replay = $service->reserve($user, $key, array_reverse($this->rows())); $this->assertSame($result->pluck('id')->all(), $replay->pluck('id')->all()); $this->assertDatabaseCount('tickets', 2); $this->assertDatabaseCount('entry_reservations', 2); $this->assertDatabaseCount('reservation_batches', 1); $this->assertSame(1, Inventory::findOrFail(1)->entry_reserved_stock); $this->assertSame(1, Inventory::findOrFail(3)->entry_reserved_stock); } public function test_repeated_variants_use_stock_instead_of_assuming_a_unique_seat(): void { $service = $this->service(); $user = User::findOrFail(1); $row = $this->rows()[0]; $service->reserve($user, (string) Str::uuid(), [$row, $row]); $service->reserve($user, (string) Str::uuid(), [$row]); $this->assertSame(3, Inventory::findOrFail(1)->entry_reserved_stock); $this->expectException(ValidationException::class); $service->reserve($user, (string) Str::uuid(), [$row]); } public function test_custom_policy_validates_resolved_selections_inside_the_transaction_before_stock_changes(): void { $policy = Mockery::mock(EntryReservationPolicy::class); $policy->shouldReceive('validate')->once()->andReturnUsing(function (array $selections): void { $this->assertGreaterThan(0, DB::transactionLevel()); $this->assertSame(1, $selections[0]['item']->id); $this->assertSame(1, $selections[0]['variant']->id); $this->assertSame(2, $selections[1]['item']->id); $this->assertNull($selections[1]['variant']); $this->assertSame(0, $selections[0]['inventory']->entry_reserved_stock); throw ValidationException::withMessages(['rows.0.variant_id' => 'Custom selection rule.']); }); try { $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows(), $policy); $this->fail('Expected custom selection validation'); } catch (ValidationException $error) { $this->assertSame(['rows.0.variant_id' => ['Custom selection rule.']], $error->errors()); } $this->assertDatabaseCount('tickets', 0); $this->assertDatabaseCount('reservation_batches', 0); $this->assertSame(0, (int) Inventory::sum('entry_reserved_stock')); } public function test_idempotent_replays_do_not_validate_the_custom_policy_again(): void { $policy = Mockery::mock(EntryReservationPolicy::class); $policy->shouldReceive('validate')->once(); $service = $this->service(); $user = User::findOrFail(1); $key = (string) Str::uuid(); $result = $service->reserve($user, $key, $this->rows(), $policy); $replay = $service->reserve($user, $key, array_reverse($this->rows()), $policy); $this->assertSame($result->pluck('id')->all(), $replay->pluck('id')->all()); $this->assertDatabaseCount('tickets', 2); $this->assertSame(1, Inventory::findOrFail(1)->entry_reserved_stock); } public function test_shared_inventory_is_checked_for_the_entire_batch(): void { DB::table('catalog_items')->where('id', 2)->update(['inventory_id' => 1]); DB::table('inventories')->where('id', 1)->update(['real_stock' => 1]); try { $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); $this->fail('Expected insufficient shared stock'); } catch (ValidationException $error) { $this->assertSame(['rows.0.variant_id', 'rows.1.catalog_item_id'], array_keys($error->errors())); } $this->assertDatabaseCount('tickets', 0); $this->assertDatabaseCount('reservation_batches', 0); $this->assertSame(0, (int) Inventory::sum('entry_reserved_stock')); } public function test_ticket_failure_rolls_back_the_entire_batch(): void { try { $this->service(true)->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); $this->fail('Expected ticket failure'); } catch (\RuntimeException $error) { $this->assertSame('Ticket generation failed', $error->getMessage()); } $this->assertDatabaseCount('tickets', 0); $this->assertDatabaseCount('entry_reservations', 0); $this->assertDatabaseCount('reservation_batches', 0); $this->assertSame(0, (int) Inventory::sum('entry_reserved_stock')); } public function test_an_item_from_another_tenant_is_rejected(): void { $this->expectException(ValidationException::class); $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), [ ['catalog_item_id' => 3, 'tipo_pago' => 'sin_cargo'], ]); } public function test_a_variant_from_another_item_is_rejected(): void { $this->expectException(ValidationException::class); $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), [ ['catalog_item_id' => 2, 'variant_id' => 1, 'tipo_pago' => 'sin_cargo'], ]); } public function test_a_variant_is_required_when_the_item_has_variants(): void { $this->expectException(ValidationException::class); $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), [ ['catalog_item_id' => 1, 'tipo_pago' => 'sin_cargo'], ]); } public function test_disabled_variants_and_items_without_tickets_are_rejected(): void { DB::table('variantes')->where('id', 1)->update(['sales_disabled_at' => now()]); DB::table('catalog_items')->where('id', 2)->update(['has_tickets' => false]); try { $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); $this->fail('Expected unavailable selection'); } catch (ValidationException $error) { $this->assertSame(['rows.0.variant_id', 'rows.1.catalog_item_id'], array_keys($error->errors())); } $this->assertDatabaseCount('entry_reservations', 0); } public function test_the_active_event_limits_the_catalog_for_new_reservations(): void { DB::table('tenants')->where('codigo', 'ticket_shop')->update(['active_event_id' => 10]); DB::table('catalog_items')->where('id', 1)->update(['event_id' => 10]); DB::table('catalog_items')->where('id', 2)->update(['event_id' => 20]); $this->expectException(ValidationException::class); $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); } public function test_unlimited_items_can_emit_tickets_without_inventory(): void { $service = $this->service(); $user = User::findOrFail(1); $reservation = $service->reserve($user, (string) Str::uuid(), [ ['catalog_item_id' => 4, 'tipo_pago' => 'otro_metodo'], ])->sole(); $this->assertNull($reservation->inventory_id); $this->assertNull($reservation->variant_id); $this->assertSame('500.00', $reservation->importe); $service->cancel($user, $reservation->id); $this->assertSoftDeleted('entry_reservations', ['id' => $reservation->id]); } public function test_filters_and_exports_are_scoped_by_tenant_and_catalog_item(): void { $service = $this->service(); $user = User::findOrFail(1); $service->reserve($user, (string) Str::uuid(), $this->rows()); $service->reserve(User::findOrFail(2), (string) Str::uuid(), [ ['catalog_item_id' => 3, 'tipo_pago' => 'sin_cargo'], ]); $this->assertSame(2, $service->reservations($user)->total()); $this->assertSame(1, $service->reservations($user, ['catalog_item_id' => 2, 'tipo_pago' => 'otro_metodo'])->total()); $this->assertSame(1, $service->reservations($user, ['variant_id' => 1, 'per_page' => 1])->total()); $this->assertSame(2, $service->reservationsForExport($user)->count()); $this->assertSame(2, $service->reservationsForExport($user, ['catalog_item_id' => 2])->sole()->catalog_item_id); $this->assertSame(0, $service->reservations($user, ['catalog_item_id' => 3])->total()); } public function test_cancel_releases_stock_and_cancels_the_ticket_without_a_variant(): void { $service = $this->service(); $user = User::findOrFail(1); $reservation = $service->reserve($user, (string) Str::uuid(), [$this->rows()[1]])->sole(); $service->cancel($user, $reservation->id, 2); $this->assertSoftDeleted('entry_reservations', ['id' => $reservation->id]); $this->assertNotNull(Ticket::findOrFail($reservation->ticket_id)->cancelled_at); $this->assertSame(0, Inventory::findOrFail(3)->entry_reserved_stock); $this->assertSame(0, $service->reservations($user)->total()); } public function test_another_tenant_cannot_cancel_a_reservation(): void { $service = $this->service(); $reservation = $service->reserve(User::findOrFail(1), (string) Str::uuid(), [$this->rows()[0]])->sole(); $this->expectException(ModelNotFoundException::class); $service->cancel(User::findOrFail(2), $reservation->id); } public function test_cancellation_can_be_restricted_to_one_catalog_item_by_an_adapter(): void { $service = $this->service(); $user = User::findOrFail(1); $reservation = $service->reserve($user, (string) Str::uuid(), [$this->rows()[0]])->sole(); $this->expectException(ModelNotFoundException::class); $service->cancel($user, $reservation->id, 2); } public function test_cancellation_does_not_release_stock_for_a_used_ticket(): void { $service = $this->service(); $user = User::findOrFail(1); $reservation = $service->reserve($user, (string) Str::uuid(), [$this->rows()[0]])->sole(); Ticket::whereKey($reservation->ticket_id)->update(['used_at' => now()]); try { $service->cancel($user, $reservation->id); $this->fail('Expected active ticket requirement'); } catch (ValidationException $error) { $this->assertArrayHasKey('status', $error->errors()); } $this->assertSame(1, Inventory::findOrFail(1)->entry_reserved_stock); $this->assertDatabaseHas('entry_reservations', ['id' => $reservation->id, 'deleted_at' => null]); } public function test_a_reused_key_with_a_different_item_is_rejected(): void { $service = $this->service(); $user = User::findOrFail(1); $key = (string) Str::uuid(); $service->reserve($user, $key, [$this->rows()[1]]); try { $service->reserve($user, $key, [['catalog_item_id' => 4, 'tipo_pago' => 'otro_metodo']]); $this->fail('Expected idempotency conflict'); } catch (HttpException $error) { $this->assertSame(409, $error->getStatusCode()); } $this->assertDatabaseCount('tickets', 1); } public function test_clients_cannot_supply_the_price(): void { $this->expectException(ValidationException::class); $this->service()->reserve(User::findOrFail(1), (string) Str::uuid(), [ [...$this->rows()[0], 'importe' => 10], ]); } public function test_old_variant_only_batch_hashes_replay_including_cancelled_history(): void { $service = $this->service(); $user = User::findOrFail(1); $key = (string) Str::uuid(); $rows = [ ['catalog_item_id' => 1, 'variant_id' => 1, 'tipo_pago' => 'sin_cargo'], ['catalog_item_id' => 1, 'variant_id' => 2, 'tipo_pago' => 'otro_metodo'], ]; $result = $service->reserve($user, $key, $rows); $legacyRows = array_map(fn ($row) => ['variant_id' => $row['variant_id'], 'tipo_pago' => $row['tipo_pago']], $rows); DB::table('reservation_batches')->update([ 'request_hash' => hash('sha256', json_encode($legacyRows, JSON_THROW_ON_ERROR)), ]); $service->cancel($user, $result->first()->id); $replay = $service->reserve($user, $key, array_reverse($rows)); $this->assertSame($result->last()->id, $replay->sole()->id); $this->assertDatabaseCount('tickets', 2); $this->assertDatabaseCount('reservation_batches', 1); $this->assertSame(0, Inventory::findOrFail(1)->entry_reserved_stock); $this->assertSame(1, Inventory::findOrFail(2)->entry_reserved_stock); } public function test_old_batch_hashes_cannot_replay_with_a_different_catalog_item(): void { $service = $this->service(); $user = User::findOrFail(1); $key = (string) Str::uuid(); $service->reserve($user, $key, [$this->rows()[0]]); DB::table('reservation_batches')->update([ 'request_hash' => hash('sha256', json_encode([ ['variant_id' => 1, 'tipo_pago' => 'sin_cargo'], ], JSON_THROW_ON_ERROR)), ]); try { $service->reserve($user, $key, [ ['catalog_item_id' => 2, 'variant_id' => 1, 'tipo_pago' => 'sin_cargo'], ]); $this->fail('Expected legacy idempotency conflict'); } catch (HttpException $error) { $this->assertSame(409, $error->getStatusCode()); } $this->assertDatabaseCount('tickets', 1); } public function test_ticket_download_selection_uses_tenant_and_catalog_item_scope(): void { $service = $this->service(); $user = User::findOrFail(1); $reservation = $service->reserve($user, (string) Str::uuid(), [$this->rows()[1]])->sole(); $this->assertSame($reservation->ticket_id, $service->reservationTicket($user, $reservation->id, 2)->id); $this->expectException(ModelNotFoundException::class); $service->reservationTicket(User::findOrFail(2), $reservation->id); } public function test_real_ticket_generator_links_each_catalog_item_and_optional_variant(): void { $validity = Mockery::mock(TicketValidityResolver::class); $validity->shouldReceive('resolveVariant')->andReturn(ResolvedTicketValidity::unrestricted()); $service = new EntryReservationService(new TicketGeneratorService($validity)); $result = $service->reserve(User::findOrFail(1), (string) Str::uuid(), $this->rows()); foreach ($result as $reservation) { $this->assertDatabaseHas('tickets', [ 'id' => $reservation->ticket_id, 'tenant_code' => 'ticket_shop', 'source_catalog_item_id' => $reservation->catalog_item_id, 'source_variant_id' => $reservation->variant_id, 'source_purchase_item_id' => null, ]); } } }