diff --git a/ShopIt_API_Postman_Collection.json b/ShopIt_API_Postman_Collection.json index eb7f267..55aac5a 100644 --- a/ShopIt_API_Postman_Collection.json +++ b/ShopIt_API_Postman_Collection.json @@ -2,7 +2,7 @@ "info": { "_postman_id": "76fd6fd2-53b9-4d92-8e02-1ddcc6207fa2", "name": "ShopIt API — Complete", - "description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 122 operaciones HTTP, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.", + "description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 137 operaciones HTTP, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ @@ -385,6 +385,42 @@ } }, "response": [] + }, + { + "name": "redirect Google Auth", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /auth/google/redirect`\n\nControlador: `App\\Domains\\Auth\\Controllers\\GoogleAuthController@redirect`", + "url": { + "raw": "{{base_url}}/auth/google/redirect?tenant={{tenant_code}}&return_url={{storefront_url}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "auth", + "google", + "redirect" + ], + "query": [ + { + "key": "tenant", + "value": "{{tenant_code}}" + }, + { + "key": "return_url", + "value": "{{storefront_url}}" + } + ] + } + }, + "response": [] } ] }, @@ -572,60 +608,6 @@ } }, "response": [] - }, - { - "name": "Update Checkout Cart Item Cart", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `PATCH /api/tenants/{tenant:codigo}/checkout-carts/{cart}/items/{cartItem}`\n\nControlador: `App\\Domains\\Cart\\Controllers\\CartController@updateCheckoutItem`\n\nRequiere autenticación Sanctum.", - "url": { - "raw": "{{base_url}}/api/tenants/{{tenant_code}}/checkout-carts/{{cart}}/items/{{cart_item_id}}", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "tenants", - "{{tenant_code}}", - "checkout-carts", - "{{cart}}", - "items", - "{{cart_item_id}}" - ] - }, - "body": { - "mode": "raw", - "raw": "{\n \"cantidad\": 2\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{token}}", - "type": "string" - } - ] - } - }, - "response": [] } ] }, @@ -1169,99 +1151,6 @@ }, "response": [] }, - { - "name": "Prepare Item Editing Purchase", - "request": { - "method": "POST", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `POST /api/tenants/{tenant:codigo}/compras/{compra}/edit-items`\n\nControlador: `App\\Domains\\Purchase\\Controllers\\PurchaseController@prepareItemEditing`\n\nRequiere autenticación Sanctum.", - "url": { - "raw": "{{base_url}}/api/tenants/{{tenant_code}}/compras/{{purchase_id}}/edit-items", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "tenants", - "{{tenant_code}}", - "compras", - "{{purchase_id}}", - "edit-items" - ] - }, - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{token}}", - "type": "string" - } - ] - } - }, - "response": [] - }, - { - "name": "Update Item Purchase", - "request": { - "method": "PATCH", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `PATCH /api/tenants/{tenant:codigo}/compras/{compra}/items/{item}`\n\nControlador: `App\\Domains\\Purchase\\Controllers\\PurchaseController@updateItem`\n\nRequiere autenticación Sanctum.", - "url": { - "raw": "{{base_url}}/api/tenants/{{tenant_code}}/compras/{{purchase_id}}/items/{{purchase_item_id}}", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "tenants", - "{{tenant_code}}", - "compras", - "{{purchase_id}}", - "items", - "{{purchase_item_id}}" - ] - }, - "body": { - "mode": "raw", - "raw": "{\n \"quantity\": 2,\n \"variant_id\": {{variant_id}}\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{token}}", - "type": "string" - } - ] - } - }, - "response": [] - }, { "name": "Create Payment Intent Purchase", "request": { @@ -2214,7 +2103,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"client_id\": {{client_id}},\n \"codigo\": \"{{tenant_code}}\",\n \"nombre\": \"Tenant Demo\",\n \"dominio\": \"{{tenant_domain}}\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"secondary_color\": \"#2563EB\",\n \"danger_color\": \"#DC2626\",\n \"success_color\": \"#16A34A\",\n \"header_bg_color\": \"#FFFFFF\",\n \"footer_bg_color\": \"#111827\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"search_product_layout\": \"column_with_image\",\n \"search_group_layout\": \"paginated\",\n \"search_items_per_page\": 12,\n \"display_categories\": true,\n \"display_seach_bar\": true,\n \"display_cart\": true,\n \"cart_editing_policy\": \"full\",\n \"checkout_editing_policy\": \"full\",\n \"website_type_code\": \"shopit\"\n}", + "raw": "{\n \"client_id\": {{client_id}},\n \"codigo\": \"{{tenant_code}}\",\n \"nombre\": \"Tenant Demo\",\n \"dominio\": \"{{tenant_domain}}\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"secondary_color\": \"#2563EB\",\n \"danger_color\": \"#DC2626\",\n \"success_color\": \"#16A34A\",\n \"header_bg_color\": \"#FFFFFF\",\n \"footer_bg_color\": \"#111827\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"search_product_layout\": \"column_with_image\",\n \"search_group_layout\": \"paginated\",\n \"search_items_per_page\": 12,\n \"display_categories\": true,\n \"display_seach_bar\": true,\n \"display_cart\": true,\n \"cart_editing_policy\": \"full\",\n \"website_type_code\": \"shopit\"\n}", "options": { "raw": { "language": "json" @@ -2280,7 +2169,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\",\n \"checkout_editing_policy\": \"full\"\n}", + "raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\"\n}", "options": { "raw": { "language": "json" @@ -2320,7 +2209,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\",\n \"checkout_editing_policy\": \"full\"\n}", + "raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\"\n}", "options": { "raw": { "language": "json" @@ -2663,6 +2552,45 @@ }, "response": [] }, + { + "name": "Ticket Form", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/forms/fiesta-futbol-infantil/ticket`\n\nControlador: `App\\Domains\\Forms\\Controllers\\AdminApp\\TicketFormController`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/forms/fiesta-futbol-infantil/ticket", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "forms", + "fiesta-futbol-infantil", + "ticket" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Sale Form", "request": { @@ -2738,6 +2666,44 @@ } }, "response": [] + }, + { + "name": "Ticket Filter Form", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/forms/tickets-filter`\n\nControlador: `App\\Domains\\Forms\\Controllers\\AdminApp\\TicketFilterFormController`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/forms/tickets-filter", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "forms", + "tickets-filter" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] } ] }, @@ -3058,6 +3024,84 @@ }, "response": [] }, + { + "name": "Get Category Visibility", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/accommodations/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@show`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/accommodations/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "accommodations", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Update Category Visibility", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PATCH /api/v1/adminapp/tenant/accommodations/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@update`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/accommodations/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "accommodations", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Delete Accommodation", "request": { @@ -3187,6 +3231,84 @@ }, "response": [] }, + { + "name": "Get Category Visibility", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/entries/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@show`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/entries/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "entries", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Update Category Visibility", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PATCH /api/v1/adminapp/tenant/entries/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@update`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/entries/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "entries", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Delete Entry", "request": { @@ -3316,6 +3438,84 @@ }, "response": [] }, + { + "name": "Get Category Visibility", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/foods/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@show`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/foods/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "foods", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Update Category Visibility", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PATCH /api/v1/adminapp/tenant/foods/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@update`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/foods/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "foods", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Delete Food", "request": { @@ -3445,6 +3645,84 @@ }, "response": [] }, + { + "name": "Get Category Visibility", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/merchandise/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@show`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/merchandise/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "merchandise", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Update Category Visibility", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PATCH /api/v1/adminapp/tenant/merchandise/visibility`\n\nControlador: `App\\Domains\\FiestaFutbolInfantil\\Controllers\\CategoryVisibilityController@update`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/merchandise/visibility", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "merchandise", + "visibility" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Delete Merchandise", "request": { @@ -4049,6 +4327,45 @@ }, "response": [] }, + { + "name": "download Excel Sale", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/sales/excel`\n\nControlador: `App\\Domains\\Sale\\Controllers\\AdminApp\\SaleController@downloadExcel`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/sales/excel", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "sales", + "excel" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "List Modifications Sale", "request": { @@ -4088,6 +4405,46 @@ }, "response": [] }, + { + "name": "download Modifications Excel Sale", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/sales/modifications/excel`\n\nControlador: `App\\Domains\\Sale\\Controllers\\AdminApp\\SaleController@downloadModificationsExcel`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/sales/modifications/excel", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "sales", + "modifications", + "excel" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Download Modifications PDF Sale", "request": { @@ -4601,6 +4958,127 @@ } ] }, + { + "name": "Ticket", + "item": [ + { + "name": "List Ticket", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/tickets`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\AdminApp\\TicketController@index`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/tickets", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "tickets" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "download Excel Ticket", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/tickets/excel`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\AdminApp\\TicketController@downloadExcel`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/tickets/excel", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "tickets", + "excel" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Download PDF Ticket", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/v1/adminapp/tenant/tickets/pdf`\n\nControlador: `App\\Domains\\Ticket\\Controllers\\AdminApp\\TicketController@downloadPdf`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/v1/adminapp/tenant/tickets/pdf", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "v1", + "adminapp", + "tenant", + "tickets", + "pdf" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{admin_token}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ] + }, { "name": "Tenant", "item": [ @@ -5254,6 +5732,11 @@ "value": "http://localhost:8000", "type": "string" }, + { + "key": "storefront_url", + "value": "http://localhost:4200", + "type": "string" + }, { "key": "token", "value": "", diff --git a/app/Domains/Ticket/Services/LoadTestTicketDatasetService.php b/app/Domains/Ticket/Services/LoadTestTicketDatasetService.php new file mode 100644 index 0000000..89bf279 --- /dev/null +++ b/app/Domains/Ticket/Services/LoadTestTicketDatasetService.php @@ -0,0 +1,229 @@ + + * } + */ + public function prepare( + string $tenantCode, + int $ticketCount, + int $scannerCount, + int $ownerCount, + ?int $catalogItemId = null, + ?int $variantId = null, + ?string $runId = null, + ): array { + $this->validateInput($tenantCode, $ticketCount, $scannerCount, $ownerCount); + + $tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail(); + $catalogItem = $this->resolveCatalogItem($tenant, $catalogItemId); + $variant = $this->resolveVariant($catalogItem, $variantId); + $runId ??= now()->format('Ymd-His').'-'.Str::lower(Str::random(6)); + + if (preg_match('/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/', $runId) !== 1) { + throw new InvalidArgumentException('run_id contiene caracteres inválidos o es demasiado largo.'); + } + + if ($variant !== null && ! $this->validityResolver->resolveVariant($variant)->isValid()) { + throw new InvalidArgumentException( + 'La variante seleccionada no tiene una vigencia activa y resoluble.' + ); + } + + $scanners = $this->scanners($tenant, $catalogItem, $scannerCount); + $owners = $this->owners($tenant, $ownerCount); + $tokens = $scanners->map(function (User $scanner): string { + $scanner->tokens()->where('name', 'load-test-scanner')->delete(); + + return $scanner->createToken( + 'load-test-scanner', + ['scanner'], + now()->addMinutes((int) config('sanctum.expiration', 720)), + )->plainTextToken; + })->values(); + + $rows = []; + $remaining = $ticketCount; + $ownerIndex = 0; + $scannerIndex = 0; + $batchSize = min(500, max(1, (int) ceil($ticketCount / $ownerCount))); + + while ($remaining > 0) { + $quantity = min($batchSize, $remaining); + $owner = $owners[$ownerIndex % $owners->count()]; + $tickets = $this->ticketGenerator->generate( + $catalogItem, + $owner, + $quantity, + $variant?->getKey(), + ); + + foreach ($tickets as $ticket) { + if (! $ticket->is_valid) { + throw new InvalidArgumentException( + 'La configuración seleccionada genera tickets que no están vigentes.' + ); + } + + $rows[] = [ + 'scanner_token' => $tokens[$scannerIndex % $tokens->count()], + 'ticket_uuid' => $ticket->ticket, + 'expected_status' => 200, + ]; + $scannerIndex++; + } + + $remaining -= $quantity; + $ownerIndex++; + } + + return [ + 'run_id' => $runId, + 'tenant_code' => $tenant->codigo, + 'catalog_item_id' => $catalogItem->getKey(), + 'variant_id' => $variant?->getKey(), + 'tickets' => count($rows), + 'scanners' => $scanners->count(), + 'owners' => $owners->count(), + 'rows' => $rows, + ]; + } + + private function validateInput( + string $tenantCode, + int $ticketCount, + int $scannerCount, + int $ownerCount, + ): void { + foreach ([ + 'tickets' => [$ticketCount, 100_000], + 'scanners' => [$scannerCount, 10_000], + 'owners' => [$ownerCount, 100_000], + ] as $name => [$value, $maximum]) { + if ($value < 1 || $value > $maximum) { + throw new InvalidArgumentException("{$name} debe estar entre 1 y {$maximum}."); + } + } + + if ($scannerCount > $ticketCount || $ownerCount > $ticketCount) { + throw new InvalidArgumentException( + 'La cantidad de scanners y propietarios no puede superar la cantidad de tickets.' + ); + } + } + + private function resolveCatalogItem(Tenant $tenant, ?int $catalogItemId): CatalogItem + { + $query = $tenant->catalogItems() + ->where('has_tickets', true) + ->where('type', CatalogItemType::Standard->value); + + if ($catalogItemId !== null) { + $query->whereKey($catalogItemId); + } + + $catalogItem = $query->first(); + + if ($catalogItem === null) { + throw new InvalidArgumentException( + 'No se encontró un producto estándar con tickets habilitados para el tenant.' + ); + } + + if ($tenant->requiresScannerCategoryValidation() && $catalogItem->category_id === null) { + throw new InvalidArgumentException( + 'El producto debe tener una categoría para autorizar a los scanners.' + ); + } + + return $catalogItem; + } + + private function resolveVariant(CatalogItem $catalogItem, ?int $variantId): ?Variant + { + if ($variantId === null) { + return null; + } + + $variant = $catalogItem->variants()->whereKey($variantId)->first(); + + if ($variant === null) { + throw new InvalidArgumentException('La variante no pertenece al producto seleccionado.'); + } + + return $variant; + } + + /** @return Collection */ + private function scanners(Tenant $tenant, CatalogItem $catalogItem, int $count): Collection + { + return Collection::times($count, function (int $number) use ($tenant, $catalogItem): User { + $scanner = User::query()->updateOrCreate( + ['email' => $this->email($tenant, 'scanner', $number)], + [ + 'nombre_apellido' => "Load test scanner {$number}", + 'password' => Str::password(32), + 'rol_codigo' => RoleCode::Scanner->value, + 'tenant_codigo' => $tenant->codigo, + ], + ); + if ($tenant->requiresScannerCategoryValidation()) { + $scanner->scanCategories()->syncWithoutDetaching([$catalogItem->category_id]); + } + + return $scanner; + }); + } + + /** @return Collection */ + private function owners(Tenant $tenant, int $count): Collection + { + return Collection::times($count, function (int $number) use ($tenant): User { + $owner = User::query()->updateOrCreate( + ['email' => $this->email($tenant, 'owner', $number)], + [ + 'nombre_apellido' => "Load test owner {$number}", + 'password' => Str::password(32), + 'rol_codigo' => RoleCode::User->value, + 'tenant_codigo' => $tenant->codigo, + ], + ); + + return $owner; + }); + } + + private function email(Tenant $tenant, string $kind, int $number): string + { + $tenantSlug = Str::lower(preg_replace('/[^a-z0-9]+/i', '-', $tenant->codigo)); + + return "loadtest+{$tenantSlug}.{$kind}.{$number}@shopit.test"; + } +} diff --git a/app/Domains/Ticket/documentacion/README.md b/app/Domains/Ticket/documentacion/README.md index 21d06ae..d4ced7f 100644 --- a/app/Domains/Ticket/documentacion/README.md +++ b/app/Domains/Ticket/documentacion/README.md @@ -24,6 +24,33 @@ vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin sin pe 3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia. 4. `Notification` envía la confirmación de compra después de la generación y adjunta los tickets cuando existen. +## Datos descartables para pruebas de carga + +En ambientes `local`, `testing`, `staging`, `homo` u `homologation`, el comando siguiente crea tickets +válidos, identidades scanner con tokens Sanctum y un dataset JSON importable por Postman: + +```bash +php artisan load-test:tickets:prepare loadtest-evento \ + --tickets=40000 \ + --scanners=100 \ + --owners=1000 \ + --catalog-item=123 \ + --run=evento-001 +``` + +El tenant debe existir y se recomienda que sea exclusivo para carga. Si no se indica `--catalog-item`, +se usa el primer producto estándar del tenant con tickets habilitados. `--variant` +es opcional; al indicarlo, su configuración de vigencia debe estar activa y ser resoluble. Sin variante, +los tickets tienen vigencia irrestricta. + +El archivo se escribe por defecto en `storage/app/private/load-tests/` y contiene tokens secretos, por +lo que no debe versionarse. Para limpiar el tenant después de la ejecución: + +```bash +php artisan tenants:reset-transactions loadtest-evento --dry-run +php artisan tenants:reset-transactions loadtest-evento +``` + ## Endpoints Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`: diff --git a/postman/generate-shopit-collection.php b/postman/generate-shopit-collection.php index a63f36e..2aaf14a 100644 --- a/postman/generate-shopit-collection.php +++ b/postman/generate-shopit-collection.php @@ -11,7 +11,7 @@ declare(strict_types=1); $root = dirname(__DIR__); chdir($root); -$command = escapeshellarg(PHP_BINARY).' artisan route:list --path=api --json'; +$command = escapeshellarg(PHP_BINARY).' artisan route:list --json'; $routeJson = shell_exec($command); if (! is_string($routeJson) || trim($routeJson) === '') { @@ -20,6 +20,11 @@ if (! is_string($routeJson) || trim($routeJson) === '') { } $routes = json_decode($routeJson, true, flags: JSON_THROW_ON_ERROR); +$routes = array_values(array_filter( + $routes, + fn (array $route): bool => str_starts_with($route['uri'], 'api/') + || $route['uri'] === 'auth/google/redirect', +)); const TINY_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; @@ -177,6 +182,10 @@ function bodyFor(string $method, string $uri): ?array function queryFor(string $uri): array { return match ($uri) { + 'auth/google/redirect' => [ + ['key' => 'tenant', 'value' => '{{tenant_code}}'], + ['key' => 'return_url', 'value' => '{{storefront_url}}'], + ], 'api/tenants/bootstrap' => [['key' => 'dominio', 'value' => '{{tenant_domain}}'], ['key' => 'path', 'value' => '/']], 'api/v1/adminapp/bootstrap/{dominio}', 'api/v1/scanner/bootstrap/{dominio}' => [['key' => 'path', 'value' => '/']], 'api/tenants/{tenant:codigo}/catalog-items' => [['key' => 'q', 'value' => 'demo'], ['key' => 'page', 'value' => '1']], @@ -415,6 +424,7 @@ foreach ($tree as $section => $folders) { $variables = [ 'base_url' => 'http://localhost:8000', + 'storefront_url' => 'http://localhost:4200', 'token' => '', 'admin_token' => '', 'scanner_token' => '', 'user_email' => 'usuario@example.com', 'user_password' => 'Password!123', 'admin_email' => 'admin@example.com', 'admin_password' => 'Password!123', diff --git a/routes/console.php b/routes/console.php index e9597ab..1d98696 100644 --- a/routes/console.php +++ b/routes/console.php @@ -3,9 +3,11 @@ use App\Domains\Auth\Services\AdminCredentialVerifier; use App\Domains\Catalog\Services\ExpireStockReservationsService; use App\Domains\Purchase\Services\TenantTransactionResetService; +use App\Domains\Ticket\Services\LoadTestTicketDatasetService; use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Schedule; +use Illuminate\Support\Facades\Storage; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); @@ -24,6 +26,79 @@ Schedule::command('reservations:expire') ->everyMinute() ->withoutOverlapping(); +Artisan::command( + 'load-test:tickets:prepare + {tenant : Código del tenant que recibirá los datos de carga} + {--tickets=1000 : Cantidad de tickets válidos} + {--scanners=10 : Cantidad de identidades scanner} + {--owners=100 : Cantidad de propietarios de tickets} + {--catalog-item= : Producto estándar con tickets habilitados} + {--variant= : Variante opcional que define la vigencia} + {--run= : Identificador opcional de la ejecución} + {--output= : Ruta relativa dentro del disco local}', + function (LoadTestTicketDatasetService $datasetService): int { + if (! app()->environment(['local', 'testing', 'staging', 'homo', 'homologation'])) { + $this->error('Este comando sólo puede ejecutarse en local u homologación.'); + + return self::FAILURE; + } + + $requestedOutput = filled($this->option('output')) + ? ltrim((string) $this->option('output'), '/\\') + : null; + + if ($requestedOutput !== null + && ($requestedOutput === '' || str_contains(str_replace('\\', '/', $requestedOutput), '../'))) { + $this->error('La ruta de salida debe permanecer dentro del disco local.'); + + return self::FAILURE; + } + + try { + $dataset = $datasetService->prepare( + (string) $this->argument('tenant'), + (int) $this->option('tickets'), + (int) $this->option('scanners'), + (int) $this->option('owners'), + filled($this->option('catalog-item')) ? (int) $this->option('catalog-item') : null, + filled($this->option('variant')) ? (int) $this->option('variant') : null, + filled($this->option('run')) ? (string) $this->option('run') : null, + ); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $relativePath = $requestedOutput !== null + ? $requestedOutput + : "load-tests/{$dataset['run_id']}.postman.json"; + $payload = json_encode($dataset['rows'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + if (! is_string($payload) || ! Storage::disk('local')->put($relativePath, $payload.PHP_EOL)) { + $this->error('No se pudo escribir el dataset.'); + + return self::FAILURE; + } + + $this->info('Dataset de carga generado.'); + $this->table(['Dato', 'Valor'], [ + ['run_id', $dataset['run_id']], + ['tenant', $dataset['tenant_code']], + ['catalog_item_id', $dataset['catalog_item_id']], + ['variant_id', $dataset['variant_id'] ?? 'sin variante (vigencia irrestricta)'], + ['tickets', $dataset['tickets']], + ['scanners', $dataset['scanners']], + ['owners', $dataset['owners']], + ['archivo', Storage::disk('local')->path($relativePath)], + ]); + $this->warn('El archivo contiene tokens secretos. No lo subas al repositorio.'); + $this->comment("Limpieza: php artisan tenants:reset-transactions {$dataset['tenant_code']}"); + + return self::SUCCESS; + }, +)->purpose('Prepare disposable scanner tickets and a Postman performance dataset'); + Artisan::command( 'tenants:reset-transactions {tenant : Código del tenant que se limpiará} diff --git a/tests/Feature/Ticket/PrepareLoadTestTicketsCommandTest.php b/tests/Feature/Ticket/PrepareLoadTestTicketsCommandTest.php new file mode 100644 index 0000000..b7e2867 --- /dev/null +++ b/tests/Feature/Ticket/PrepareLoadTestTicketsCommandTest.php @@ -0,0 +1,107 @@ +seed(AuthorizationSeeder::class); + WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); + $tenant = $this->createTenant('acme'); + $category = Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Entradas de carga', + ]); + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'category_id' => $category->id, + 'slug' => 'entrada-load-test', + 'nombre' => 'Entrada load test', + 'descripcion' => 'Ticket descartable', + 'precio' => 100, + 'has_tickets' => true, + ]); + + $exitCode = Artisan::call('load-test:tickets:prepare', [ + 'tenant' => $tenant->codigo, + '--tickets' => 4, + '--scanners' => 2, + '--owners' => 2, + '--catalog-item' => $catalogItem->id, + '--run' => 'test-run', + '--output' => 'load-tests/test-run.postman.json', + ]); + + $this->assertSame(0, $exitCode, Artisan::output()); + + Storage::disk('local')->assertExists('load-tests/test-run.postman.json'); + $rows = json_decode( + Storage::disk('local')->get('load-tests/test-run.postman.json'), + true, + flags: JSON_THROW_ON_ERROR, + ); + + $this->assertCount(4, $rows); + $this->assertCount(4, collect($rows)->pluck('ticket_uuid')->unique()); + $this->assertCount(2, collect($rows)->pluck('scanner_token')->unique()); + $this->assertSame([200], collect($rows)->pluck('expected_status')->unique()->values()->all()); + $this->assertDatabaseCount('tickets', 4); + $this->assertCount(2, Ticket::query()->distinct()->pluck('user_id')); + $this->assertDatabaseCount('category_scanners', 2); + + $first = $rows[0]; + $this->withToken($first['scanner_token']) + ->postJson("/api/v1/scanner/tickets/{$first['ticket_uuid']}/scan") + ->assertOk() + ->assertJsonPath('data.ticket', $first['ticket_uuid']); + + $this->assertNotNull( + Ticket::query()->where('ticket', $first['ticket_uuid'])->firstOrFail()->used_at + ); + } + + private function createTenant(string $code): Tenant + { + $logo = Attachment::query()->create([ + 'key' => (string) Str::uuid(), + 'path' => "tests/{$code}-logo.png", + 'filename' => "{$code}-logo.png", + 'type' => 'image', + 'mime_type' => 'image/png', + 'extension' => 'png', + 'size' => 1, + ]); + + return Tenant::query()->create([ + 'codigo' => $code, + 'nombre' => ucfirst($code), + 'dominio' => "{$code}.test", + 'primary_color' => '#ff7006', + 'secondary_color' => '#777777', + 'danger_color' => '#e04a4a', + 'success_color' => '#81bc73', + 'header_bg_color' => '#313131', + 'footer_bg_color' => '#313131', + 'header_logo_id' => $logo->id, + 'footer_logo_id' => $logo->id, + 'website_type_code' => 'onticket', + ]); + } +}