From bb4495c6cc7abacd15d60cf66339c739d9ac7527 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 1 Oct 2026 08:56:35 -0300 Subject: [PATCH 1/7] feat(users): add event_id to users table and update related resources --- .../Resources/AdministratorResource.php | 1 + app/Domains/Core/Auth/Models/User.php | 14 ++++- .../Core/Auth/Resources/UserResource.php | 1 + ..._01_000000_add_event_id_to_users_table.php | 28 +++++++++ .../Auth/UserAuthorizationRelationsTest.php | 3 + .../Migrations/AddEventIdToUsersTest.php | 57 +++++++++++++++++++ 6 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 database/migrations/2026_10_01_000000_add_event_id_to_users_table.php create mode 100644 tests/Feature/Migrations/AddEventIdToUsersTest.php diff --git a/app/Domains/Core/Administrator/Resources/AdministratorResource.php b/app/Domains/Core/Administrator/Resources/AdministratorResource.php index 6abf6340..03c9b72e 100644 --- a/app/Domains/Core/Administrator/Resources/AdministratorResource.php +++ b/app/Domains/Core/Administrator/Resources/AdministratorResource.php @@ -18,6 +18,7 @@ class AdministratorResource extends JsonResource 'dni' => $this->dni, 'email' => $this->email, 'rol_codigo' => $this->rol_codigo, + 'event_id' => $this->event_id, 'role' => $this->whenLoaded('role', fn () => [ 'codigo' => $this->role?->codigo, 'nombre' => $this->role?->nombre, diff --git a/app/Domains/Core/Auth/Models/User.php b/app/Domains/Core/Auth/Models/User.php index b01b2125..7282794a 100644 --- a/app/Domains/Core/Auth/Models/User.php +++ b/app/Domains/Core/Auth/Models/User.php @@ -2,11 +2,12 @@ namespace App\Domains\Core\Auth\Models; +use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Models\Role; -use App\Domains\Commerce\Catalog\Models\Category; -use App\Domains\Ticketing\Event\Models\EventDateChangeView; use App\Domains\Core\Tenant\Models\Tenant; +use App\Domains\Ticketing\Event\Models\Event; +use App\Domains\Ticketing\Event\Models\EventDateChangeView; use App\Domains\Ticketing\Ticket\Models\ScanAttempt; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -20,7 +21,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; -#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])] +#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo', 'event_id'])] #[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])] class User extends Authenticatable { @@ -86,6 +87,12 @@ class User extends Authenticatable return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); } + /** @return BelongsTo */ + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + /** @return BelongsToMany */ public function scanCategories(): BelongsToMany { @@ -103,6 +110,7 @@ class User extends Authenticatable protected function casts(): array { return [ + 'event_id' => 'integer', 'email_verified_at' => 'datetime', 'password' => 'hashed', 'failed_login_attempts' => 'integer', diff --git a/app/Domains/Core/Auth/Resources/UserResource.php b/app/Domains/Core/Auth/Resources/UserResource.php index 8a8ddbac..cc8b4a99 100644 --- a/app/Domains/Core/Auth/Resources/UserResource.php +++ b/app/Domains/Core/Auth/Resources/UserResource.php @@ -24,6 +24,7 @@ class UserResource extends JsonResource 'telefono' => $this->telefono, 'rol_codigo' => $this->rol_codigo, 'tenant_codigo' => $this->tenant_codigo, + 'event_id' => $this->event_id, 'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories ->map(fn ($category) => [ 'id' => $category->id, diff --git a/database/migrations/2026_10_01_000000_add_event_id_to_users_table.php b/database/migrations/2026_10_01_000000_add_event_id_to_users_table.php new file mode 100644 index 00000000..f351604f --- /dev/null +++ b/database/migrations/2026_10_01_000000_add_event_id_to_users_table.php @@ -0,0 +1,28 @@ +foreignId('event_id') + ->nullable() + ->after('tenant_codigo') + ->constrained('events') + ->cascadeOnUpdate() + ->restrictOnDelete(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropForeign(['event_id']); + $table->dropColumn('event_id'); + }); + } +}; diff --git a/tests/Feature/Auth/UserAuthorizationRelationsTest.php b/tests/Feature/Auth/UserAuthorizationRelationsTest.php index d0719b65..e8ae4373 100644 --- a/tests/Feature/Auth/UserAuthorizationRelationsTest.php +++ b/tests/Feature/Auth/UserAuthorizationRelationsTest.php @@ -17,6 +17,7 @@ class UserAuthorizationRelationsTest extends TestCase $this->assertTrue(Schema::hasColumns('users', [ 'rol_codigo', 'tenant_codigo', + 'event_id', ])); } @@ -28,5 +29,7 @@ class UserAuthorizationRelationsTest extends TestCase $this->assertNull($user->tenant_codigo); $this->assertSame(RoleCode::User->value, $user->role->codigo); $this->assertNull($user->tenant); + $this->assertNull($user->event_id); + $this->assertNull($user->event); } } diff --git a/tests/Feature/Migrations/AddEventIdToUsersTest.php b/tests/Feature/Migrations/AddEventIdToUsersTest.php new file mode 100644 index 00000000..88de36cf --- /dev/null +++ b/tests/Feature/Migrations/AddEventIdToUsersTest.php @@ -0,0 +1,57 @@ +id(); + }); + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_codigo')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + DB::table('users')->insert(['id' => 1, 'tenant_codigo' => 'legacy']); + DB::table('events')->insert(['id' => 42]); + + $migration = require database_path('migrations/2026_10_01_000000_add_event_id_to_users_table.php'); + $migration->up(); + + $legacy = User::query()->findOrFail(1); + $this->assertNull($legacy->event_id); + $this->assertNull($legacy->event); + $this->assertSame('legacy', $legacy->tenant_codigo); + + $legacy->update(['event_id' => '42']); + $user = $legacy->fresh(); + $this->assertSame(42, $user->event_id); + $this->assertSame(42, $user->event->id); + $this->assertSame(42, UserResource::make($user)->resolve(new Request)['event_id']); + $this->assertSame(42, AdministratorResource::make($user)->resolve(new Request)['event_id']); + + try { + DB::table('events')->where('id', 42)->delete(); + $this->fail('An assigned event must not be deleted.'); + } catch (QueryException $exception) { + $this->assertStringContainsString('FOREIGN KEY', $exception->getMessage()); + } + + $migration->down(); + $this->assertFalse(Schema::hasColumn('users', 'event_id')); + $this->assertSame('legacy', DB::table('users')->where('id', 1)->value('tenant_codigo')); + } +} -- 2.49.1 From 31955f862d645d7d62d7b0f168f7d3d4ddbcebac Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 1 Oct 2026 09:20:43 -0300 Subject: [PATCH 2/7] feat(sale): implement event scope for sales, totals, and modifications; add tests for event-based access --- .../Services/PurchaseRefundSummaryService.php | 6 +- .../Controllers/AdminApp/SaleController.php | 23 +-- .../Sale/Services/AdminAppSaleService.php | 69 ++++--- .../Commerce/Sale/documentacion/README.md | 4 + .../Sale/AdminAppSaleEventScopeTest.php | 183 ++++++++++++++++++ 5 files changed, 243 insertions(+), 42 deletions(-) create mode 100644 tests/Feature/Sale/AdminAppSaleEventScopeTest.php diff --git a/app/Domains/Commerce/Purchase/Services/PurchaseRefundSummaryService.php b/app/Domains/Commerce/Purchase/Services/PurchaseRefundSummaryService.php index 9c113035..dffbb7aa 100644 --- a/app/Domains/Commerce/Purchase/Services/PurchaseRefundSummaryService.php +++ b/app/Domains/Commerce/Purchase/Services/PurchaseRefundSummaryService.php @@ -8,12 +8,14 @@ use Illuminate\Database\Eloquent\Builder; class PurchaseRefundSummaryService { - public function totalForTenant(Tenant $tenant): string + public function totalForTenant(Tenant $tenant, ?int $eventId = null): string { $total = TicketRefund::query() ->whereHas( 'purchaseItem.purchase', - fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo) + fn (Builder $query): Builder => $query + ->where('tenant_codigo', $tenant->codigo) + ->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId)) ) ->sum('amount'); diff --git a/app/Domains/Commerce/Sale/Controllers/AdminApp/SaleController.php b/app/Domains/Commerce/Sale/Controllers/AdminApp/SaleController.php index bb94f0dc..e9cdc514 100644 --- a/app/Domains/Commerce/Sale/Controllers/AdminApp/SaleController.php +++ b/app/Domains/Commerce/Sale/Controllers/AdminApp/SaleController.php @@ -32,10 +32,10 @@ class SaleController extends Controller $tenant = $request->user()->tenant()->firstOrFail(); return SaleResource::collection( - $this->saleService->sales($tenant, $request->validated()) + $this->saleService->sales($tenant, $request->validated(), $request->user()->event_id) )->additional([ - 'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant), - 'refunded_total' => $this->saleService->refundedTotal($tenant), + 'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant, $request->user()->event_id), + 'refunded_total' => $this->saleService->refundedTotal($tenant, $request->user()->event_id), ]); } @@ -43,7 +43,7 @@ class SaleController extends Controller { $tenant = $request->user()->tenant()->firstOrFail(); - return new SaleDetailResource($this->saleService->detail($tenant, $sale)); + return new SaleDetailResource($this->saleService->detail($tenant, $sale, $request->user()->event_id)); } public function tickets(Request $request, int $sale): AnonymousResourceCollection @@ -51,7 +51,7 @@ class SaleController extends Controller $tenant = $request->user()->tenant()->firstOrFail(); return SaleTicketResource::collection( - $this->saleService->tickets($tenant, $sale) + $this->saleService->tickets($tenant, $sale, $request->user()->event_id) ); } @@ -59,14 +59,14 @@ class SaleController extends Controller { $tenant = $request->user()->tenant()->firstOrFail(); - return new SaleResource($this->saleService->confirm($tenant, $sale)); + return new SaleResource($this->saleService->confirm($tenant, $sale, $request->user()->event_id)); } public function cancel(Request $request, int $sale): SaleResource { $tenant = $request->user()->tenant()->firstOrFail(); - return new SaleResource($this->saleService->cancel($tenant, $sale)); + return new SaleResource($this->saleService->cancel($tenant, $sale, $request->user()->event_id)); } public function modifications( @@ -76,6 +76,7 @@ class SaleController extends Controller $this->saleService->modifications( $request->user()->tenant()->firstOrFail(), $request->validated(), + $request->user()->event_id, ) ); } @@ -86,7 +87,7 @@ class SaleController extends Controller return $this->salePdfService->downloadSales( $tenant, - $this->saleService->salesForExport($tenant, $request->validated()), + $this->saleService->salesForExport($tenant, $request->validated(), $request->user()->event_id), $request->validated('timezone'), ); } @@ -97,7 +98,7 @@ class SaleController extends Controller return $this->salePdfService->downloadModifications( $tenant, - $this->saleService->modificationsForExport($tenant, $request->validated()), + $this->saleService->modificationsForExport($tenant, $request->validated(), $request->user()->event_id), $request->validated('timezone'), ); } @@ -108,7 +109,7 @@ class SaleController extends Controller return $this->saleExcelService->downloadSales( $tenant, - $this->saleService->salesForExport($tenant, $request->validated()), + $this->saleService->salesForExport($tenant, $request->validated(), $request->user()->event_id), $request->validated('timezone'), ); } @@ -120,7 +121,7 @@ class SaleController extends Controller return $this->saleExcelService->downloadModifications( $tenant, - $this->saleService->modificationsForExport($tenant, $request->validated()), + $this->saleService->modificationsForExport($tenant, $request->validated(), $request->user()->event_id), $request->validated('timezone'), ); } diff --git a/app/Domains/Commerce/Sale/Services/AdminAppSaleService.php b/app/Domains/Commerce/Sale/Services/AdminAppSaleService.php index 79a663e5..8265e7c5 100644 --- a/app/Domains/Commerce/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Commerce/Sale/Services/AdminAppSaleService.php @@ -2,7 +2,6 @@ namespace App\Domains\Commerce\Sale\Services; -use App\Shared\Logging\Models\ValueChange; use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Services\CheckoutService; use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService; @@ -10,6 +9,7 @@ use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; +use App\Shared\Logging\Models\ValueChange; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -21,19 +21,18 @@ class AdminAppSaleService protected PurchaseRefundSummaryService $refundSummaryService, ) {} - public function confirmedSalesTotal(Tenant $tenant): string + public function confirmedSalesTotal(Tenant $tenant, ?int $eventId = null): string { - $total = Purchase::query() - ->where('tenant_codigo', $tenant->codigo) + $total = $this->purchasesQuery($tenant, $eventId) ->where('status', Purchase::STATUS_PAID) ->sum('total'); return number_format((float) $total, 2, '.', ''); } - public function refundedTotal(Tenant $tenant): string + public function refundedTotal(Tenant $tenant, ?int $eventId = null): string { - return $this->refundSummaryService->totalForTenant($tenant); + return $this->refundSummaryService->totalForTenant($tenant, $eventId); } /** @@ -47,43 +46,42 @@ class AdminAppSaleService * } $filters * @return LengthAwarePaginator */ - public function sales(Tenant $tenant, array $filters = []): LengthAwarePaginator + public function sales(Tenant $tenant, array $filters = [], ?int $eventId = null): LengthAwarePaginator { - return $this->salesQuery($tenant, $filters) + return $this->salesQuery($tenant, $filters, $eventId) ->paginateFromRequest() ->withQueryString(); } - public function detail(Tenant $tenant, int $saleId): Purchase + public function detail(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase { - return Purchase::query() - ->where('tenant_codigo', $tenant->codigo) + return $this->purchasesQuery($tenant, $eventId) ->with('items') ->findOrFail($saleId); } /** @return Collection */ - public function tickets(Tenant $tenant, int $saleId): Collection + public function tickets(Tenant $tenant, int $saleId, ?int $eventId = null): Collection { - return $this->findForTenant($tenant, $saleId) + return $this->findForTenant($tenant, $saleId, $eventId) ->tickets() ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund']) ->orderBy('id') ->get(); } - public function confirm(Tenant $tenant, int $saleId): Purchase + public function confirm(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase { - $sale = $this->findForTenant($tenant, $saleId); + $sale = $this->findForTenant($tenant, $saleId, $eventId); return $this->saleForResponse( $this->checkoutService->confirmPaidPurchase($sale) ); } - public function cancel(Tenant $tenant, int $saleId): Purchase + public function cancel(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase { - $sale = $this->findForTenant($tenant, $saleId); + $sale = $this->findForTenant($tenant, $saleId, $eventId); return $this->saleForResponse( $this->checkoutService->cancelPurchaseFromAdmin($sale) @@ -94,18 +92,18 @@ class AdminAppSaleService * @param array $filters * @return Collection */ - public function salesForExport(Tenant $tenant, array $filters = []): Collection + public function salesForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection { - return $this->salesQuery($tenant, $filters)->get(); + return $this->salesQuery($tenant, $filters, $eventId)->get(); } /** * @param array $filters * @return LengthAwarePaginator */ - public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator + public function modifications(Tenant $tenant, array $filters = [], ?int $eventId = null): LengthAwarePaginator { - return $this->modificationsQuery($tenant, $filters) + return $this->modificationsQuery($tenant, $filters, $eventId) ->paginateFromRequest() ->withQueryString(); } @@ -114,13 +112,13 @@ class AdminAppSaleService * @param array $filters * @return Collection */ - public function modificationsForExport(Tenant $tenant, array $filters = []): Collection + public function modificationsForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection { - return $this->modificationsQuery($tenant, $filters)->get(); + return $this->modificationsQuery($tenant, $filters, $eventId)->get(); } /** @param array $filters */ - protected function salesQuery(Tenant $tenant, array $filters): Builder + protected function salesQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder { $sortColumns = [ 'id' => 'id', @@ -137,8 +135,7 @@ class AdminAppSaleService ? $requestedDirection : 'desc'; - return Purchase::query() - ->where('tenant_codigo', $tenant->codigo) + return $this->purchasesQuery($tenant, $eventId) ->when($filters['q'] ?? null, function (Builder $query, string $search): void { $term = trim($search); @@ -176,11 +173,18 @@ class AdminAppSaleService * @param array $filters * @return Builder */ - protected function modificationsQuery(Tenant $tenant, array $filters): Builder + protected function modificationsQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder { return ValueChange::query() ->where('tenant_code', $tenant->codigo) ->where('trackable_type', (new Purchase)->getMorphClass()) + ->when($eventId !== null, fn (Builder $query): Builder => $query->whereHasMorph( + 'trackable', + [Purchase::class], + fn (Builder $sales): Builder => $sales + ->where('tenant_codigo', $tenant->codigo) + ->where('event_id', $eventId), + )) ->when($filters['q'] ?? null, function (Builder $query, string $search): void { $term = trim($search); @@ -221,11 +225,18 @@ class AdminAppSaleService ->orderByDesc('id'); } - protected function findForTenant(Tenant $tenant, int $saleId): Purchase + protected function findForTenant(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase + { + return $this->purchasesQuery($tenant, $eventId) + ->findOrFail($saleId); + } + + /** @return Builder */ + protected function purchasesQuery(Tenant $tenant, ?int $eventId): Builder { return Purchase::query() ->where('tenant_codigo', $tenant->codigo) - ->findOrFail($saleId); + ->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId)); } protected function saleForResponse(Purchase $sale): Purchase diff --git a/app/Domains/Commerce/Sale/documentacion/README.md b/app/Domains/Commerce/Sale/documentacion/README.md index 2c1b5fe8..9c41bff3 100644 --- a/app/Domains/Commerce/Sale/documentacion/README.md +++ b/app/Domains/Commerce/Sale/documentacion/README.md @@ -29,4 +29,8 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel. +Cuando el usuario autenticado tiene `event_id`, el controlador lo pasa al servicio como alcance obligatorio para ventas, totales, historial y exportaciones. El alcance se combina con el tenant y no se obtiene de los filtros enviados por el cliente. Los administradores sin `event_id` conservan el alcance del tenant. + +El detalle, los tickets de una venta, la confirmación y la cancelación buscan la compra dentro del mismo alcance. Una venta de otro evento o sin evento devuelve 404 para un administrador con `event_id`, antes de ejecutar cualquier acción en `CheckoutService`. + El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta. diff --git a/tests/Feature/Sale/AdminAppSaleEventScopeTest.php b/tests/Feature/Sale/AdminAppSaleEventScopeTest.php new file mode 100644 index 00000000..2dfe7f9b --- /dev/null +++ b/tests/Feature/Sale/AdminAppSaleEventScopeTest.php @@ -0,0 +1,183 @@ +id(); + $table->string('codigo'); + }); + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->softDeletes(); + }); + Schema::create('compras', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_codigo'); + $table->unsignedBigInteger('event_id')->nullable(); + $table->string('nombre_apellido'); + $table->string('status'); + $table->decimal('total', 12, 2); + $table->timestamps(); + }); + Schema::create('compra_items', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('compra_id'); + $table->integer('cantidad'); + }); + Schema::create('tickets', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('source_purchase_item_id'); + }); + Schema::create('ticket_refunds', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('purchase_item_id'); + $table->decimal('amount', 12, 2); + }); + Schema::create('value_changes', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('trackable_type'); + $table->unsignedBigInteger('trackable_id'); + $table->string('attribute'); + $table->string('old_value'); + $table->string('new_value'); + $table->timestamp('changed_at'); + $table->string('actor_type'); + $table->unsignedBigInteger('user_id')->nullable(); + }); + + DB::table('tenants')->insert(['codigo' => 'onticket']); + foreach ([1 => ['onticket', 10], 2 => ['onticket', 20], 3 => ['onticket', null], 4 => ['other', 10]] as $id => [$tenant, $event]) { + DB::table('compras')->insert([ + 'id' => $id, + 'tenant_codigo' => $tenant, + 'event_id' => $event, + 'nombre_apellido' => 'Cliente', + 'status' => Purchase::STATUS_PAID, + 'total' => $id * 100, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('compra_items')->insert(['id' => $id, 'compra_id' => $id, 'cantidad' => 1]); + DB::table('ticket_refunds')->insert(['purchase_item_id' => $id, 'amount' => $id * 10]); + DB::table('value_changes')->insert([ + 'id' => $id, + 'tenant_code' => $tenant, + 'trackable_type' => (new Purchase)->getMorphClass(), + 'trackable_id' => $id, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_PENDING_PAYMENT, + 'new_value' => Purchase::STATUS_PAID, + 'changed_at' => now(), + 'actor_type' => 'system', + ]); + } + $this->actingAsAdministrator(10); + } + + public function test_list_totals_and_filters_cannot_escape_the_authenticated_event(): void + { + $this->getJson('/api/v1/adminapp/tenant/sales?event_id=20&q=Cliente') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', 1) + ->assertJsonPath('confirmed_sales_total', '100.00') + ->assertJsonPath('refunded_total', '10.00'); + $this->getJson('/api/v1/adminapp/tenant/sales?id=2')->assertOk()->assertJsonCount(0, 'data'); + $this->getJson('/api/v1/adminapp/tenant/sales/modifications?q=Cliente') + ->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.sale_id', 1); + } + + public function test_unscoped_administrators_keep_access_to_all_sales_in_their_tenant(): void + { + $this->actingAsAdministrator(null); + $this->getJson('/api/v1/adminapp/tenant/sales')->assertOk()->assertJsonCount(3, 'data') + ->assertJsonPath('confirmed_sales_total', '600.00')->assertJsonPath('refunded_total', '60.00'); + $this->getJson('/api/v1/adminapp/tenant/sales/modifications')->assertOk()->assertJsonCount(3, 'data'); + $this->getJson('/api/v1/adminapp/tenant/sales/2')->assertOk(); + $this->getJson('/api/v1/adminapp/tenant/sales/3')->assertOk(); + } + + public function test_foreign_and_unassigned_sales_are_inaccessible_before_any_checkout_action(): void + { + $this->mock(CheckoutService::class, function (MockInterface $mock): void { + $mock->shouldNotReceive('confirmPaidPurchase'); + $mock->shouldNotReceive('cancelPurchaseFromAdmin'); + }); + foreach ([2, 3, 4] as $id) { + $this->getJson("/api/v1/adminapp/tenant/sales/{$id}")->assertNotFound(); + $this->getJson("/api/v1/adminapp/tenant/sales/{$id}/tickets")->assertNotFound(); + $this->postJson("/api/v1/adminapp/tenant/sales/{$id}/confirm", ['event_id' => 20])->assertNotFound(); + $this->postJson("/api/v1/adminapp/tenant/sales/{$id}/cancel", ['event_id' => 20])->assertNotFound(); + } + $this->assertSame(Purchase::STATUS_PAID, DB::table('compras')->where('id', 2)->value('status')); + } + + public function test_own_event_allows_detail_tickets_and_checkout_actions(): void + { + // Empty snapshots keep this fixture focused on authorization. + DB::table('compra_items')->where('compra_id', 1)->delete(); + $this->mock(CheckoutService::class, function (MockInterface $mock): void { + foreach (['confirmPaidPurchase', 'cancelPurchaseFromAdmin'] as $method) { + $mock->shouldReceive($method)->once()->withArgs(fn (Purchase $sale): bool => $sale->id === 1) + ->andReturnUsing(fn (Purchase $sale): Purchase => $sale); + } + }); + $this->getJson('/api/v1/adminapp/tenant/sales/1')->assertOk()->assertJsonPath('data.id', 1); + $this->getJson('/api/v1/adminapp/tenant/sales/1/tickets')->assertOk(); + $this->postJson('/api/v1/adminapp/tenant/sales/1/confirm')->assertOk()->assertJsonPath('data.id', 1); + $this->postJson('/api/v1/adminapp/tenant/sales/1/cancel')->assertOk()->assertJsonPath('data.id', 1); + } + + public function test_pdf_and_excel_exports_only_receive_sales_and_history_for_the_own_event(): void + { + foreach ([AdminAppSalePdfService::class, AdminAppSaleExcelService::class] as $class) { + $this->mock($class, function (MockInterface $mock) use ($class): void { + foreach (['downloadSales', 'downloadModifications'] as $method) { + $mock->shouldReceive($method)->once()->withArgs( + fn ($tenant, Collection $rows, $timezone): bool => $tenant->codigo === 'onticket' + && $rows->pluck('id')->all() === [1] && $timezone === 'UTC' + )->andReturn($class === AdminAppSalePdfService::class + ? response('pdf') : new StreamedResponse(fn () => print ('excel'))); + } + }); + } + foreach (['pdf', 'excel', 'modifications/pdf', 'modifications/excel'] as $path) { + $this->getJson("/api/v1/adminapp/tenant/sales/{$path}?timezone=UTC&event_id=20")->assertOk(); + } + } + + private function actingAsAdministrator(?int $eventId): void + { + $user = new User; + $user->setRawAttributes([ + 'id' => 1, + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => 'onticket', + 'event_id' => $eventId, + ]); + Sanctum::actingAs($user); + } +} -- 2.49.1 From c7afb701966b370e8fcfd9cb8865e165cf1161f2 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 1 Oct 2026 09:25:25 -0300 Subject: [PATCH 3/7] feat(staff): implement event scope for staff management; update controllers, services, and resources to handle event_id; add tests for event-based access --- .../AdminAppAdministratorController.php | 3 + .../Services/AdministratorService.php | 24 +-- .../Administrator/documentacion/README.md | 2 + .../Controllers/AdminAppStaffController.php | 6 +- .../Core/Staff/Resources/StaffResource.php | 1 + .../Core/Staff/Services/StaffService.php | 28 ++-- .../Core/Staff/documentacion/README.md | 2 + tests/Feature/Staff/StaffEventScopeTest.php | 158 ++++++++++++++++++ 8 files changed, 199 insertions(+), 25 deletions(-) create mode 100644 tests/Feature/Staff/StaffEventScopeTest.php diff --git a/app/Domains/Core/Administrator/Controllers/AdminAppAdministratorController.php b/app/Domains/Core/Administrator/Controllers/AdminAppAdministratorController.php index 4c3b6e80..b98e866e 100644 --- a/app/Domains/Core/Administrator/Controllers/AdminAppAdministratorController.php +++ b/app/Domains/Core/Administrator/Controllers/AdminAppAdministratorController.php @@ -20,6 +20,7 @@ class AdminAppAdministratorController extends Controller return AdministratorResource::collection($this->administratorService->list( $request->user()->tenant()->firstOrFail(), $request->string('search')->trim()->toString() ?: null, + $request->user()->event_id, )); } @@ -28,6 +29,7 @@ class AdminAppAdministratorController extends Controller return AdministratorResource::make($this->administratorService->create( $request->user()->tenant()->firstOrFail(), $request->validated(), + $request->user()->event_id, )); } @@ -37,6 +39,7 @@ class AdminAppAdministratorController extends Controller $request->user()->tenant()->firstOrFail(), $administrator, $request->validated(), + $request->user()->event_id, )); } diff --git a/app/Domains/Core/Administrator/Services/AdministratorService.php b/app/Domains/Core/Administrator/Services/AdministratorService.php index e0a786bf..043a2674 100644 --- a/app/Domains/Core/Administrator/Services/AdministratorService.php +++ b/app/Domains/Core/Administrator/Services/AdministratorService.php @@ -19,9 +19,9 @@ class AdministratorService public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {} /** @return Collection */ - public function list(Tenant $tenant, ?string $search = null): Collection + public function list(Tenant $tenant, ?string $search = null, ?int $eventId = null): Collection { - return $this->query($tenant)->with('role') + return $this->query($tenant, $eventId)->with('role') ->when($search, fn (Builder $query, string $search) => $query->where(function (Builder $query) use ($search): void { $query->where('nombre_apellido', 'like', "%{$search}%") ->orWhere('dni', 'like', "%{$search}%") @@ -31,14 +31,15 @@ class AdministratorService } /** @param array $data */ - public function create(Tenant $tenant, array $data): User + public function create(Tenant $tenant, array $data, ?int $eventId = null): User { - return DB::transaction(function () use ($tenant, $data): User { + return DB::transaction(function () use ($tenant, $data, $eventId): User { $administrator = User::query()->create([ ...$this->attributes($data), 'password' => Str::random(64), 'rol_codigo' => RoleCode::AdminApp->value, 'tenant_codigo' => $tenant->codigo, + 'event_id' => $eventId, ]); $this->resetPasswordAttemptService->createForAdminAppEmail( $administrator->email, @@ -50,10 +51,10 @@ class AdministratorService } /** @param array $data */ - public function update(Tenant $tenant, int $administratorId, array $data): User + public function update(Tenant $tenant, int $administratorId, array $data, ?int $eventId = null): User { - return DB::transaction(function () use ($tenant, $administratorId, $data): User { - $administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId); + return DB::transaction(function () use ($tenant, $administratorId, $data, $eventId): User { + $administrator = $this->query($tenant, $eventId)->lockForUpdate()->findOrFail($administratorId); $administrator->update($this->attributes($data)); return $administrator->load('role'); @@ -66,11 +67,11 @@ class AdministratorService // Serialize deletions for this tenant, including requests already authenticated // when another administrator removes their account. Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail(); - $administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId); + $administrator = $this->query($tenant, $actor->event_id)->lockForUpdate()->findOrFail($administratorId); if ($administrator->is($actor)) { throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']); } - $activeAdministrators = $this->query($tenant)->lockForUpdate()->get(); + $activeAdministrators = $this->query($tenant, $actor->event_id)->lockForUpdate()->get(); if ($activeAdministrators->count() <= 1) { throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']); } @@ -80,10 +81,11 @@ class AdministratorService }); } - private function query(Tenant $tenant): Builder + private function query(Tenant $tenant, ?int $eventId = null): Builder { return User::query()->where('tenant_codigo', $tenant->codigo) - ->where('rol_codigo', RoleCode::AdminApp->value); + ->where('rol_codigo', RoleCode::AdminApp->value) + ->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId)); } /** @param array $data diff --git a/app/Domains/Core/Administrator/documentacion/README.md b/app/Domains/Core/Administrator/documentacion/README.md index ba506358..8c3ec561 100644 --- a/app/Domains/Core/Administrator/documentacion/README.md +++ b/app/Domains/Core/Administrator/documentacion/README.md @@ -55,6 +55,8 @@ No agrega tablas ni migraciones. No modifica el CRUD de escáneres ni el fronten ## Verificación +Cuando el actor tiene `event_id`, los nuevos administradores heredan su evento y el listado, la búsqueda, la edición y la baja se limitan a ese evento dentro del tenant. La comprobación de administradores activos también usa ese alcance. El evento se toma del usuario autenticado, no del cuerpo de la solicitud; sin `event_id` se conserva el comportamiento por tenant. + `php artisan test tests/Feature/Administrator/AdministratorControllerTest.php` Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de diff --git a/app/Domains/Core/Staff/Controllers/AdminAppStaffController.php b/app/Domains/Core/Staff/Controllers/AdminAppStaffController.php index 1f1d69cb..d4ef1a45 100644 --- a/app/Domains/Core/Staff/Controllers/AdminAppStaffController.php +++ b/app/Domains/Core/Staff/Controllers/AdminAppStaffController.php @@ -26,6 +26,7 @@ class AdminAppStaffController extends Controller return StaffResource::collection($this->staffService->list( $request->user()->tenant()->firstOrFail(), $request->string('search')->trim()->toString() ?: null, + $request->user()->event_id, )); } @@ -34,6 +35,7 @@ class AdminAppStaffController extends Controller return StaffResource::make($this->staffService->create( $request->user()->tenant()->firstOrFail(), $request->validated(), + $request->user()->event_id, )); } @@ -43,12 +45,13 @@ class AdminAppStaffController extends Controller $request->user()->tenant()->firstOrFail(), $staff, $request->validated(), + $request->user()->event_id, )); } public function destroy(Request $request, int $staff): Response { - $this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff); + $this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff, $request->user()->event_id); return response()->noContent(); } @@ -60,6 +63,7 @@ class AdminAppStaffController extends Controller $scanner = $this->staffService->find( $request->user()->tenant()->firstOrFail(), $staff, + $request->user()->event_id, ); return ScanAttemptResource::collection( diff --git a/app/Domains/Core/Staff/Resources/StaffResource.php b/app/Domains/Core/Staff/Resources/StaffResource.php index 5892a514..0e758799 100644 --- a/app/Domains/Core/Staff/Resources/StaffResource.php +++ b/app/Domains/Core/Staff/Resources/StaffResource.php @@ -18,6 +18,7 @@ class StaffResource extends JsonResource 'dni' => $this->dni, 'email' => $this->email, 'rol_codigo' => $this->rol_codigo, + 'event_id' => $this->event_id, 'role' => $this->whenLoaded('role', fn () => [ 'codigo' => $this->role?->codigo, 'nombre' => $this->role?->nombre, diff --git a/app/Domains/Core/Staff/Services/StaffService.php b/app/Domains/Core/Staff/Services/StaffService.php index 89ca950b..7a7d624b 100644 --- a/app/Domains/Core/Staff/Services/StaffService.php +++ b/app/Domains/Core/Staff/Services/StaffService.php @@ -2,11 +2,11 @@ namespace App\Domains\Core\Staff\Services; +use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Core\Auth\Models\ResetPasswordAttempt; use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Services\ResetPasswordAttemptService; use App\Domains\Core\Authorization\Enums\RoleCode; -use App\Domains\Commerce\Catalog\Models\Category; use App\Domains\Core\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -22,9 +22,9 @@ class StaffService ) {} /** @return Collection */ - public function list(Tenant $tenant, ?string $search = null): Collection + public function list(Tenant $tenant, ?string $search = null, ?int $eventId = null): Collection { - return $this->staffQuery($tenant) + return $this->staffQuery($tenant, $eventId) ->with(['role', 'scanCategories' => fn ($query) => $query->orderBy('nombre')]) ->when($search, function (Builder $query, string $search): void { $query->where(function (Builder $query) use ($search): void { @@ -52,18 +52,19 @@ class StaffService } /** @param array $data */ - public function create(Tenant $tenant, array $data): User + public function create(Tenant $tenant, array $data, ?int $eventId = null): User { $categoryIds = $this->categoryIdsFor($tenant, $data); $this->assertCategoriesBelongToTenant($tenant, $categoryIds); - return DB::transaction(function () use ($tenant, $data, $categoryIds): User { + return DB::transaction(function () use ($tenant, $data, $categoryIds, $eventId): User { $staff = User::query()->create([ ...Arr::only($data, ['nombre_apellido', 'dni', 'email']), 'email' => mb_strtolower(trim((string) $data['email'])), 'password' => Str::random(64), 'rol_codigo' => RoleCode::Scanner->value, 'tenant_codigo' => $tenant->codigo, + 'event_id' => $eventId, ]); $staff->scanCategories()->sync($categoryIds); $this->resetPasswordAttemptService->createForScannerEmail( @@ -76,9 +77,9 @@ class StaffService } /** @param array $data */ - public function update(Tenant $tenant, int $staffId, array $data): User + public function update(Tenant $tenant, int $staffId, array $data, ?int $eventId = null): User { - $staff = $this->find($tenant, $staffId); + $staff = $this->find($tenant, $staffId, $eventId); $categoryIds = $this->categoryIdsFor($tenant, $data); $this->assertCategoriesBelongToTenant($tenant, $categoryIds); @@ -92,9 +93,9 @@ class StaffService }); } - public function delete(Tenant $tenant, int $staffId): void + public function delete(Tenant $tenant, int $staffId, ?int $eventId = null): void { - $staff = $this->find($tenant, $staffId); + $staff = $this->find($tenant, $staffId, $eventId); DB::transaction(function () use ($staff): void { $staff->tokens()->delete(); @@ -102,16 +103,17 @@ class StaffService }); } - public function find(Tenant $tenant, int $staffId): User + public function find(Tenant $tenant, int $staffId, ?int $eventId = null): User { - return $this->staffQuery($tenant)->findOrFail($staffId); + return $this->staffQuery($tenant, $eventId)->findOrFail($staffId); } - private function staffQuery(Tenant $tenant): Builder + private function staffQuery(Tenant $tenant, ?int $eventId = null): Builder { return User::query() ->where('tenant_codigo', $tenant->codigo) - ->where('rol_codigo', RoleCode::Scanner->value); + ->where('rol_codigo', RoleCode::Scanner->value) + ->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId)); } /** diff --git a/app/Domains/Core/Staff/documentacion/README.md b/app/Domains/Core/Staff/documentacion/README.md index f72d4a63..49539c01 100644 --- a/app/Domains/Core/Staff/documentacion/README.md +++ b/app/Domains/Core/Staff/documentacion/README.md @@ -18,3 +18,5 @@ Recurso REST `/v1/adminapp/tenant/staff`, excepto detalle individual, protegido ## Dependencias y reglas Usa `Auth/User` como entidad de personal, `Authorization` para su rol, `Catalog/Category` para asignaciones y `Tenant` para aislamiento. Toda búsqueda, edición o borrado debe comprobar que el usuario pertenece al tenant autenticado. + +Si el administrador autenticado tiene `event_id`, el alta de scanners hereda ese valor y las búsquedas, ediciones, bajas y consultas de intentos de escaneo se limitan a personal del mismo evento. El cliente no puede elegir ni cambiar el evento. Sin `event_id`, se mantiene el alcance por tenant. diff --git a/tests/Feature/Staff/StaffEventScopeTest.php b/tests/Feature/Staff/StaffEventScopeTest.php new file mode 100644 index 00000000..2359144c --- /dev/null +++ b/tests/Feature/Staff/StaffEventScopeTest.php @@ -0,0 +1,158 @@ +id(); + $table->string('codigo'); + $table->boolean('scanner_category_validation_enabled')->default(false); + }); + Schema::create('roles', function (Blueprint $table): void { + $table->id(); + $table->string('codigo'); + $table->string('nombre'); + }); + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->string('rol_codigo'); + $table->string('tenant_codigo'); + $table->unsignedBigInteger('event_id')->nullable(); + $table->string('nombre_apellido'); + $table->string('dni'); + $table->string('email'); + $table->string('active_email')->nullable(); + $table->string('password')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Schema::create('categorias', function (Blueprint $table): void { + $table->id(); + $table->string('nombre'); + $table->string('tenant_code')->nullable(); + $table->unsignedBigInteger('categoria_id')->nullable(); + }); + Schema::create('catalog_items', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('category_id'); + $table->string('tenant_code'); + $table->softDeletes(); + }); + Schema::create('category_scanners', function (Blueprint $table): void { + $table->unsignedBigInteger('user_id'); + $table->unsignedBigInteger('categoria_id'); + $table->timestamps(); + }); + Schema::create('personal_access_tokens', function (Blueprint $table): void { + $table->id(); + $table->string('tokenable_type'); + $table->unsignedBigInteger('tokenable_id'); + }); + + DB::table('tenants')->insert(['codigo' => 'onticket']); + foreach (['adminapp', 'scanner'] as $role) { + DB::table('roles')->insert(['codigo' => $role, 'nombre' => $role]); + foreach ([10, 20, null] as $eventId) { + $this->insertUser($role, 'onticket', $eventId); + } + $this->insertUser($role, 'other', 10); + } + Sanctum::actingAs(User::query()->findOrFail(1)); + } + + public function test_lists_and_searches_are_restricted_to_the_authenticated_event(): void + { + foreach (['administrators' => 1, 'staff' => 5] as $path => $id) { + foreach (['', '?search=Persona&event_id=20'] as $query) { + $this->getJson("/api/v1/adminapp/tenant/{$path}{$query}") + ->assertOk()->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $id)->assertJsonPath('data.0.event_id', 10); + } + } + } + + public function test_creation_inherits_the_actor_event_even_if_the_client_supplies_another(): void + { + $this->mock(ResetPasswordAttemptService::class, function (MockInterface $mock): void { + $mock->shouldReceive('createForAdminAppEmail')->once(); + $mock->shouldReceive('createForScannerEmail')->once(); + }); + foreach (['administrators' => 'adminapp', 'staff' => 'scanner'] as $path => $role) { + $this->postJson("/api/v1/adminapp/tenant/{$path}", [ + ...$this->payload("new-{$role}@example.com"), 'event_id' => 20, + ])->assertSuccessful()->assertJsonPath('data.event_id', 10); + $this->assertDatabaseHas('users', ['email' => "new-{$role}@example.com", 'event_id' => 10, 'rol_codigo' => $role]); + } + } + + public function test_foreign_and_unassigned_staff_cannot_be_edited_deleted_or_inspected(): void + { + foreach (['administrators' => [2, 3, 4], 'staff' => [6, 7, 8]] as $path => $ids) { + foreach ($ids as $id) { + $this->putJson("/api/v1/adminapp/tenant/{$path}/{$id}", $this->payload("update-{$id}@example.com"))->assertNotFound(); + $this->deleteJson("/api/v1/adminapp/tenant/{$path}/{$id}")->assertNotFound(); + $this->assertDatabaseHas('users', ['id' => $id, 'deleted_at' => null, 'email' => "person-{$id}@example.com"]); + if ($path === 'staff') { + $this->getJson("/api/v1/adminapp/tenant/staff/{$id}/scan-attempts")->assertNotFound(); + } + } + } + } + + public function test_own_staff_can_be_edited_and_deleted_without_changing_its_event(): void + { + $adminId = $this->insertUser('adminapp', 'onticket', 10); + foreach (['administrators' => $adminId, 'staff' => 5] as $path => $id) { + $this->putJson("/api/v1/adminapp/tenant/{$path}/{$id}", [ + ...$this->payload("updated-{$id}@example.com"), 'event_id' => 20, + ])->assertOk()->assertJsonPath('data.event_id', 10); + $this->deleteJson("/api/v1/adminapp/tenant/{$path}/{$id}")->assertNoContent(); + $this->assertSoftDeleted('users', ['id' => $id]); + } + } + + public function test_unscoped_actor_keeps_tenant_lists_and_creates_without_an_event(): void + { + Sanctum::actingAs(User::query()->findOrFail(3)); + $this->mock(ResetPasswordAttemptService::class, function (MockInterface $mock): void { + $mock->shouldReceive('createForAdminAppEmail')->once(); + $mock->shouldReceive('createForScannerEmail')->once(); + }); + foreach (['administrators', 'staff'] as $path) { + $this->getJson("/api/v1/adminapp/tenant/{$path}")->assertOk()->assertJsonCount(3, 'data'); + $this->postJson("/api/v1/adminapp/tenant/{$path}", $this->payload("legacy-{$path}@example.com")) + ->assertSuccessful()->assertJsonPath('data.event_id', null); + } + } + + private function insertUser(string $role, string $tenant, ?int $eventId): int + { + $id = DB::table('users')->count() + 1; + + return DB::table('users')->insertGetId([ + 'id' => $id, 'rol_codigo' => $role, 'tenant_codigo' => $tenant, + 'event_id' => $eventId, 'nombre_apellido' => 'Persona', 'dni' => '12345678', + 'email' => "person-{$id}@example.com", 'active_email' => "person-{$id}@example.com", + ]); + } + + private function payload(string $email): array + { + return ['nombre_apellido' => 'Persona editada', 'dni' => '87654321', 'email' => $email]; + } +} -- 2.49.1 From c975b5d51d7a9a1525220d15ae916a61a6edb938 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 1 Oct 2026 09:41:26 -0300 Subject: [PATCH 4/7] feat(menu): update ticket routes to use new menu code; add tests for menu access and route validation --- .../Ticketing/Ticket/routes/adminapp.php | 12 +-- app/Shared/Forms/routes/adminapp.php | 2 +- tests/Feature/Menu/TicketMenuAccessTest.php | 76 +++++++++++++++++++ 3 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/Menu/TicketMenuAccessTest.php diff --git a/app/Domains/Ticketing/Ticket/routes/adminapp.php b/app/Domains/Ticketing/Ticket/routes/adminapp.php index b785612a..17e0329a 100644 --- a/app/Domains/Ticketing/Ticket/routes/adminapp.php +++ b/app/Domains/Ticketing/Ticket/routes/adminapp.php @@ -7,24 +7,24 @@ Route::prefix('v1/adminapp/tenant') ->middleware(['auth:sanctum', 'adminapp.tenant']) ->group(function (): void { Route::get('tickets', [TicketController::class, 'index']) - ->middleware('tenant.menu:adminapp.tickets') + ->middleware('tenant.menu:onticket.adminapp.tickets') ->name('adminapp.tickets.index'); Route::post('tickets/{ticket}/cancel', [TicketController::class, 'cancel']) ->whereNumber('ticket') - ->middleware('tenant.menu:adminapp.tickets') + ->middleware('tenant.menu:onticket.adminapp.tickets') ->name('adminapp.tickets.cancel'); Route::get('tickets/{ticket}/refund', [TicketController::class, 'calculateRefund']) ->whereNumber('ticket') - ->middleware('tenant.menu:adminapp.tickets') + ->middleware('tenant.menu:onticket.adminapp.tickets') ->name('adminapp.tickets.calculate-refund'); Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund']) ->whereNumber('ticket') - ->middleware('tenant.menu:adminapp.tickets') + ->middleware('tenant.menu:onticket.adminapp.tickets') ->name('adminapp.tickets.refund'); Route::get('tickets/pdf', [TicketController::class, 'downloadPdf']) - ->middleware('tenant.menu:adminapp.tickets') + ->middleware('tenant.menu:onticket.adminapp.tickets') ->name('adminapp.tickets.pdf'); Route::get('tickets/excel', [TicketController::class, 'downloadExcel']) - ->middleware('tenant.menu:adminapp.tickets') + ->middleware('tenant.menu:onticket.adminapp.tickets') ->name('adminapp.tickets.excel'); }); diff --git a/app/Shared/Forms/routes/adminapp.php b/app/Shared/Forms/routes/adminapp.php index 6517d639..e77dcc63 100644 --- a/app/Shared/Forms/routes/adminapp.php +++ b/app/Shared/Forms/routes/adminapp.php @@ -23,7 +23,7 @@ Route::prefix('v1/adminapp/forms') Route::get('sale', SaleFormController::class); Route::get('staff', StaffFormController::class); Route::get('tickets-filter', TicketFilterFormController::class) - ->middleware('tenant.menu:adminapp.tickets') + ->middleware('tenant.menu:onticket.adminapp.tickets') ->name('adminapp.forms.tickets-filter'); Route::get( 'fiesta-futbol-infantil/ticket', diff --git a/tests/Feature/Menu/TicketMenuAccessTest.php b/tests/Feature/Menu/TicketMenuAccessTest.php new file mode 100644 index 00000000..a9dae0a2 --- /dev/null +++ b/tests/Feature/Menu/TicketMenuAccessTest.php @@ -0,0 +1,76 @@ + ['onticket.adminapp.tickets', 'current', true], + 'old code' => ['adminapp.tickets', 'current', false], + 'another tenant' => ['onticket.adminapp.tickets', 'other', false], + 'unrelated menu' => ['adminapp.ventas', 'current', false], + ]; + } + + #[DataProvider('menuAssignments')] + public function test_ticket_menu_requires_an_association_with_the_authenticated_tenant(string $menuCode, string $assignedTenant, bool $allowed): void + { + Schema::create('tenants', function (Blueprint $table): void { + $table->id(); + $table->string('codigo'); + }); + Schema::create('menues', function (Blueprint $table): void { + $table->id(); + $table->string('code'); + }); + Schema::create('tenants_menues', function (Blueprint $table): void { + $table->string('tenant_code'); + $table->string('menu_code'); + }); + DB::table('tenants')->insert(['codigo' => 'current']); + DB::table('menues')->insert(['code' => $menuCode]); + DB::table('tenants_menues')->insert(['tenant_code' => $assignedTenant, 'menu_code' => $menuCode]); + + $user = new User(['tenant_codigo' => 'current']); + $request = Request::create('/api/v1/adminapp/tenant/tickets'); + $request->setUserResolver(fn () => $user); + if (! $allowed) { + $this->expectException(HttpException::class); + $this->expectExceptionCode(0); + } + + try { + $response = (new EnsureTenantHasMenu)->handle($request, fn () => response('allowed'), 'onticket.adminapp.tickets'); + $this->assertSame('allowed', $response->getContent()); + } catch (HttpException $exception) { + $this->assertSame(404, $exception->getStatusCode()); + throw $exception; + } + } + + public function test_all_ticket_routes_and_filter_form_use_the_updated_menu_codes(): void + { + foreach ([ + 'adminapp.tickets.index', 'adminapp.tickets.cancel', + 'adminapp.tickets.calculate-refund', 'adminapp.tickets.refund', + 'adminapp.tickets.pdf', 'adminapp.tickets.excel', 'adminapp.forms.tickets-filter', + ] as $name) { + $route = Route::getRoutes()->getByName($name); + $this->assertNotNull($route); + $this->assertContains('tenant.menu:onticket.adminapp.tickets', $route->gatherMiddleware()); + } + } +} -- 2.49.1 From 2d11263adc4b7ba58c1ab6249bbeb2746ab252a2 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 1 Oct 2026 09:46:29 -0300 Subject: [PATCH 5/7] feat(ticket): add event_id support to ticket service methods; update search, cancel, and refund calculations --- .../Controllers/AdminApp/TicketController.php | 10 ++--- .../Ticket/Services/AdminAppTicketService.php | 42 ++++++++++--------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/app/Domains/Ticketing/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticketing/Ticket/Controllers/AdminApp/TicketController.php index 6106ddd1..3c9bdbaa 100644 --- a/app/Domains/Ticketing/Ticket/Controllers/AdminApp/TicketController.php +++ b/app/Domains/Ticketing/Ticket/Controllers/AdminApp/TicketController.php @@ -29,7 +29,7 @@ class TicketController extends Controller $tenant = $request->user()->tenant()->firstOrFail(); return new AdminAppTicketCollection( - $this->ticketService->search($tenant, $request->validated()) + $this->ticketService->search($tenant, $request->validated(), $request->user()->event_id) ); } @@ -37,7 +37,7 @@ class TicketController extends Controller { $tenant = $request->user()->tenant()->firstOrFail(); - return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket)); + return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket, $request->user()->event_id)); } public function calculateRefund(Request $request, int $ticket): AdminAppTicketRefundCalculationResource @@ -45,7 +45,7 @@ class TicketController extends Controller $tenant = $request->user()->tenant()->firstOrFail(); return new AdminAppTicketRefundCalculationResource( - $this->ticketService->calculateRefund($tenant, $ticket) + $this->ticketService->calculateRefund($tenant, $ticket, $request->user()->event_id) ); } @@ -69,7 +69,7 @@ class TicketController extends Controller return $this->ticketPdfService->download( $tenant, - $this->ticketService->ticketsForExport($tenant, $request->validated()), + $this->ticketService->ticketsForExport($tenant, $request->validated(), $request->user()->event_id), $request->validated('timezone'), ); } @@ -80,7 +80,7 @@ class TicketController extends Controller return $this->ticketExcelService->download( $tenant, - $this->ticketService->ticketsForExport($tenant, $request->validated()), + $this->ticketService->ticketsForExport($tenant, $request->validated(), $request->user()->event_id), $request->validated('timezone'), ); } diff --git a/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php index 81dbfa0f..e8dc384d 100644 --- a/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticketing/Ticket/Services/AdminAppTicketService.php @@ -41,9 +41,9 @@ class AdminAppTicketService /** * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int, sort_by?: string|null, sort_direction?: string|null} $filters */ - public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult + public function search(Tenant $tenant, array $filters = [], ?int $eventId = null): AdminAppTicketResult { - $query = $this->baseQuery($tenant, $filters); + $query = $this->baseQuery($tenant, $filters, $eventId); $countQuery = clone $query; $databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters); @@ -77,7 +77,7 @@ class AdminAppTicketService tickets: $tickets, scannedTickets: $scannedTickets, totalTickets: $totalTickets, - refundedTotal: $this->refundSummaryService->totalForTenant($tenant), + refundedTotal: $this->refundSummaryService->totalForTenant($tenant, $eventId), ); } @@ -85,9 +85,9 @@ class AdminAppTicketService * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, sort_by?: string|null, sort_direction?: string|null} $filters * @return Collection */ - public function ticketsForExport(Tenant $tenant, array $filters = []): Collection + public function ticketsForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection { - $query = $this->baseQuery($tenant, $filters); + $query = $this->baseQuery($tenant, $filters, $eventId); $databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters); $tickets = $query ->with(self::RELATIONS) @@ -97,11 +97,10 @@ class AdminAppTicketService return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters); } - public function cancel(Tenant $tenant, int $ticketId): Ticket + public function cancel(Tenant $tenant, int $ticketId, ?int $eventId = null): Ticket { - return DB::transaction(function () use ($tenant, $ticketId): Ticket { - $ticket = Ticket::query() - ->where('tenant_code', $tenant->codigo) + return DB::transaction(function () use ($tenant, $ticketId, $eventId): Ticket { + $ticket = $this->ticketsQuery($tenant, $eventId) ->lockForUpdate() ->findOrFail($ticketId); @@ -124,10 +123,9 @@ class AdminAppTicketService * partial: string|null, * } */ - public function calculateRefund(Tenant $tenant, int $ticketId): array + public function calculateRefund(Tenant $tenant, int $ticketId, ?int $eventId = null): array { - $ticket = Ticket::query() - ->where('tenant_code', $tenant->codigo) + $ticket = $this->ticketsQuery($tenant, $eventId) ->findOrFail($ticketId); if (! $ticket->can_refund()) { @@ -175,14 +173,13 @@ class AdminAppTicketService string $refundType, ?User $createdBy = null, ): Ticket { - $this->ensureRefundIsAllowed($tenant, $refundType); - return DB::transaction(function () use ($tenant, $ticketId, $refundType, $createdBy): Ticket { - $ticket = Ticket::query() - ->where('tenant_code', $tenant->codigo) + $ticket = $this->ticketsQuery($tenant, $createdBy?->event_id) ->lockForUpdate() ->findOrFail($ticketId); + $this->ensureRefundIsAllowed($tenant, $refundType); + if (! $ticket->can_refund()) { if ($ticket->status !== Ticket::STATUS_ACTIVE) { throw ValidationException::withMessages([ @@ -314,12 +311,11 @@ class AdminAppTicketService * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters * @return Builder */ - private function baseQuery(Tenant $tenant, array $filters): Builder + private function baseQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder { $search = trim((string) ($filters['q'] ?? '')); - $query = Ticket::query() - ->where('tenant_code', $tenant->codigo) + $query = $this->ticketsQuery($tenant, $eventId) ->when($search !== '', function (Builder $query) use ($search): void { $this->applySearchFilter($query, $search); }) @@ -359,6 +355,14 @@ class AdminAppTicketService return $query; } + /** @return Builder */ + private function ticketsQuery(Tenant $tenant, ?int $eventId): Builder + { + return Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->when($eventId !== null, fn (Builder $query): Builder => $query->where('tickets.event_id', $eventId)); + } + /** @param Builder $query */ private function applySearchFilter(Builder $query, string $search): void { -- 2.49.1 From 22de61c8c03e3e82c286532a56b4499b7c957d43 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 1 Oct 2026 09:46:47 -0300 Subject: [PATCH 6/7] feat(tests): add AdminAppTicketEventScopeTest for event-based ticket management --- .../Ticket/AdminAppTicketEventScopeTest.php | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tests/Feature/Ticket/AdminAppTicketEventScopeTest.php diff --git a/tests/Feature/Ticket/AdminAppTicketEventScopeTest.php b/tests/Feature/Ticket/AdminAppTicketEventScopeTest.php new file mode 100644 index 00000000..627c5a39 --- /dev/null +++ b/tests/Feature/Ticket/AdminAppTicketEventScopeTest.php @@ -0,0 +1,173 @@ +id(); + $table->string('codigo'); + $table->string('timezone')->default('UTC'); + $table->boolean('allow_ticket_refund')->default(false); + $table->boolean('allow_ticket_total_refund')->default(false); + $table->boolean('allow_ticket_partial_refund')->default(false); + }); + Schema::create('menues', function (Blueprint $table): void { + $table->id(); + $table->string('code'); + }); + Schema::create('tenants_menues', function (Blueprint $table): void { + $table->string('tenant_code'); + $table->string('menu_code'); + }); + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->string('nombre_apellido'); + $table->softDeletes(); + }); + Schema::create('tickets', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->unsignedBigInteger('event_id')->nullable(); + $table->string('ticket'); + foreach (['source_variant_id', 'source_catalog_item_id', 'source_purchase_item_id', 'scanner_user_id', 'user_id'] as $column) { + $table->unsignedBigInteger($column)->nullable(); + } + foreach (['used_at', 'disabled_at', 'cancelled_at', 'refunded_at'] as $column) { + $table->timestamp($column)->nullable(); + } + $table->timestamps(); + }); + Schema::create('compras', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_codigo'); + $table->unsignedBigInteger('event_id')->nullable(); + $table->string('nombre_apellido'); + }); + Schema::create('compra_items', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('compra_id'); + $table->decimal('precio_unitario', 12, 2); + }); + Schema::create('ticket_refunds', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('ticket_id')->nullable(); + $table->unsignedBigInteger('purchase_item_id'); + $table->decimal('amount', 12, 2); + }); + Schema::create('desfile_entry_reservations', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('ticket_id'); + }); + Schema::create('value_changes', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('trackable_type'); + $table->unsignedBigInteger('trackable_id'); + $table->string('attribute'); + $table->string('old_value')->nullable(); + $table->string('new_value')->nullable(); + $table->timestamp('changed_at'); + $table->string('actor_type'); + $table->unsignedBigInteger('user_id')->nullable(); + }); + + DB::table('tenants')->insert(['codigo' => 'onticket']); + DB::table('menues')->insert(['code' => 'onticket.adminapp.tickets']); + DB::table('tenants_menues')->insert(['tenant_code' => 'onticket', 'menu_code' => 'onticket.adminapp.tickets']); + foreach ([1 => ['onticket', 10], 2 => ['onticket', 20], 3 => ['onticket', null], 4 => ['other', 10]] as $id => [$tenant, $event]) { + DB::table('tickets')->insert([ + 'id' => $id, 'tenant_code' => $tenant, 'event_id' => $event, + 'ticket' => "ticket-{$id}", 'used_at' => now(), + ]); + DB::table('compras')->insert(['id' => $id, 'tenant_codigo' => $tenant, 'event_id' => $event, 'nombre_apellido' => 'Cliente']); + DB::table('compra_items')->insert(['id' => $id, 'compra_id' => $id, 'precio_unitario' => 100]); + DB::table('ticket_refunds')->insert(['purchase_item_id' => $id, 'amount' => $id * 10]); + } + $this->actingAsAdmin(10); + } + + public function test_list_counts_refunded_total_and_search_cannot_escape_the_user_event(): void + { + $this->getJson('/api/v1/adminapp/tenant/tickets?sort_by=id&event_id=20') + ->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', 1) + ->assertJsonPath('scanned_tickets', 1)->assertJsonPath('total_tickets', 1) + ->assertJsonPath('refunded_total', '10.00'); + $this->getJson('/api/v1/adminapp/tenant/tickets?q=2')->assertOk()->assertJsonCount(0, 'data'); + // Status uses sorting in memory rather than the database. + $this->getJson('/api/v1/adminapp/tenant/tickets?sort_by=status') + ->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', 1); + } + + public function test_foreign_unassigned_and_other_tenant_tickets_cannot_be_modified_or_refunded(): void + { + foreach ([2, 3, 4] as $id) { + $this->postJson("/api/v1/adminapp/tenant/tickets/{$id}/cancel", ['event_id' => 20])->assertNotFound(); + $this->getJson("/api/v1/adminapp/tenant/tickets/{$id}/refund")->assertNotFound(); + $this->postJson("/api/v1/adminapp/tenant/tickets/{$id}/refund", ['refund_type' => 'total', 'event_id' => 20])->assertNotFound(); + $this->assertDatabaseHas('tickets', ['id' => $id, 'cancelled_at' => null, 'refunded_at' => null]); + } + $this->assertDatabaseCount('value_changes', 0); + $this->assertDatabaseCount('ticket_refunds', 4); + } + + public function test_own_ticket_can_be_cancelled_and_refund_requests_reach_business_validation(): void + { + DB::table('tickets')->where('id', 1)->update(['used_at' => null]); + $this->getJson('/api/v1/adminapp/tenant/tickets/1/refund')->assertUnprocessable(); + $this->postJson('/api/v1/adminapp/tenant/tickets/1/refund', ['refund_type' => 'total'])->assertUnprocessable(); + $this->postJson('/api/v1/adminapp/tenant/tickets/1/cancel')->assertOk(); + $this->assertNotNull(DB::table('tickets')->where('id', 1)->value('cancelled_at')); + } + + public function test_unscoped_admin_keeps_the_tenant_scope(): void + { + $this->actingAsAdmin(null); + $this->getJson('/api/v1/adminapp/tenant/tickets?sort_by=id') + ->assertOk()->assertJsonCount(3, 'data')->assertJsonPath('total_tickets', 3) + ->assertJsonPath('refunded_total', '60.00'); + DB::table('tickets')->where('id', 3)->update(['used_at' => null]); + $this->postJson('/api/v1/adminapp/tenant/tickets/3/cancel')->assertOk(); + $this->postJson('/api/v1/adminapp/tenant/tickets/4/cancel')->assertNotFound(); + } + + public function test_pdf_and_excel_receive_only_the_tickets_of_the_user_event(): void + { + foreach ([AdminAppTicketPdfService::class, AdminAppTicketExcelService::class] as $class) { + $this->mock($class, function (MockInterface $mock) use ($class): void { + $mock->shouldReceive('download')->once()->withArgs( + fn ($tenant, Collection $tickets, $timezone): bool => $tenant->codigo === 'onticket' + && $tickets->pluck('id')->all() === [1] && $timezone === 'UTC' + )->andReturn($class === AdminAppTicketPdfService::class + ? response('pdf') : new StreamedResponse(fn () => print('excel'))); + }); + } + foreach (['pdf', 'excel'] as $format) { + $this->getJson("/api/v1/adminapp/tenant/tickets/{$format}?timezone=UTC&event_id=20")->assertOk(); + } + } + + private function actingAsAdmin(?int $eventId): void + { + $user = new User; + $user->setRawAttributes(['id' => 1, 'rol_codigo' => 'adminapp', 'tenant_codigo' => 'onticket', 'event_id' => $eventId]); + Sanctum::actingAs($user); + } +} -- 2.49.1 From 3ddff7e0ee73767f85e0024a1fc81eebab518dc2 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 1 Oct 2026 09:47:32 -0300 Subject: [PATCH 7/7] feat(tests): enhance AdminAppTicketEventScopeTest with soft deletes and active ticket filtering --- app/Domains/Ticketing/Ticket/documentacion/README.md | 6 +++++- tests/Feature/Ticket/AdminAppTicketEventScopeTest.php | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/Domains/Ticketing/Ticket/documentacion/README.md b/app/Domains/Ticketing/Ticket/documentacion/README.md index e460d5c3..66b5df4c 100644 --- a/app/Domains/Ticketing/Ticket/documentacion/README.md +++ b/app/Domains/Ticketing/Ticket/documentacion/README.md @@ -82,13 +82,17 @@ Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`: - `POST /tickets/pdf`. Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú -`adminapp.tickets`: +`onticket.adminapp.tickets`: - `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye `scanned_tickets` y `total_tickets` para el tenant autenticado. `TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas. +Si el administrador autenticado tiene `event_id`, las consultas de Tickets y sus exportaciones se limitan a `tickets.event_id` dentro del tenant. Los contadores usan el mismo alcance y el total reembolsado se limita a las compras del evento. Sin `event_id`, se conserva el alcance por tenant. + +La cancelación, el cálculo de reembolso y el reembolso buscan el ticket dentro de ese alcance antes de validar o ejecutar la operación. Un ticket de otro evento o sin evento devuelve 404 para un administrador con evento asignado. El alcance se obtiene del usuario autenticado, no de los parámetros del cliente. + ## Dependencias y reglas Depende de `Purchase`, `Catalog`, `Tenant` y `Auth`. La generación debe ser idempotente ante reintentos del evento. `TicketNotAvailableException` y `TicketGenerationException` separan indisponibilidad de errores de generación. diff --git a/tests/Feature/Ticket/AdminAppTicketEventScopeTest.php b/tests/Feature/Ticket/AdminAppTicketEventScopeTest.php index 627c5a39..8820c3be 100644 --- a/tests/Feature/Ticket/AdminAppTicketEventScopeTest.php +++ b/tests/Feature/Ticket/AdminAppTicketEventScopeTest.php @@ -75,6 +75,7 @@ class AdminAppTicketEventScopeTest extends TestCase Schema::create('desfile_entry_reservations', function (Blueprint $table): void { $table->id(); $table->unsignedBigInteger('ticket_id'); + $table->softDeletes(); }); Schema::create('value_changes', function (Blueprint $table): void { $table->id(); @@ -114,6 +115,10 @@ class AdminAppTicketEventScopeTest extends TestCase // Status uses sorting in memory rather than the database. $this->getJson('/api/v1/adminapp/tenant/tickets?sort_by=status') ->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', 1); + DB::table('tickets')->where('id', 1)->update(['used_at' => null]); + $this->getJson('/api/v1/adminapp/tenant/tickets?status=active') + ->assertOk()->assertJsonCount(1, 'data') + ->assertJsonPath('scanned_tickets', 0)->assertJsonPath('total_tickets', 1); } public function test_foreign_unassigned_and_other_tenant_tickets_cannot_be_modified_or_refunded(): void @@ -156,7 +161,7 @@ class AdminAppTicketEventScopeTest extends TestCase fn ($tenant, Collection $tickets, $timezone): bool => $tenant->codigo === 'onticket' && $tickets->pluck('id')->all() === [1] && $timezone === 'UTC' )->andReturn($class === AdminAppTicketPdfService::class - ? response('pdf') : new StreamedResponse(fn () => print('excel'))); + ? response('pdf') : new StreamedResponse(fn () => print ('excel'))); }); } foreach (['pdf', 'excel'] as $format) { -- 2.49.1