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); } }