From 6ee3f22e41c5a509df214e82f55aa3565c948c88 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 14:44:55 -0300 Subject: [PATCH 1/6] feat(menu): add tickets menu and update related seeder and tests --- ..._futbol_infantil_tickets_adminapp_menu.php | 82 +++++++++++++++++++ database/seeders/MenuSeeder.php | 7 ++ tests/Feature/Seeders/MenuSeederTest.php | 7 +- 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php diff --git a/database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php b/database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php new file mode 100644 index 0000000..87c41fb --- /dev/null +++ b/database/migrations/2026_08_28_000000_add_fiesta_futbol_infantil_tickets_adminapp_menu.php @@ -0,0 +1,82 @@ +where('code', 'main.adminapp')->exists()) { + // Reference data is added by seeders on fresh installations. + return; + } + + $now = now(); + + DB::transaction(function () use ($now): void { + DB::table('menues')->updateOrInsert( + ['code' => self::MENU_CODE], + [ + 'label' => 'Tickets', + 'parent_menu_code' => 'main.adminapp', + 'content_type' => 'dynamic', + 'static_content_schema' => null, + 'route' => '/admin/tickets', + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + + DB::table('tenants_menues') + ->where('menu_code', self::MENU_CODE) + ->where('tenant_code', '!=', self::TENANT_CODE) + ->delete(); + + if (DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) { + DB::table('tenants_menues')->updateOrInsert( + [ + 'tenant_code' => self::TENANT_CODE, + 'menu_code' => self::MENU_CODE, + ], + [ + 'static_content' => null, + 'created_at' => $now, + 'updated_at' => $now, + ], + ); + } + + DB::table('roles') + ->whereIn('codigo', ['admin', 'adminapp']) + ->pluck('codigo') + ->each(function (string $roleCode): void { + DB::table('roles_menues')->updateOrInsert([ + 'rol_codigo' => $roleCode, + 'menu_codigo' => self::MENU_CODE, + ]); + }); + }); + } + + public function down(): void + { + DB::transaction(function (): void { + DB::table('tenants_menues') + ->where('menu_code', self::MENU_CODE) + ->delete(); + + DB::table('roles_menues') + ->where('menu_codigo', self::MENU_CODE) + ->delete(); + + DB::table('menues') + ->where('code', self::MENU_CODE) + ->delete(); + }); + } +}; diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php index dbab420..11885b5 100644 --- a/database/seeders/MenuSeeder.php +++ b/database/seeders/MenuSeeder.php @@ -78,6 +78,12 @@ class MenuSeeder extends Seeder 'parent_menu_code' => 'main.adminapp', 'route' => '/admin/staff', ], + [ + 'code' => 'adminapp.tickets', + 'label' => 'Tickets', + 'parent_menu_code' => 'main.adminapp', + 'route' => '/admin/tickets', + ], [ 'code' => 'adminapp.fiesta-futbol-infantil.entradas', 'label' => 'Entradas', @@ -270,6 +276,7 @@ class MenuSeeder extends Seeder 'fiesta_futbol_infantil', ]; $fiestaCategoryMenuCodes = [ + 'adminapp.tickets', 'adminapp.fiesta-futbol-infantil.entradas', 'adminapp.fiesta-futbol-infantil.alojamientos', 'adminapp.fiesta-futbol-infantil.merchandising', diff --git a/tests/Feature/Seeders/MenuSeederTest.php b/tests/Feature/Seeders/MenuSeederTest.php index 6f579d0..d253c8d 100644 --- a/tests/Feature/Seeders/MenuSeederTest.php +++ b/tests/Feature/Seeders/MenuSeederTest.php @@ -34,6 +34,7 @@ class MenuSeederTest extends TestCase 'adminapp.ventas' => ['Ventas', '/admin/ventas'], ]; $fiestaCategoryMenus = [ + 'adminapp.tickets' => ['Tickets', '/admin/tickets'], 'adminapp.fiesta-futbol-infantil.entradas' => ['Entradas', '/admin/entradas'], 'adminapp.fiesta-futbol-infantil.alojamientos' => ['Alojamientos', '/admin/alojamientos'], 'adminapp.fiesta-futbol-infantil.merchandising' => ['Merchandising', '/admin/merchandising'], @@ -49,7 +50,11 @@ class MenuSeederTest extends TestCase $this->assertSame(Menu::CONTENT_TYPE_DYNAMIC, $adminApp->content_type); $this->assertSame('/', $adminApp->route); $this->assertSame( - array_keys([...$expectedMenus, ...$fiestaCategoryMenus]), + collect(array_keys([ + ...$expectedMenus, + ...$fiestaCategoryMenus, + 'adminapp.desfile.entradas' => ['Entradas', '/admin/desfile/entradas'], + ]))->sort()->values()->all(), $adminApp->children->pluck('code')->sort()->values()->all() ); $this->assertTrue( From c792e7d30666a4b34b2c2ab3758795d2678b5868 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 15:10:23 -0300 Subject: [PATCH 2/6] feat(tickets): implement admin app ticket management with listing and search functionality --- .../Controllers/AdminApp/TicketController.php | 23 +++ .../Requests/AdminAppTicketIndexRequest.php | 23 +++ .../Ticket/Services/AdminAppTicketService.php | 52 ++++++ app/Domains/Ticket/documentacion/README.md | 6 + app/Domains/Ticket/routes/adminapp.php | 12 ++ app/Domains/Ticket/routes/api.php | 1 + .../Ticket/AdminAppTicketControllerTest.php | 163 ++++++++++++++++++ 7 files changed, 280 insertions(+) create mode 100644 app/Domains/Ticket/Controllers/AdminApp/TicketController.php create mode 100644 app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php create mode 100644 app/Domains/Ticket/Services/AdminAppTicketService.php create mode 100644 app/Domains/Ticket/routes/adminapp.php create mode 100644 tests/Feature/Ticket/AdminAppTicketControllerTest.php diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php new file mode 100644 index 0000000..2fa6b10 --- /dev/null +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -0,0 +1,23 @@ +user()->tenant()->firstOrFail(); + + return TicketResource::collection( + $this->ticketService->list($tenant, $request->validated()) + )->additional($this->ticketService->counts($tenant)); + } +} diff --git a/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php new file mode 100644 index 0000000..e54dec3 --- /dev/null +++ b/app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php @@ -0,0 +1,23 @@ +> */ + public function rules(): array + { + return [ + 'q' => ['sometimes', 'nullable', 'string', 'max:255'], + 'page' => ['sometimes', 'integer', 'min:1'], + 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], + ]; + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php new file mode 100644 index 0000000..6d7c9c2 --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -0,0 +1,52 @@ +where('tenant_code', $tenant->codigo); + + return [ + 'scanned_tickets' => (clone $query)->whereNotNull('used_at')->count(), + 'total_tickets' => $query->count(), + ]; + } + + /** + * @param array{q?: string|null, page?: int, per_page?: int} $filters + * @return LengthAwarePaginator + */ + public function list(Tenant $tenant, array $filters = []): LengthAwarePaginator + { + $search = trim((string) ($filters['q'] ?? '')); + + return Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->when($search !== '', function (Builder $query) use ($search): void { + $query->when( + ctype_digit($search), + fn (Builder $searchQuery): Builder => $searchQuery + ->where('tickets.id', (int) $search), + fn (Builder $searchQuery): Builder => $searchQuery + ->where('ticket', 'like', "%{$search}%"), + ); + }) + ->with([ + ...TicketValidityResolver::RELATIONS, + ...TicketPresentationResolver::RELATIONS, + 'user', + 'sourceCatalogItem.category', + ]) + ->orderByDesc('id') + ->paginateFromRequest() + ->withQueryString(); + } +} diff --git a/app/Domains/Ticket/documentacion/README.md b/app/Domains/Ticket/documentacion/README.md index a266d30..c43a9bd 100644 --- a/app/Domains/Ticket/documentacion/README.md +++ b/app/Domains/Ticket/documentacion/README.md @@ -30,6 +30,12 @@ Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`: - `GET /tickets`. - `POST /tickets/pdf`. +Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú +`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. ## Dependencias y reglas diff --git a/app/Domains/Ticket/routes/adminapp.php b/app/Domains/Ticket/routes/adminapp.php new file mode 100644 index 0000000..64af50e --- /dev/null +++ b/app/Domains/Ticket/routes/adminapp.php @@ -0,0 +1,12 @@ +middleware(['auth:sanctum', 'adminapp.tenant']) + ->group(function (): void { + Route::get('tickets', [TicketController::class, 'index']) + ->middleware('tenant.menu:adminapp.tickets') + ->name('adminapp.tickets.index'); + }); diff --git a/app/Domains/Ticket/routes/api.php b/app/Domains/Ticket/routes/api.php index da5305b..8bcdb8e 100644 --- a/app/Domains/Ticket/routes/api.php +++ b/app/Domains/Ticket/routes/api.php @@ -11,3 +11,4 @@ Route::prefix('tenants/{tenant:codigo}') }); require __DIR__.'/scanner.php'; +require __DIR__.'/adminapp.php'; diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php new file mode 100644 index 0000000..2cbea72 --- /dev/null +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -0,0 +1,163 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/tenant/tickets')->assertUnauthorized(); + } + + public function test_the_tenant_must_have_the_tickets_menu(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $this->getJson('/api/v1/adminapp/tenant/tickets')->assertNotFound(); + } + + public function test_it_lists_only_tickets_from_the_authenticated_tenant(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $otherTenant = $this->createTenant('other'); + $admin = $this->createAdminAppUser($tenant); + $otherUser = $this->createAdminAppUser($otherTenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $ticket = $this->createTicket($tenant, $admin); + $this->createTicket($otherTenant, $otherUser); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.tenant_code', $tenant->codigo) + ->assertJsonPath('meta.total', 1); + } + + public function test_it_supports_id_and_uuid_search(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $matching = $this->createTicket($tenant, $admin); + $this->createTicket($tenant, $admin); + + $this->getJson("/api/v1/adminapp/tenant/tickets?q={$matching->id}") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $matching->id); + + $this->getJson('/api/v1/adminapp/tenant/tickets?q='.substr($matching->ticket, 0, 8)) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.ticket', $matching->ticket); + } + + public function test_it_includes_tenant_scanned_and_total_ticket_counts(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $otherTenant = $this->createTenant('other'); + $admin = $this->createAdminAppUser($tenant); + $otherUser = $this->createAdminAppUser($otherTenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $this->createTicket($tenant, $admin)->update(['used_at' => now()]); + $this->createTicket($tenant, $admin); + $this->createTicket($otherTenant, $otherUser)->update(['used_at' => now()]); + + $this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match') + ->assertOk() + ->assertJsonCount(0, 'data') + ->assertJsonPath('scanned_tickets', 1) + ->assertJsonPath('total_tickets', 2); + } + + private function createTenant(string $code): Tenant + { + $headerLogo = $this->createAttachment("{$code}-header.png"); + $footerLogo = $this->createAttachment("{$code}-footer.png"); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'website_type_code' => 'onticket', + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'success_color' => '#444444', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + } + + private function createAttachment(string $filename): Attachment + { + return Attachment::query()->create([ + 'path' => "test/{$filename}", + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } + + private function createAdminAppUser(Tenant $tenant): User + { + return User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ]); + } + + private function grantTicketsMenu(Tenant $tenant): void + { + $menu = Menu::query()->create([ + 'code' => 'adminapp.tickets', + 'label' => 'Tickets', + 'route' => '/admin/tickets', + ]); + + $tenant->menues()->attach($menu->code); + } + + private function createTicket(Tenant $tenant, User $user): Ticket + { + return Ticket::query()->create([ + 'tenant_code' => $tenant->codigo, + 'ticket' => (string) Str::uuid(), + 'user_id' => $user->id, + ]); + } +} From ba0e2dd00c0bf3d7bfe405425926355afcbf2cff Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 15:14:11 -0300 Subject: [PATCH 3/6] feat(tickets): enhance ticket search functionality and add ticket counts to response --- .../Controllers/AdminApp/TicketController.php | 11 +++-- .../AdminApp/AdminAppTicketCollection.php | 35 +++++++++++++++ .../Ticket/Services/AdminAppTicketResult.php | 16 +++++++ .../Ticket/Services/AdminAppTicketService.php | 44 ++++++++++--------- .../Ticket/AdminAppTicketControllerTest.php | 5 +++ 5 files changed, 85 insertions(+), 26 deletions(-) create mode 100644 app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php create mode 100644 app/Domains/Ticket/Services/AdminAppTicketResult.php diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php index 2fa6b10..beb9fac 100644 --- a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -3,21 +3,20 @@ namespace App\Domains\Ticket\Controllers\AdminApp; use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest; -use App\Domains\Ticket\Resources\TicketResource; +use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection; use App\Domains\Ticket\Services\AdminAppTicketService; use App\Http\Controllers\Controller; -use Illuminate\Http\Resources\Json\AnonymousResourceCollection; class TicketController extends Controller { public function __construct(private readonly AdminAppTicketService $ticketService) {} - public function index(AdminAppTicketIndexRequest $request): AnonymousResourceCollection + public function index(AdminAppTicketIndexRequest $request): AdminAppTicketCollection { $tenant = $request->user()->tenant()->firstOrFail(); - return TicketResource::collection( - $this->ticketService->list($tenant, $request->validated()) - )->additional($this->ticketService->counts($tenant)); + return new AdminAppTicketCollection( + $this->ticketService->search($tenant, $request->validated()) + ); } } diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php new file mode 100644 index 0000000..4355ad6 --- /dev/null +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php @@ -0,0 +1,35 @@ + */ + public $collects = TicketResource::class; + + private readonly int $scannedTickets; + + private readonly int $totalTickets; + + public function __construct(AdminAppTicketResult $result) + { + parent::__construct($result->tickets); + + $this->scannedTickets = $result->scannedTickets; + $this->totalTickets = $result->totalTickets; + } + + /** @return array{scanned_tickets: int, total_tickets: int} */ + public function with(Request $request): array + { + return [ + 'scanned_tickets' => $this->scannedTickets, + 'total_tickets' => $this->totalTickets, + ]; + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketResult.php b/app/Domains/Ticket/Services/AdminAppTicketResult.php new file mode 100644 index 0000000..e9f65f4 --- /dev/null +++ b/app/Domains/Ticket/Services/AdminAppTicketResult.php @@ -0,0 +1,16 @@ + $tickets */ + public function __construct( + public LengthAwarePaginator $tickets, + public int $scannedTickets, + public int $totalTickets, + ) {} +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 6d7c9c2..1180843 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -5,26 +5,39 @@ namespace App\Domains\Ticket\Services; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Pagination\LengthAwarePaginator; class AdminAppTicketService { - /** @return array{scanned_tickets: int, total_tickets: int} */ - public function counts(Tenant $tenant): array + /** + * @param array{q?: string|null, page?: int, per_page?: int} $filters + */ + public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult { - $query = Ticket::query()->where('tenant_code', $tenant->codigo); + $query = $this->baseQuery($tenant, $filters); - return [ - 'scanned_tickets' => (clone $query)->whereNotNull('used_at')->count(), - 'total_tickets' => $query->count(), - ]; + $tickets = (clone $query) + ->with([ + ...TicketValidityResolver::RELATIONS, + ...TicketPresentationResolver::RELATIONS, + 'user', + 'sourceCatalogItem.category', + ]) + ->orderByDesc('id') + ->paginateFromRequest() + ->withQueryString(); + + return new AdminAppTicketResult( + tickets: $tickets, + scannedTickets: (clone $query)->whereNotNull('used_at')->count(), + totalTickets: $tickets->total(), + ); } /** * @param array{q?: string|null, page?: int, per_page?: int} $filters - * @return LengthAwarePaginator + * @return Builder */ - public function list(Tenant $tenant, array $filters = []): LengthAwarePaginator + private function baseQuery(Tenant $tenant, array $filters): Builder { $search = trim((string) ($filters['q'] ?? '')); @@ -38,15 +51,6 @@ class AdminAppTicketService fn (Builder $searchQuery): Builder => $searchQuery ->where('ticket', 'like', "%{$search}%"), ); - }) - ->with([ - ...TicketValidityResolver::RELATIONS, - ...TicketPresentationResolver::RELATIONS, - 'user', - 'sourceCatalogItem.category', - ]) - ->orderByDesc('id') - ->paginateFromRequest() - ->withQueryString(); + }); } } diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 2cbea72..9705e65 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -98,6 +98,11 @@ class AdminAppTicketControllerTest extends TestCase $this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match') ->assertOk() ->assertJsonCount(0, 'data') + ->assertJsonPath('scanned_tickets', 0) + ->assertJsonPath('total_tickets', 0); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() ->assertJsonPath('scanned_tickets', 1) ->assertJsonPath('total_tickets', 2); } From 1ec7ab3e3518c31b26581683eefa24408de52438 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 15:17:35 -0300 Subject: [PATCH 4/6] feat(tickets): implement AdminAppTicketResource and update ticket collection to use it --- .../AdminApp/AdminAppTicketCollection.php | 5 +- .../AdminApp/AdminAppTicketResource.php | 62 +++++++++++++++++++ .../Ticket/AdminAppTicketControllerTest.php | 59 +++++++++++++++++- 3 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php index 4355ad6..3af312c 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketCollection.php @@ -2,15 +2,14 @@ namespace App\Domains\Ticket\Resources\AdminApp; -use App\Domains\Ticket\Resources\TicketResource; use App\Domains\Ticket\Services\AdminAppTicketResult; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\ResourceCollection; class AdminAppTicketCollection extends ResourceCollection { - /** @var class-string */ - public $collects = TicketResource::class; + /** @var class-string */ + public $collects = AdminAppTicketResource::class; private readonly int $scannedTickets; diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php new file mode 100644 index 0000000..8a7b9e2 --- /dev/null +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php @@ -0,0 +1,62 @@ + */ + public function toArray(Request $request): array + { + return [ + ...parent::toArray($request), + 'variant_properties' => $this->variantProperties(), + ]; + } + + /** + * @return list + * }> + */ + private function variantProperties(): array + { + $variant = $this->sourceVariant; + + if ($variant === null) { + return []; + } + + $itemAttributes = $variant->definitions + ->map(fn ($definition) => $definition->itemAttribute) + ->filter() + ->merge($variant->catalogItem?->itemAttributes ?? collect()) + ->unique('id') + ->values(); + + return $variant->selectionOptions($itemAttributes) + ->map(function (array $selection, string $attributeCode) use ($itemAttributes): array { + $itemAttribute = $itemAttributes->first( + fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute?->codigo + === $attributeCode, + ); + $values = array_is_list($selection) ? $selection : [$selection]; + + return [ + 'code' => $attributeCode, + 'label' => $itemAttribute?->attribute?->nombre + ?? ($attributeCode === 'event_date' ? 'Fecha' : $attributeCode), + 'values' => array_values($values), + ]; + }) + ->values() + ->all(); + } +} diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 9705e65..c8da04b 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -6,7 +6,12 @@ use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; +use App\Domains\Catalog\Models\Attribute; +use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\Variant; use App\Domains\Menu\Models\Menu; +use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Models\Ticket; @@ -107,6 +112,53 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonPath('total_tickets', 2); } + public function test_it_returns_structured_variant_properties(): void + { + $tenant = $this->createTenant('fiesta_futbol_infantil'); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + + $item = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'remera', + 'nombre' => 'Remera', + 'precio' => '8000.00', + 'has_tickets' => true, + ]); + $attribute = Attribute::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'codigo' => 'size', + 'nombre' => 'Talle', + 'type' => FieldType::Select, + ]); + $attribute->options()->create(['value' => 'xl', 'label' => 'XL']); + $itemAttribute = $item->itemAttributes()->create([ + 'attribute_id' => $attribute->id, + 'sort_order' => 1, + ]); + $variant = Variant::query()->create([ + 'catalog_item_id' => $item->id, + 'inventory_id' => Inventory::query()->create()->id, + ]); + $variant->definitions()->create([ + 'item_attribute_id' => $itemAttribute->id, + 'value' => 'xl', + ]); + $ticket = $this->createTicket($tenant, $admin, [ + 'source_catalog_item_id' => $item->id, + 'source_variant_id' => $variant->id, + ]); + + $this->getJson('/api/v1/adminapp/tenant/tickets') + ->assertOk() + ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.variant_properties.0.code', 'size') + ->assertJsonPath('data.0.variant_properties.0.label', 'Talle') + ->assertJsonPath('data.0.variant_properties.0.values.0.value', 'xl') + ->assertJsonPath('data.0.variant_properties.0.values.0.label', 'XL'); + } + private function createTenant(string $code): Tenant { $headerLogo = $this->createAttachment("{$code}-header.png"); @@ -157,12 +209,13 @@ class AdminAppTicketControllerTest extends TestCase $tenant->menues()->attach($menu->code); } - private function createTicket(Tenant $tenant, User $user): Ticket + /** @param array $attributes */ + private function createTicket(Tenant $tenant, User $user, array $attributes = []): Ticket { - return Ticket::query()->create([ + return Ticket::query()->create(array_merge([ 'tenant_code' => $tenant->codigo, 'ticket' => (string) Str::uuid(), 'user_id' => $user->id, - ]); + ], $attributes)); } } From 0f6f39cce716fe3c5de84696aa88c19d71dd31fb Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 16:05:56 -0300 Subject: [PATCH 5/6] feat(tickets): enhance AdminAppTicketResource and service with purchase details and update tests --- .../AdminApp/AdminAppTicketResource.php | 25 +++++++++++++++ .../Ticket/Services/AdminAppTicketService.php | 2 ++ .../Ticket/AdminAppTicketControllerTest.php | 31 ++++++++++++++++++- 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php index 8a7b9e2..14ffd08 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticket\Resources\AdminApp; use App\Domains\Catalog\Models\ItemAttribute; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Resources\TicketResource; use Illuminate\Http\Request; @@ -13,12 +14,36 @@ class AdminAppTicketResource extends TicketResource /** @return array */ public function toArray(Request $request): array { + $purchaseItem = $this->sourcePurchaseItem(); + return [ ...parent::toArray($request), + 'source_purchase_id' => $this->source_purchase_id, + 'order_number' => $this->source_purchase_id, + 'product' => $purchaseItem?->item_nombre + ?? $this->sourceCatalogItem?->nombre + ?? $this->name, + 'amount' => $purchaseItem?->precio_unitario, + 'client' => $this->sourcePurchase?->nombre_apellido ?? $this->user?->nombre_apellido, + 'date' => $this->sourcePurchase?->created_at, + 'status' => $this->status, + 'scanned_by' => $this->scannerUser?->nombre_apellido, 'variant_properties' => $this->variantProperties(), ]; } + private function sourcePurchaseItem(): ?PurchaseItem + { + return $this->sourcePurchase?->items->first(function (PurchaseItem $item): bool { + if ($this->source_variant_id !== null) { + return $item->source_variant_id === $this->source_variant_id; + } + + return $item->source_catalog_item_id === $this->source_catalog_item_id + && $item->source_variant_id === null; + }); + } + /** * @return listorderByDesc('id') ->paginateFromRequest() diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index c8da04b..15f867f 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -11,6 +11,8 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; use App\Domains\Menu\Models\Menu; +use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; @@ -145,14 +147,41 @@ class AdminAppTicketControllerTest extends TestCase 'item_attribute_id' => $itemAttribute->id, 'value' => 'xl', ]); - $ticket = $this->createTicket($tenant, $admin, [ + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $admin->id, + 'status' => Purchase::STATUS_PAID, + 'nombre_apellido' => 'Nombre Apellido', + 'total' => '8000.00', + ]); + PurchaseItem::query()->create([ + 'compra_id' => $purchase->id, 'source_catalog_item_id' => $item->id, 'source_variant_id' => $variant->id, + 'nombre' => 'Remera', + 'descripcion' => '', + 'slug' => 'remera', + 'item_nombre' => 'Remera', + 'cantidad' => 1, + 'precio_unitario' => '8000.00', + 'total' => '8000.00', + ]); + $ticket = $this->createTicket($tenant, $admin, [ + 'source_purchase_id' => $purchase->id, + 'source_catalog_item_id' => $item->id, + 'source_variant_id' => $variant->id, + 'scanner_user_id' => $admin->id, + 'used_at' => now(), ]); $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() ->assertJsonPath('data.0.id', $ticket->id) + ->assertJsonPath('data.0.order_number', $purchase->id) + ->assertJsonPath('data.0.product', 'Remera') + ->assertJsonPath('data.0.amount', '8000.00') + ->assertJsonPath('data.0.status', Ticket::STATUS_USED) + ->assertJsonPath('data.0.scanned_by', $admin->nombre_apellido) ->assertJsonPath('data.0.variant_properties.0.code', 'size') ->assertJsonPath('data.0.variant_properties.0.label', 'Talle') ->assertJsonPath('data.0.variant_properties.0.values.0.value', 'xl') From b1f42775d6c8a03215aca81589240b20b57f6ced Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 28 Aug 2026 17:00:47 -0300 Subject: [PATCH 6/6] feat(tickets): add TicketFormController, TicketFormService, and TicketFormResource with related routes and tests --- .../AdminApp/TicketFormController.php | 22 ++ .../Forms/Resources/TicketFormResource.php | 18 ++ .../Forms/Services/TicketFormService.php | 234 ++++++++++++++++++ app/Domains/Forms/documentacion/README.md | 2 + app/Domains/Forms/routes/adminapp.php | 5 + phpunit.xml | 3 +- .../AdminAppTicketFormControllerTest.php | 155 ++++++++++++ tests/TestCase.php | 23 +- 8 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 app/Domains/Forms/Controllers/AdminApp/TicketFormController.php create mode 100644 app/Domains/Forms/Resources/TicketFormResource.php create mode 100644 app/Domains/Forms/Services/TicketFormService.php create mode 100644 tests/Feature/Forms/AdminAppTicketFormControllerTest.php diff --git a/app/Domains/Forms/Controllers/AdminApp/TicketFormController.php b/app/Domains/Forms/Controllers/AdminApp/TicketFormController.php new file mode 100644 index 0000000..5dc3936 --- /dev/null +++ b/app/Domains/Forms/Controllers/AdminApp/TicketFormController.php @@ -0,0 +1,22 @@ +ticketFormService->get( + $request->user('sanctum')->tenant()->firstOrFail() + ) + ); + } +} diff --git a/app/Domains/Forms/Resources/TicketFormResource.php b/app/Domains/Forms/Resources/TicketFormResource.php new file mode 100644 index 0000000..dcaa7a9 --- /dev/null +++ b/app/Domains/Forms/Resources/TicketFormResource.php @@ -0,0 +1,18 @@ + */ + public function toArray(Request $request): array + { + return [ + 'statuses' => $this->resource['statuses'], + 'categories' => $this->resource['categories'], + ]; + } +} diff --git a/app/Domains/Forms/Services/TicketFormService.php b/app/Domains/Forms/Services/TicketFormService.php new file mode 100644 index 0000000..c8690db --- /dev/null +++ b/app/Domains/Forms/Services/TicketFormService.php @@ -0,0 +1,234 @@ + + */ + private const CATEGORY_PRESENTATIONS = [ + 'entradas' => [ + 'label' => null, + 'product' => self::PRODUCT, + 'type' => null, + 'order' => 1, + ], + 'alojamientos' => [ + 'label' => 'Camping', + 'product' => 'tipo_alojamiento', + 'type' => null, + 'order' => 2, + ], + 'camping' => [ + 'label' => null, + 'product' => 'tipo_alojamiento', + 'type' => null, + 'order' => 2, + ], + 'comidas' => [ + 'label' => 'Comida', + 'product' => 'event_date', + 'type' => 'horario', + 'order' => 3, + ], + 'comida' => [ + 'label' => null, + 'product' => 'event_date', + 'type' => 'horario', + 'order' => 3, + ], + 'merchandising' => [ + 'label' => null, + 'product' => self::PRODUCT, + 'type' => 'color', + 'order' => 4, + ], + ]; + + /** + * @return array{ + * statuses: list, + * categories: list + * }> + * }> + * } + */ + public function get(Tenant $tenant): array + { + $categories = []; + + $items = CatalogItem::query() + ->where('tenant_code', $tenant->codigo) + ->where('has_tickets', true) + ->whereHas('category') + ->with([ + 'category', + 'itemAttributes.attribute.options', + 'variants.definitions.itemAttribute.attribute.options', + 'variants.eventDates', + 'variants.eventDate', + ]) + ->orderBy('group_order') + ->orderBy('nombre') + ->get(); + + foreach ($items as $item) { + $sourceCategory = trim((string) $item->category?->nombre); + $categoryValue = mb_strtolower($sourceCategory); + $presentation = self::CATEGORY_PRESENTATIONS[$categoryValue] ?? [ + 'label' => null, + 'product' => self::PRODUCT, + 'type' => null, + 'order' => PHP_INT_MAX, + ]; + + $categories[$categoryValue] ??= [ + 'value' => $categoryValue, + 'label' => $presentation['label'] ?? $sourceCategory, + 'order' => $presentation['order'], + 'products' => [], + ]; + + foreach ($this->products($item, $presentation['product'], $presentation['type']) as $product) { + $productValue = $product['value']; + $existingProduct = $categories[$categoryValue]['products'][$productValue] ?? [ + 'value' => $productValue, + 'label' => $product['label'], + 'types' => [], + ]; + + foreach ($product['types'] as $type) { + $existingProduct['types'][$type['value']] = $type; + } + + $categories[$categoryValue]['products'][$productValue] = $existingProduct; + } + } + + uasort($categories, fn (array $left, array $right): int => $left['order'] <=> $right['order'] + ?: $left['label'] <=> $right['label']); + + return [ + 'statuses' => [ + ['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'], + ['value' => Ticket::STATUS_USED, 'label' => 'Usado'], + ['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'], + ], + 'categories' => array_values(array_map( + fn (array $category): array => [ + 'value' => $category['value'], + 'label' => $category['label'], + 'products' => array_values(array_map( + fn (array $product): array => [ + 'value' => $product['value'], + 'label' => $product['label'], + 'types' => array_values($product['types']), + ], + $category['products'], + )), + ], + $categories, + )), + ]; + } + + /** + * @return list + * }> + */ + private function products(CatalogItem $item, string $productCode, ?string $typeCode): array + { + if ($productCode === self::PRODUCT) { + return [[ + 'value' => $item->slug, + 'label' => $item->nombre, + 'types' => $this->types($item, $typeCode), + ]]; + } + + $products = []; + + foreach ($item->variants as $variant) { + foreach ($this->variantOptions($variant, $productCode) as $productOption) { + $productValue = $productOption['value']; + $products[$productValue] ??= [ + 'value' => $productValue, + 'label' => $this->optionLabel($productOption['label'], $productCode), + 'types' => [], + ]; + + foreach ($this->variantOptions($variant, $typeCode) as $typeOption) { + $products[$productValue]['types'][$typeOption['value']] = $typeOption; + } + } + } + + return array_values(array_map( + fn (array $product): array => [ + 'value' => $product['value'], + 'label' => $product['label'], + 'types' => array_values($product['types']), + ], + $products, + )); + } + + /** @return list */ + private function types(CatalogItem $item, ?string $typeCode): array + { + $types = []; + + foreach ($item->variants as $variant) { + foreach ($this->variantOptions($variant, $typeCode) as $typeOption) { + $types[$typeOption['value']] = $typeOption; + } + } + + return array_values($types); + } + + /** @return list */ + private function variantOptions(Variant $variant, ?string $attributeCode): array + { + if ($attributeCode === null) { + return []; + } + + $selection = $variant->selectionOptions($variant->catalogItem->itemAttributes) + ->get($attributeCode); + + if ($selection === null) { + return []; + } + + return array_is_list($selection) ? $selection : [$selection]; + } + + private function optionLabel(string $label, string $attributeCode): string + { + if ($attributeCode !== 'event_date') { + return $label; + } + + [$day, $month] = array_pad(explode('/', $label), 2, null); + + return $day !== null && $month !== null ? "{$day}/{$month}" : $label; + } +} diff --git a/app/Domains/Forms/documentacion/README.md b/app/Domains/Forms/documentacion/README.md index 8f3a84b..9a3782d 100644 --- a/app/Domains/Forms/documentacion/README.md +++ b/app/Domains/Forms/documentacion/README.md @@ -9,6 +9,7 @@ Provee catálogos y opciones auxiliares para construir formularios del panel adm - `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant. - `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación. - `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant. +- `TicketFormService`: expone estados y opciones anidadas de categoría, producto y tipo para los tickets de Fiesta Fútbol Infantil. Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales. @@ -19,6 +20,7 @@ Bajo `/v1/adminapp/forms`, con `auth:sanctum` y `adminapp.tenant`: - `GET /event`. - `GET /sale`. - `GET /staff`. +- `GET /fiesta-futbol-infantil/ticket`: estados y jerarquía categoría → producto → tipo para filtros de tickets. - `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising. ## Dependencias diff --git a/app/Domains/Forms/routes/adminapp.php b/app/Domains/Forms/routes/adminapp.php index cb8683f..21a92d1 100644 --- a/app/Domains/Forms/routes/adminapp.php +++ b/app/Domains/Forms/routes/adminapp.php @@ -5,6 +5,7 @@ use App\Domains\Forms\Controllers\AdminApp\FoodFormController; use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController; use App\Domains\Forms\Controllers\AdminApp\SaleFormController; use App\Domains\Forms\Controllers\AdminApp\StaffFormController; +use App\Domains\Forms\Controllers\AdminApp\TicketFormController; use Illuminate\Support\Facades\Route; Route::prefix('v1/adminapp/forms') @@ -13,6 +14,10 @@ Route::prefix('v1/adminapp/forms') Route::get('event', EventFormController::class); Route::get('sale', SaleFormController::class); Route::get('staff', StaffFormController::class); + Route::get( + 'fiesta-futbol-infantil/ticket', + TicketFormController::class + ); Route::get( 'fiesta-futbol-infantil/merchandise', MerchandiseFormController::class diff --git a/phpunit.xml b/phpunit.xml index 642274a..0bce1ba 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,7 +18,8 @@ - + + diff --git a/tests/Feature/Forms/AdminAppTicketFormControllerTest.php b/tests/Feature/Forms/AdminAppTicketFormControllerTest.php new file mode 100644 index 0000000..5bb4ef3 --- /dev/null +++ b/tests/Feature/Forms/AdminAppTicketFormControllerTest.php @@ -0,0 +1,155 @@ +seed(AuthorizationSeeder::class); + } + + public function test_authentication_is_required(): void + { + $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/ticket') + ->assertUnauthorized(); + } + + public function test_it_returns_nested_ticket_options_using_the_frontend_presentation_mapping(): void + { + $headerLogo = $this->createAttachment('header.png'); + $footerLogo = $this->createAttachment('footer.png'); + $tenant = Tenant::query()->create([ + 'codigo' => 'fiesta_futbol_infantil', + 'nombre' => 'Fiesta Fútbol Infantil', + 'dominio' => 'fiesta-futbol-infantil.test', + 'primary_color' => '#00973F', + 'secondary_color' => '#A0A0A0', + 'danger_color' => '#FF8888', + 'success_color' => '#198754', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#015327', + 'header_logo_id' => $headerLogo->id, + 'footer_logo_id' => $footerLogo->id, + ]); + $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); + + Sanctum::actingAs(User::factory()->create([ + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => $tenant->codigo, + ])); + + $response = $this->getJson('/api/v1/adminapp/forms/fiesta-futbol-infantil/ticket') + ->assertOk() + ->assertExactJson([ + 'data' => [ + 'statuses' => [ + ['value' => 'active', 'label' => 'Activo'], + ['value' => 'used', 'label' => 'Usado'], + ['value' => 'expired', 'label' => 'Vencido'], + ], + 'categories' => [ + [ + 'value' => 'entradas', + 'label' => 'Entradas', + 'products' => [[ + 'value' => 'abono', + 'label' => 'Abono', + 'types' => [], + ]], + ], + [ + 'value' => 'alojamientos', + 'label' => 'Camping', + 'products' => [ + ['value' => 'Carpa', 'label' => 'Carpa', 'types' => []], + ['value' => 'Motorhome', 'label' => 'Motorhome', 'types' => []], + ], + ], + [ + 'value' => 'comidas', + 'label' => 'Comida', + 'products' => [ + [ + 'value' => (string) $tenant->eventDates[0]->id, + 'label' => '09/10', + 'types' => [ + ['value' => 'Desayuno', 'label' => 'Desayuno'], + ['value' => 'Almuerzo', 'label' => 'Almuerzo'], + ['value' => 'Cena', 'label' => 'Cena'], + ], + ], + [ + 'value' => (string) $tenant->eventDates[1]->id, + 'label' => '10/10', + 'types' => [ + ['value' => 'Desayuno', 'label' => 'Desayuno'], + ['value' => 'Almuerzo', 'label' => 'Almuerzo'], + ['value' => 'Cena', 'label' => 'Cena'], + ], + ], + [ + 'value' => (string) $tenant->eventDates[2]->id, + 'label' => '11/10', + 'types' => [ + ['value' => 'Desayuno', 'label' => 'Desayuno'], + ['value' => 'Almuerzo', 'label' => 'Almuerzo'], + ['value' => 'Cena', 'label' => 'Cena'], + ], + ], + [ + 'value' => (string) $tenant->eventDates[3]->id, + 'label' => '12/10', + 'types' => [ + ['value' => 'Desayuno', 'label' => 'Desayuno'], + ['value' => 'Almuerzo', 'label' => 'Almuerzo'], + ['value' => 'Cena', 'label' => 'Cena'], + ], + ], + ], + ], + [ + 'value' => 'merchandising', + 'label' => 'Merchandising', + 'products' => [[ + 'value' => 'camiseta', + 'label' => 'Camiseta', + 'types' => [ + ['value' => 'Verde', 'label' => 'Verde'], + ['value' => 'Blanco', 'label' => 'Blanco'], + ], + ]], + ], + ], + ], + ]); + + $response->assertJsonMissingPath('data.categories.0.products.0.category'); + } + + private function createAttachment(string $filename): Attachment + { + return Attachment::query()->create([ + 'path' => "tests/{$filename}", + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..19a12de 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,30 @@ namespace Tests; +use Illuminate\Foundation\Application; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use RuntimeException; abstract class TestCase extends BaseTestCase { - // + /** + * Boot the application only when the test database is explicitly isolated. + */ + public function createApplication(): Application + { + $app = parent::createApplication(); + + $database = (string) $app['config']->get( + 'database.connections.'.$app['config']->get('database.default').'.database' + ); + + if (! preg_match('/^shopit_(?:test|testing)(?:_\d+)?$/', $database)) { + throw new RuntimeException(sprintf( + 'Refusing to run tests against database [%s]. Use [shopit_test] or [shopit_testing].', + $database !== '' ? $database : '(empty)' + )); + } + + return $app; + } }