diff --git a/ShopIt_API_Postman_Collection.json b/ShopIt_API_Postman_Collection.json index 55aac5a..b895726 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 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`.", + "description": "Colección canónica generada desde las rutas reales de Laravel. Incluye 137 operaciones HTTP, presets de configuración para cada integración, 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": [ @@ -1425,14 +1425,14 @@ ], "description": "Ruta Laravel: `GET /api/clients/{client}`\n\nControlador: `App\\Domains\\Client\\Controllers\\ClientController@show`", "url": { - "raw": "{{base_url}}/api/clients/{{client_id}}", + "raw": "{{base_url}}/api/clients/{{client_code}}", "host": [ "{{base_url}}" ], "path": [ "api", "clients", - "{{client_id}}" + "{{client_code}}" ] } }, @@ -1456,14 +1456,14 @@ ], "description": "Ruta Laravel: `PUT /api/clients/{client}`\n\nControlador: `App\\Domains\\Client\\Controllers\\ClientController@update`", "url": { - "raw": "{{base_url}}/api/clients/{{client_id}}", + "raw": "{{base_url}}/api/clients/{{client_code}}", "host": [ "{{base_url}}" ], "path": [ "api", "clients", - "{{client_id}}" + "{{client_code}}" ] }, "body": { @@ -1496,14 +1496,14 @@ ], "description": "Ruta Laravel: `PATCH /api/clients/{client}`\n\nControlador: `App\\Domains\\Client\\Controllers\\ClientController@update`", "url": { - "raw": "{{base_url}}/api/clients/{{client_id}}", + "raw": "{{base_url}}/api/clients/{{client_code}}", "host": [ "{{base_url}}" ], "path": [ "api", "clients", - "{{client_id}}" + "{{client_code}}" ] }, "body": { @@ -1531,14 +1531,14 @@ ], "description": "Ruta Laravel: `DELETE /api/clients/{client}`\n\nControlador: `App\\Domains\\Client\\Controllers\\ClientController@destroy`", "url": { - "raw": "{{base_url}}/api/clients/{{client_id}}", + "raw": "{{base_url}}/api/clients/{{client_code}}", "host": [ "{{base_url}}" ], "path": [ "api", "clients", - "{{client_id}}" + "{{client_code}}" ] } }, @@ -1550,257 +1550,704 @@ "name": "Integration", "item": [ { - "name": "List Client Integration", - "request": { - "method": "GET", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `GET /api/clients/{client}/integrations`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@index`", - "url": { - "raw": "{{base_url}}/api/clients/{{client_id}}/integrations", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "clients", - "{{client_id}}", - "integrations" - ] - } - }, - "response": [] - }, - { - "name": "Get Client Integration", - "request": { - "method": "GET", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `GET /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@show`", - "url": { - "raw": "{{base_url}}/api/clients/{{client_id}}/integrations/{{integration_code}}", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "clients", - "{{client_id}}", - "integrations", - "{{integration_code}}" - ] - } - }, - "response": [] - }, - { - "name": "Create Client Integration", - "request": { - "method": "PUT", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `PUT /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@store`", - "url": { - "raw": "{{base_url}}/api/clients/{{client_id}}/integrations/{{integration_code}}", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "clients", - "{{client_id}}", - "integrations", - "{{integration_code}}" + "name": "Client", + "item": [ + { + "name": "Management", + "description": "Consulta, configura o desvincula cualquier integración usando `{{integration_code}}`.", + "item": [ + { + "name": "List Client Integration", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/clients/{client}/integrations`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@index`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/clients/{{client_code}}/integrations", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "clients", + "{{client_code}}", + "integrations" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Client Integration", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@show`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/clients/{{client_code}}/integrations/{{integration_code}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "clients", + "{{client_code}}", + "integrations", + "{{integration_code}}" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Configure Integration (generic)", + "request": { + "method": "PUT", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PUT /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@store`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/clients/{{client_code}}/integrations/{{integration_code}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "clients", + "{{client_code}}", + "integrations", + "{{integration_code}}" + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"integration_data\": {\n \"api_key\": \"replace-me\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Delete Client Integration", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `DELETE /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@destroy`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/clients/{{client_code}}/integrations/{{integration_code}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "clients", + "{{client_code}}", + "integrations", + "{{integration_code}}" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] + } ] }, - "body": { - "mode": "raw", - "raw": "{\n \"integration_data\": {\n \"api_key\": \"replace-me\"\n }\n}", - "options": { - "raw": { - "language": "json" + { + "name": "Email (SMTP)", + "description": "Configura el transporte SMTP usado para el envío de correos.", + "item": [ + { + "name": "Configure Email (SMTP)", + "request": { + "method": "PUT", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PUT /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `email`. Configura el transporte SMTP usado para el envío de correos.", + "url": { + "raw": "{{base_url}}/api/clients/{{client_code}}/integrations/email", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "clients", + "{{client_code}}", + "integrations", + "email" + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"integration_data\": {\n \"MAIL_MAILER\": \"smtp\",\n \"MAIL_SCHEME\": \"smtp\",\n \"MAIL_HOST\": \"smtp.example.com\",\n \"MAIL_PORT\": 587,\n \"MAIL_USERNAME\": \"usuario@example.com\",\n \"MAIL_PASSWORD\": \"replace-me\",\n \"MAIL_FROM_ADDRESS\": \"no-reply@example.com\",\n \"MAIL_FROM_NAME\": \"ShopIt\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] } - } - } - }, - "response": [] - }, - { - "name": "List Integration", - "request": { - "method": "GET", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `GET /api/integrations`\n\nControlador: `App\\Domains\\Integration\\Controllers\\IntegrationController@index`", - "url": { - "raw": "{{base_url}}/api/integrations", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "integrations" - ] - } - }, - "response": [] - }, - { - "name": "Create Integration", - "request": { - "method": "POST", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `POST /api/integrations`\n\nControlador: `App\\Domains\\Integration\\Controllers\\IntegrationController@store`", - "url": { - "raw": "{{base_url}}/api/integrations", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "integrations" ] }, - "body": { - "mode": "raw", - "raw": "{\n \"integration_code\": \"telepagos\",\n \"name\": \"Telepagos\",\n \"url\": \"https://api.example.com\",\n \"integration_data_schema\": {\n \"api_key\": [\n \"required\",\n \"string\"\n ]\n },\n \"requires_client_configuration\": true\n}", - "options": { - "raw": { - "language": "json" + { + "name": "Telepagos Producción", + "description": "Configura las credenciales productivas de Telepagos.", + "item": [ + { + "name": "Configure Telepagos Producción", + "request": { + "method": "PUT", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PUT /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `telepagos`. Configura las credenciales productivas de Telepagos.", + "url": { + "raw": "{{base_url}}/api/clients/{{client_code}}/integrations/telepagos", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "clients", + "{{client_code}}", + "integrations", + "telepagos" + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"integration_data\": {\n \"username\": \"replace-me\",\n \"password\": \"replace-me\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] } - } - } - }, - "response": [] - }, - { - "name": "Get Integration", - "request": { - "method": "GET", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `GET /api/integrations/{integration}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\IntegrationController@show`", - "url": { - "raw": "{{base_url}}/api/integrations/{{integration_id}}", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "integrations", - "{{integration_id}}" - ] - } - }, - "response": [] - }, - { - "name": "Update Integration", - "request": { - "method": "PUT", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - }, - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `PUT /api/integrations/{integration}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\IntegrationController@update`", - "url": { - "raw": "{{base_url}}/api/integrations/{{integration_id}}", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "integrations", - "{{integration_id}}" ] }, - "body": { - "mode": "raw", - "raw": "{\n \"name\": \"Telepagos\",\n \"url\": \"https://api.example.com\",\n \"requires_client_configuration\": true\n}", - "options": { - "raw": { - "language": "json" + { + "name": "Telepagos Homologación", + "description": "Configura las credenciales del entorno de homologación de Telepagos.", + "item": [ + { + "name": "Configure Telepagos Homologación", + "request": { + "method": "PUT", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PUT /api/clients/{client}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\ClientIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `telepagos_homo`. Configura las credenciales del entorno de homologación de Telepagos.", + "url": { + "raw": "{{base_url}}/api/clients/{{client_code}}/integrations/telepagos_homo", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "clients", + "{{client_code}}", + "integrations", + "telepagos_homo" + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"integration_data\": {\n \"username\": \"replace-me\",\n \"password\": \"replace-me\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] } - } - } - }, - "response": [] - }, - { - "name": "Delete Integration", - "request": { - "method": "DELETE", - "header": [ - { - "key": "Accept", - "value": "application/json", - "type": "text" - } - ], - "description": "Ruta Laravel: `DELETE /api/integrations/{integration}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\IntegrationController@destroy`", - "url": { - "raw": "{{base_url}}/api/integrations/{{integration_id}}", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "integrations", - "{{integration_id}}" ] } - }, - "response": [] + ] + }, + { + "name": "Website Type", + "item": [ + { + "name": "Management", + "description": "Consulta, configura o desvincula cualquier integración usando `{{integration_code}}`.", + "item": [ + { + "name": "List Website Type Integration", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/website-types/{websiteType:codigo}/integrations`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@index`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "website-types", + "{{website_type_code}}", + "integrations" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Website Type Integration", + "request": { + "method": "GET", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `GET /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@show`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/{{integration_code}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "website-types", + "{{website_type_code}}", + "integrations", + "{{integration_code}}" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Configure Integration (generic)", + "request": { + "method": "PUT", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PUT /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/{{integration_code}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "website-types", + "{{website_type_code}}", + "integrations", + "{{integration_code}}" + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"integration_data\": {\n \"api_key\": \"replace-me\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Delete Website Type Integration", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `DELETE /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@destroy`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/{{integration_code}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "website-types", + "{{website_type_code}}", + "integrations", + "{{integration_code}}" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Email (SMTP)", + "description": "Configura el transporte SMTP usado para el envío de correos.", + "item": [ + { + "name": "Configure Email (SMTP)", + "request": { + "method": "PUT", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PUT /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `email`. Configura el transporte SMTP usado para el envío de correos.", + "url": { + "raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/email", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "website-types", + "{{website_type_code}}", + "integrations", + "email" + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"integration_data\": {\n \"MAIL_MAILER\": \"smtp\",\n \"MAIL_SCHEME\": \"smtp\",\n \"MAIL_HOST\": \"smtp.example.com\",\n \"MAIL_PORT\": 587,\n \"MAIL_USERNAME\": \"usuario@example.com\",\n \"MAIL_PASSWORD\": \"replace-me\",\n \"MAIL_FROM_ADDRESS\": \"no-reply@example.com\",\n \"MAIL_FROM_NAME\": \"ShopIt\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Telepagos Producción", + "description": "Configura las credenciales productivas de Telepagos.", + "item": [ + { + "name": "Configure Telepagos Producción", + "request": { + "method": "PUT", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PUT /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `telepagos`. Configura las credenciales productivas de Telepagos.", + "url": { + "raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/telepagos", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "website-types", + "{{website_type_code}}", + "integrations", + "telepagos" + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"integration_data\": {\n \"username\": \"replace-me\",\n \"password\": \"replace-me\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Telepagos Homologación", + "description": "Configura las credenciales del entorno de homologación de Telepagos.", + "item": [ + { + "name": "Configure Telepagos Homologación", + "request": { + "method": "PUT", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PUT /api/website-types/{websiteType:codigo}/integrations/{integration_code}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\WebsiteTypeIntegrationController@store`\n\nRequiere autenticación Sanctum.\n\nPreset: `telepagos_homo`. Configura las credenciales del entorno de homologación de Telepagos.", + "url": { + "raw": "{{base_url}}/api/website-types/{{website_type_code}}/integrations/telepagos_homo", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "website-types", + "{{website_type_code}}", + "integrations", + "telepagos_homo" + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"integration_data\": {\n \"username\": \"replace-me\",\n \"password\": \"replace-me\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ] + } + ] } ] }, @@ -5698,7 +6145,7 @@ ], "description": "Ruta Laravel: `POST /api/webhooks/telepagos/{client}`\n\nControlador: `App\\Domains\\Integration\\Controllers\\TelepagosWebhookController@handle`", "url": { - "raw": "{{base_url}}/api/webhooks/telepagos/{{client_id}}", + "raw": "{{base_url}}/api/webhooks/telepagos/{{client_code}}", "host": [ "{{base_url}}" ], @@ -5706,7 +6153,7 @@ "api", "webhooks", "telepagos", - "{{client_id}}" + "{{client_code}}" ] }, "body": { @@ -5803,8 +6250,13 @@ "type": "string" }, { - "key": "integration_id", - "value": "1", + "key": "client_code", + "value": "cliente-demo", + "type": "string" + }, + { + "key": "website_type_code", + "value": "shopit", "type": "string" }, { diff --git a/app/Domains/Integration/Controllers/ClientIntegrationController.php b/app/Domains/Integration/Controllers/ClientIntegrationController.php index 9b243cf..bbc3bee 100644 --- a/app/Domains/Integration/Controllers/ClientIntegrationController.php +++ b/app/Domains/Integration/Controllers/ClientIntegrationController.php @@ -4,8 +4,9 @@ namespace App\Domains\Integration\Controllers; use App\Domains\Client\Models\Client; use App\Domains\Integration\Models\Integration; -use App\Domains\Integration\Requests\StoreClientIntegrationRequest; +use App\Domains\Integration\Requests\ConfigureIntegrationRequest; use App\Domains\Integration\Services\ClientIntegrationService; +use App\Domains\Integration\Services\IntegrationAssociationService; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; @@ -35,7 +36,7 @@ class ClientIntegrationController extends Controller } public function store( - StoreClientIntegrationRequest $request, + ConfigureIntegrationRequest $request, Client $client, string $integrationCode, ): JsonResponse { @@ -61,4 +62,11 @@ class ClientIntegrationController extends Controller ], 400); } } + + public function destroy(Client $client, string $integrationCode, IntegrationAssociationService $service) + { + $service->detach($client, $integrationCode); + + return response()->noContent(); + } } diff --git a/app/Domains/Integration/Controllers/IntegrationController.php b/app/Domains/Integration/Controllers/IntegrationController.php index 907d96d..2d7de19 100644 --- a/app/Domains/Integration/Controllers/IntegrationController.php +++ b/app/Domains/Integration/Controllers/IntegrationController.php @@ -35,6 +35,7 @@ class IntegrationController extends Controller public function destroy(Integration $integration) { + abort_if($integration->instances()->exists(), 409, 'Delete the integration instances first.'); $integration->delete(); return response()->noContent(); diff --git a/app/Domains/Integration/Controllers/WebsiteTypeIntegrationController.php b/app/Domains/Integration/Controllers/WebsiteTypeIntegrationController.php new file mode 100644 index 0000000..847ac39 --- /dev/null +++ b/app/Domains/Integration/Controllers/WebsiteTypeIntegrationController.php @@ -0,0 +1,43 @@ +integrations()->with('integrationInstance')->get()); + } + + public function show(WebsiteType $websiteType, string $integrationCode) + { + return new IntegrationAssociationResource($websiteType->integrations()->with('integrationInstance')->where('integration_code', $integrationCode)->firstOrFail()); + } + + public function store(ConfigureIntegrationRequest $request, WebsiteType $websiteType, string $integrationCode) + { + $integration = Integration::query()->where('integration_code', $integrationCode)->firstOrFail(); + + return new IntegrationAssociationResource($this->service->configure( + $websiteType, + $integration, + $request->validated('integration_data'), + )); + } + + public function destroy(WebsiteType $websiteType, string $integrationCode) + { + $this->service->detach($websiteType, $integrationCode); + + return response()->noContent(); + } +} diff --git a/app/Domains/Integration/Models/ClientIntegration.php b/app/Domains/Integration/Models/ClientIntegration.php index 676bddb..999b7ce 100644 --- a/app/Domains/Integration/Models/ClientIntegration.php +++ b/app/Domains/Integration/Models/ClientIntegration.php @@ -3,22 +3,15 @@ namespace App\Domains\Integration\Models; use App\Domains\Client\Models\Client; -use App\Domains\Integration\Casts\EncryptedIntegrationData; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class ClientIntegration extends Model { - protected $hidden = ['integration_data']; - protected $fillable = [ 'client_id', 'integration_code', - 'integration_data', - ]; - - protected $casts = [ - 'integration_data' => EncryptedIntegrationData::class, + 'integration_instance_id', ]; /** @return BelongsTo */ @@ -32,4 +25,10 @@ class ClientIntegration extends Model { return $this->belongsTo(Integration::class, 'integration_code', 'integration_code'); } + + /** @return BelongsTo */ + public function integrationInstance(): BelongsTo + { + return $this->belongsTo(IntegrationInstance::class); + } } diff --git a/app/Domains/Integration/Models/Integration.php b/app/Domains/Integration/Models/Integration.php index c384cbf..ea03ae8 100644 --- a/app/Domains/Integration/Models/Integration.php +++ b/app/Domains/Integration/Models/Integration.php @@ -3,6 +3,7 @@ namespace App\Domains\Integration\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; class Integration extends Model { @@ -13,16 +14,29 @@ class Integration extends Model 'name', 'url', 'integration_data_schema', - 'requires_client_configuration', + 'requires_configuration', ]; protected $casts = [ 'integration_data_schema' => 'array', - 'requires_client_configuration' => 'boolean', + 'requires_configuration' => 'boolean', ]; - public function clientIntegrations() + /** @return HasMany */ + public function instances(): HasMany + { + return $this->hasMany(IntegrationInstance::class, 'integration_code', 'integration_code'); + } + + /** @return HasMany */ + public function clientIntegrations(): HasMany { return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code'); } + + /** @return HasMany */ + public function websiteTypeIntegrations(): HasMany + { + return $this->hasMany(WebsiteTypeIntegration::class, 'integration_code', 'integration_code'); + } } diff --git a/app/Domains/Integration/Models/IntegrationInstance.php b/app/Domains/Integration/Models/IntegrationInstance.php new file mode 100644 index 0000000..db242eb --- /dev/null +++ b/app/Domains/Integration/Models/IntegrationInstance.php @@ -0,0 +1,41 @@ +id.':'.hash('sha256', (string) $this->getRawOriginal('integration_data')); + } + + protected $fillable = ['integration_code', 'name', 'integration_data']; + + protected $hidden = ['integration_data']; + + protected $casts = ['integration_data' => EncryptedIntegrationData::class]; + + /** @return BelongsTo */ + public function integration(): BelongsTo + { + return $this->belongsTo(Integration::class, 'integration_code', 'integration_code'); + } + + /** @return HasMany */ + public function clientIntegrations(): HasMany + { + return $this->hasMany(ClientIntegration::class); + } + + /** @return HasMany */ + public function websiteTypeIntegrations(): HasMany + { + return $this->hasMany(WebsiteTypeIntegration::class); + } +} diff --git a/app/Domains/Integration/Models/WebsiteTypeIntegration.php b/app/Domains/Integration/Models/WebsiteTypeIntegration.php new file mode 100644 index 0000000..6b791f3 --- /dev/null +++ b/app/Domains/Integration/Models/WebsiteTypeIntegration.php @@ -0,0 +1,30 @@ + */ + public function websiteType(): BelongsTo + { + return $this->belongsTo(WebsiteType::class, 'website_type_code', 'codigo'); + } + + /** @return BelongsTo */ + public function integration(): BelongsTo + { + return $this->belongsTo(Integration::class, 'integration_code', 'integration_code'); + } + + /** @return BelongsTo */ + public function integrationInstance(): BelongsTo + { + return $this->belongsTo(IntegrationInstance::class); + } +} diff --git a/app/Domains/Integration/Policies/IntegrationPolicy.php b/app/Domains/Integration/Policies/IntegrationPolicy.php new file mode 100644 index 0000000..b4bff7c --- /dev/null +++ b/app/Domains/Integration/Policies/IntegrationPolicy.php @@ -0,0 +1,14 @@ +rol_codigo === RoleCode::Admin->value; + } +} diff --git a/app/Domains/Integration/Requests/StoreClientIntegrationRequest.php b/app/Domains/Integration/Requests/ConfigureIntegrationRequest.php similarity index 76% rename from app/Domains/Integration/Requests/StoreClientIntegrationRequest.php rename to app/Domains/Integration/Requests/ConfigureIntegrationRequest.php index 40cd43c..dd80bb4 100644 --- a/app/Domains/Integration/Requests/StoreClientIntegrationRequest.php +++ b/app/Domains/Integration/Requests/ConfigureIntegrationRequest.php @@ -6,20 +6,19 @@ use App\Domains\Integration\Models\Integration; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\ValidationException; -class StoreClientIntegrationRequest extends FormRequest +class ConfigureIntegrationRequest extends FormRequest { protected ?Integration $integrationModel = null; public function authorize(): bool { - return true; + return $this->user()?->can('manage', Integration::class) ?? false; } protected function prepareForValidation(): void { - $integrationCode = $this->route('integration_code'); $this->integrationModel = Integration::query() - ->where('integration_code', $integrationCode) + ->where('integration_code', $this->route('integration_code')) ->first(); if (! $this->integrationModel) { @@ -31,7 +30,7 @@ class StoreClientIntegrationRequest extends FormRequest public function rules(): array { - $rules = []; + $rules = ['integration_data' => ['present', 'array']]; foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) { $rules['integration_data.'.$field] = $rule; diff --git a/app/Domains/Integration/Requests/StoreIntegrationRequest.php b/app/Domains/Integration/Requests/StoreIntegrationRequest.php index 9fb824d..cf19bbe 100644 --- a/app/Domains/Integration/Requests/StoreIntegrationRequest.php +++ b/app/Domains/Integration/Requests/StoreIntegrationRequest.php @@ -18,7 +18,7 @@ class StoreIntegrationRequest extends FormRequest 'name' => ['required', 'string', 'max:255'], 'url' => ['nullable', 'url', 'max:255'], 'integration_data_schema' => ['nullable', 'array'], - 'requires_client_configuration' => ['sometimes', 'boolean'], + 'requires_configuration' => ['sometimes', 'boolean'], ]; } } diff --git a/app/Domains/Integration/Requests/UpdateIntegrationRequest.php b/app/Domains/Integration/Requests/UpdateIntegrationRequest.php index 210d27e..a2c466c 100644 --- a/app/Domains/Integration/Requests/UpdateIntegrationRequest.php +++ b/app/Domains/Integration/Requests/UpdateIntegrationRequest.php @@ -3,6 +3,7 @@ namespace App\Domains\Integration\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class UpdateIntegrationRequest extends FormRequest { @@ -19,9 +20,8 @@ class UpdateIntegrationRequest extends FormRequest 'name' => ['sometimes', 'required', 'string', 'max:255'], 'url' => ['nullable', 'url', 'max:255'], 'integration_data_schema' => ['nullable', 'array'], - 'requires_client_configuration' => ['sometimes', 'boolean'], - // the code shouldn't ideally be updatable, but if it is: - 'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')], + 'requires_configuration' => ['sometimes', 'boolean'], + 'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])], ]; } } diff --git a/app/Domains/Integration/Resources/IntegrationAssociationResource.php b/app/Domains/Integration/Resources/IntegrationAssociationResource.php new file mode 100644 index 0000000..31339ca --- /dev/null +++ b/app/Domains/Integration/Resources/IntegrationAssociationResource.php @@ -0,0 +1,21 @@ + $this->id, + 'client_id' => $this->when(isset($this->client_id), $this->client_id), + 'website_type_code' => $this->when(isset($this->website_type_code), $this->website_type_code), + 'integration_code' => $this->integration_code, + 'integration_instance_id' => $this->integration_instance_id, + 'integration_instance' => new IntegrationInstanceResource($this->whenLoaded('integrationInstance')), + ]; + } +} diff --git a/app/Domains/Integration/Resources/IntegrationInstanceResource.php b/app/Domains/Integration/Resources/IntegrationInstanceResource.php new file mode 100644 index 0000000..a65f359 --- /dev/null +++ b/app/Domains/Integration/Resources/IntegrationInstanceResource.php @@ -0,0 +1,20 @@ + $this->id, + 'integration_code' => $this->integration_code, + 'name' => $this->name, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Domains/Integration/Services/BaseIntegrationService.php b/app/Domains/Integration/Services/BaseIntegrationService.php index daa638c..0ca9302 100644 --- a/app/Domains/Integration/Services/BaseIntegrationService.php +++ b/app/Domains/Integration/Services/BaseIntegrationService.php @@ -5,6 +5,8 @@ namespace App\Domains\Integration\Services; use App\Domains\Client\Models\Client; use App\Domains\Integration\Models\ClientIntegration; use App\Domains\Integration\Models\Integration; +use App\Domains\Integration\Models\IntegrationInstance; +use App\Domains\Integration\Models\WebsiteTypeIntegration; use App\Domains\Tenant\Models\Tenant; use Exception; use Illuminate\Http\Client\PendingRequest; @@ -32,9 +34,9 @@ abstract class BaseIntegrationService protected ?Integration $integration = null; /** - * The client-owned integration configuration. + * The effective integration instance. */ - protected ?ClientIntegration $clientIntegration = null; + protected ?IntegrationInstance $integrationInstance = null; /** * Set the integration code. @@ -85,7 +87,7 @@ abstract class BaseIntegrationService } /** - * Load the integration definition and its client-owned configuration. + * Load the integration definition and its effective instance configuration. * * @throws Exception */ @@ -95,6 +97,7 @@ abstract class BaseIntegrationService throw new Exception('Integration code is not set.'); } + $this->integrationInstance = null; $this->integration = Integration::where('integration_code', $this->integrationCode)->first(); if (! $this->integration) { throw new Exception("Integration with code '{$this->integrationCode}' not found."); @@ -104,11 +107,18 @@ abstract class BaseIntegrationService throw new Exception('Client context is not set.'); } - $this->clientIntegration = ClientIntegration::where('client_id', $this->clientContext->id) + $this->integrationInstance = ClientIntegration::with('integrationInstance')->where('client_id', $this->clientContext->id) ->where('integration_code', $this->integrationCode) - ->first(); + ->first()?->integrationInstance; - if (! $this->clientIntegration && $this->integration->requires_client_configuration) { + if (! $this->integrationInstance && $this->tenant?->website_type_code) { + $this->integrationInstance = WebsiteTypeIntegration::with('integrationInstance') + ->where('website_type_code', $this->tenant->website_type_code) + ->where('integration_code', $this->integrationCode) + ->first()?->integrationInstance; + } + + if (! $this->integrationInstance && $this->integration->requires_configuration) { throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured."); } } @@ -131,15 +141,15 @@ abstract class BaseIntegrationService } /** - * Get an integration setting from the client-owned configuration. + * Get an integration setting from the effective instance configuration. */ protected function getIntegrationSetting(string $key, mixed $default = null): mixed { - if (! $this->clientIntegration || ! $this->clientIntegration->integration_data) { + if (! $this->integrationInstance || ! $this->integrationInstance->integration_data) { return $default; } - return $this->clientIntegration->integration_data[$key] ?? $default; + return $this->integrationInstance->integration_data[$key] ?? $default; } /** diff --git a/app/Domains/Integration/Services/ClientIntegrationService.php b/app/Domains/Integration/Services/ClientIntegrationService.php index 1657259..2f79d8e 100644 --- a/app/Domains/Integration/Services/ClientIntegrationService.php +++ b/app/Domains/Integration/Services/ClientIntegrationService.php @@ -13,6 +13,7 @@ class ClientIntegrationService public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration { return $client->integrations() + ->with(['integration', 'integrationInstance']) ->where('integration_code', $integrationCode) ->first(); } @@ -20,7 +21,7 @@ class ClientIntegrationService /** @return Collection */ public function getAllForClient(Client $client): Collection { - return $client->integrations()->with('integration')->get(); + return $client->integrations()->with(['integration', 'integrationInstance'])->get(); } public function updateOrCreateIntegration( @@ -29,13 +30,7 @@ class ClientIntegrationService array $data, ): ClientIntegration { return DB::transaction(function () use ($client, $integration, $data): ClientIntegration { - $clientIntegration = ClientIntegration::query()->updateOrCreate( - [ - 'client_id' => $client->id, - 'integration_code' => $integration->integration_code, - ], - ['integration_data' => $data], - ); + $clientIntegration = app(IntegrationAssociationService::class)->configure($client, $integration, $data); $service = $this->resolveService($integration->integration_code); $service?->forClient($client)->onSetup(); diff --git a/app/Domains/Integration/Services/IntegrationAssociationService.php b/app/Domains/Integration/Services/IntegrationAssociationService.php new file mode 100644 index 0000000..78aced5 --- /dev/null +++ b/app/Domains/Integration/Services/IntegrationAssociationService.php @@ -0,0 +1,98 @@ + $integration->integration_code, + 'name' => $integration->name.' / '.$this->ownerName($owner), + 'integration_data' => $integrationData, + ]); + + return $this->associate($owner, $integration->integration_code, $instance); + }); + } + + public function associate(Client|WebsiteType $owner, string $code, IntegrationInstance $instance): ClientIntegration|WebsiteTypeIntegration + { + return DB::transaction(function () use ($owner, $code, $instance): ClientIntegration|WebsiteTypeIntegration { + $association = $owner->integrations() + ->where('integration_code', $code) + ->lockForUpdate() + ->first(); + $previousInstanceId = $association?->integration_instance_id; + + $instanceIds = array_values(array_unique(array_filter([ + $previousInstanceId, + $instance->id, + ]))); + sort($instanceIds); + + $instances = IntegrationInstance::query() + ->whereKey($instanceIds) + ->orderBy('id') + ->lockForUpdate() + ->get() + ->keyBy('id'); + $instance = $instances->get($instance->id) ?? IntegrationInstance::query()->findOrFail($instance->id); + abort_unless($instance->integration_code === $code, 422, 'The instance belongs to another integration.'); + + $association = $owner->integrations()->updateOrCreate( + ['integration_code' => $code], + ['integration_instance_id' => $instance->id], + ); + + if ($previousInstanceId && $previousInstanceId !== $instance->id) { + $this->deleteIfUnused($previousInstanceId); + } + + return $association->load('integrationInstance'); + }); + } + + public function detach(Client|WebsiteType $owner, string $code): void + { + DB::transaction(function () use ($owner, $code): void { + $association = $owner->integrations() + ->where('integration_code', $code) + ->lockForUpdate() + ->first(); + + if (! $association) { + return; + } + + $instanceId = $association->integration_instance_id; + $association->delete(); + $this->deleteIfUnused($instanceId); + }); + } + + private function deleteIfUnused(int $instanceId): void + { + $instance = IntegrationInstance::query()->lockForUpdate()->find($instanceId); + + if ($instance + && ! $instance->clientIntegrations()->exists() + && ! $instance->websiteTypeIntegrations()->exists()) { + $instance->delete(); + } + } + + private function ownerName(Client|WebsiteType $owner): string + { + return $owner instanceof Client ? $owner->name : $owner->nombre; + } +} diff --git a/app/Domains/Integration/Services/IntegrationInstanceService.php b/app/Domains/Integration/Services/IntegrationInstanceService.php new file mode 100644 index 0000000..7a86d37 --- /dev/null +++ b/app/Domains/Integration/Services/IntegrationInstanceService.php @@ -0,0 +1,40 @@ +lockForUpdate()->findOrFail($instance->id); + $cacheKey = $instance->tokenCacheKey(); + $instance->update($data); + if (array_key_exists('integration_data', $data)) { + DB::afterCommit(fn () => Cache::forget($cacheKey)); + } + + return $instance; + }); + } + + public function delete(IntegrationInstance $instance): void + { + DB::transaction(function () use ($instance): void { + $instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id); + abort_if($instance->clientIntegrations()->exists() || $instance->websiteTypeIntegrations()->exists(), 409, 'Unlink the instance before deleting it.'); + $cacheKey = $instance->tokenCacheKey(); + $instance->delete(); + DB::afterCommit(fn () => Cache::forget($cacheKey)); + }); + } +} diff --git a/app/Domains/Integration/Services/MailService.php b/app/Domains/Integration/Services/MailService.php index c264c24..d2cd30f 100644 --- a/app/Domains/Integration/Services/MailService.php +++ b/app/Domains/Integration/Services/MailService.php @@ -29,7 +29,7 @@ class MailService extends BaseIntegrationService private ?Mailer $mailer = null; - private bool $usesClientMailer = false; + private bool $usesInstanceMailer = false; public function __construct(?MailFactory $mailFactory = null) { @@ -40,12 +40,12 @@ class MailService extends BaseIntegrationService { parent::forTenant($tenantCode); - if ($this->clientIntegration) { + if ($this->integrationInstance) { $this->mailer = $this->resolveMailer(); - $this->usesClientMailer = true; + $this->usesInstanceMailer = true; } else { $this->mailer = $this->mailFactory->mailer(); - $this->usesClientMailer = false; + $this->usesInstanceMailer = false; } return $this; @@ -56,12 +56,12 @@ class MailService extends BaseIntegrationService parent::forClient($client); $this->tenant = $this->clientContext?->tenants()->first(); - if ($this->clientIntegration) { + if ($this->integrationInstance) { $this->mailer = $this->resolveMailer(); - $this->usesClientMailer = true; + $this->usesInstanceMailer = true; } else { $this->mailer = $this->mailFactory->mailer(); - $this->usesClientMailer = false; + $this->usesInstanceMailer = false; } return $this; @@ -122,8 +122,8 @@ class MailService extends BaseIntegrationService public function mailerName(): string { - return $this->usesClientMailer - ? 'client-smtp' + return $this->usesInstanceMailer + ? 'integration-smtp' : (string) config('mail.default'); } @@ -187,7 +187,7 @@ class MailService extends BaseIntegrationService private function resolveMailer(): Mailer { - $data = $this->clientIntegration?->integration_data; + $data = $this->integrationInstance?->integration_data; if (! is_array($data)) { throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.'); @@ -205,7 +205,7 @@ class MailService extends BaseIntegrationService } $mailer = $this->mailFactory->build([ - 'name' => 'client-smtp-'.$this->clientContext?->id, + 'name' => 'integration-smtp-'.$this->integrationInstance?->id, 'transport' => 'smtp', 'scheme' => $data['MAIL_SCHEME'] ?? null, 'host' => $data['MAIL_HOST'], diff --git a/app/Domains/Integration/Services/TelepagosIntegrationService.php b/app/Domains/Integration/Services/TelepagosIntegrationService.php index 528690b..ef99f7f 100644 --- a/app/Domains/Integration/Services/TelepagosIntegrationService.php +++ b/app/Domains/Integration/Services/TelepagosIntegrationService.php @@ -45,11 +45,11 @@ class TelepagosIntegrationService extends BaseIntegrationService */ public function getToken(): string { - if (! $this->clientIntegration || ! $this->clientContext) { + if (! $this->integrationInstance) { throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.'); } - $cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}"; + $cacheKey = $this->integrationInstance->tokenCacheKey(); $token = Cache::get($cacheKey); @@ -94,7 +94,7 @@ class TelepagosIntegrationService extends BaseIntegrationService // Calculate TTL and subtract a buffer of 60 seconds $ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60); - $cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}"; + $cacheKey = $this->integrationInstance->tokenCacheKey(); Cache::put($cacheKey, $token, $ttlSeconds); return $token; @@ -220,11 +220,11 @@ class TelepagosIntegrationService extends BaseIntegrationService */ public function clearToken(): void { - if (! $this->clientContext) { + if (! $this->integrationInstance) { return; } - $cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}"; + $cacheKey = $this->integrationInstance->tokenCacheKey(); Cache::forget($cacheKey); } diff --git a/app/Domains/Integration/documentacion/README.md b/app/Domains/Integration/documentacion/README.md index 1edd61b..941c518 100644 --- a/app/Domains/Integration/documentacion/README.md +++ b/app/Domains/Integration/documentacion/README.md @@ -1,33 +1,48 @@ # Dominio Integration -## Propósito +## Modelo -Gestiona integraciones externas disponibles y su configuración por cliente. Un cliente puede agrupar múltiples tenants que comparten las mismas credenciales. Incluye correo y pagos mediante Telepagos. +- `Integration`: catálogo, URL base, `integration_data_schema` y `requires_configuration`. +- `IntegrationInstance`: configuración interna concreta con nombre. `integration_data` se cifra con `EncryptedIntegrationData`, se almacena en `longText` y nunca se devuelve en la API. +- `ClientIntegration` y `WebsiteTypeIntegration`: asociaciones a instancias. La clave compuesta verifica el código de la instancia y la unicidad permite una instancia por integración y propietario. -## Modelo y seguridad +Las instancias no se administran directamente por HTTP. Cada configuración enviada desde un cliente o tipo de sitio crea una instancia interna nueva y reemplaza únicamente la asociación de ese propietario. Al reemplazar o desvincular una instancia, esta se elimina si ya no tiene asociaciones con ningún cliente ni tipo de sitio; las instancias compartidas se conservan mientras tengan al menos una asociación. -- `Integration`: definición global de una integración. -- `ClientIntegration`: configuración y credenciales de una integración para un cliente. -- `EncryptedIntegrationData`: cast que protege los datos sensibles persistidos. -- `ClientIntegrationService`: consulta y configura integraciones del cliente. +## Resolución -## Servicios externos +`BaseIntegrationService::forTenant()` busca primero la asociación del cliente y después la del tipo de sitio del tenant. Selecciona una configuración completa, sin mezclar credenciales entre niveles. Si una configuración está presente pero es inválida, produce un error en vez de recurrir a otra instancia. -- `BaseIntegrationService`: resuelve el cliente desde el tenant operativo y carga exclusivamente la configuración del cliente. -- `MailService`: envío de correo usando la integración configurada. -- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros. -- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos. +`forClient()` usa únicamente la asociación del cliente: sin un tenant concreto no se elige un tipo de sitio. Si no existe una instancia y `requires_configuration` es verdadero, se genera un error. Para correo opcional, `MailService` usa el mailer global si no encuentra una instancia; cuando la encuentra, construye un transporte SMTP aislado identificado como `integration-smtp`. -## Endpoints +Telepagos utiliza una clave de caché basada en el ID de instancia y una huella del texto cifrado. Volver a configurar el servicio con `forClient()` o `forTenant()` carga la configuración actual. -- CRUD global bajo `/integrations`. -- Consulta y configuración por cliente bajo `/clients/{client}/integrations`. -- `POST /webhooks/telepagos/{client}` para notificaciones del proveedor. +## Administración -## Logging de Telepagos +Todas estas rutas llevan el prefijo `/api`, requieren `auth:sanctum` y el rol global `admin` mediante `IntegrationPolicy`. Los roles `adminapp`, `scanner` y `user` no administran configuraciones. -Los eventos de autenticación, QR, consultas de cuenta y procesamiento de webhooks se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor. +| Método | Ruta | Operación | +| --- | --- | --- | +| GET | `/clients/{client}/integrations[/{integration_code}]` | Consultar asociaciones directas. | +| PUT | `/clients/{client}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del cliente. Ejecuta el hook de configuración existente. | +| DELETE | `/clients/{client}/integrations/{integration_code}` | Desvincular. | +| GET | `/website-types/{codigo}/integrations[/{integration_code}]` | Consultar asociaciones del tipo de sitio. | +| PUT | `/website-types/{codigo}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del tipo de sitio. | +| DELETE | `/website-types/{codigo}/integrations/{integration_code}` | Desvincular. | -## Dependencias y reglas +Los dos `PUT` reciben `integration_data`, un objeto completo validado según el esquema de la integración. La integración debe existir previamente en el catálogo interno. No hay endpoints públicos para administrar el catálogo ni las instancias directamente. -Se integra con `Client`, `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. El tenant conserva el contexto operativo y de branding, pero nunca es dueño de credenciales. Las credenciales no se exponen en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra. +Las respuestas y consultas incluyen metadatos de `integration_instance`, pero nunca sus credenciales. La configuración del cliente conserva su mensaje de respuesta histórico; la del tipo de sitio usa un resource con envoltorio `data`. + +## Webhooks y contexto operativo + +`POST /webhooks/telepagos/{client}` conserva su contrato público con el proveedor y la validación de pertenencia de las compras al cliente. Como no recibe un tenant, requiere una asociación directa al cliente. Para usar una instancia compartida en ese flujo, asociarla también al cliente; la herencia por tipo de sitio no se aplica a esa URL. + +`Notification` consume `MailService`; `Purchase` consume Telepagos. Los tenants mantienen el contexto operativo y el branding. + +## Despliegue + +Ejecutar `php artisan migrate` junto con este código. La migración `2026_09_04_000003` renombra `requires_client_configuration` a `requires_configuration` conservando sus valores. Los payloads del catálogo deben usar el nuevo nombre. Las migraciones previas trasladan el texto cifrado sin descifrarlo y no comparten instancias automáticamente. + +## Logging + +Telepagos registra eventos en el canal diario `telepagos`. El nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Se eliminan tokens y credenciales de las estructuras registradas. diff --git a/app/Domains/Integration/routes/api.php b/app/Domains/Integration/routes/api.php index 0fe672c..bad0f08 100644 --- a/app/Domains/Integration/routes/api.php +++ b/app/Domains/Integration/routes/api.php @@ -1,22 +1,25 @@ 'integrations'], function () { - Route::get('/', [IntegrationController::class, 'index']); - Route::post('/', [IntegrationController::class, 'store']); - Route::get('/{integration}', [IntegrationController::class, 'show']); - Route::put('/{integration}', [IntegrationController::class, 'update']); - Route::delete('/{integration}', [IntegrationController::class, 'destroy']); -}); +Route::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(function (): void { + Route::prefix('website-types/{websiteType:codigo}/integrations')->group(function (): void { + Route::get('/', [WebsiteTypeIntegrationController::class, 'index']); + Route::get('/{integration_code}', [WebsiteTypeIntegrationController::class, 'show']); + Route::put('/{integration_code}', [WebsiteTypeIntegrationController::class, 'store']); + Route::delete('/{integration_code}', [WebsiteTypeIntegrationController::class, 'destroy']); + }); -Route::group(['prefix' => 'clients/{client}/integrations'], function () { - Route::get('/', [ClientIntegrationController::class, 'index']); - Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']); - Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']); + Route::group(['prefix' => 'clients/{client}/integrations'], function () { + Route::get('/', [ClientIntegrationController::class, 'index']); + Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']); + Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']); + Route::delete('/{integration_code}', [ClientIntegrationController::class, 'destroy']); + }); }); Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']); diff --git a/app/Domains/Tenant/Models/WebsiteType.php b/app/Domains/Tenant/Models/WebsiteType.php index 23500cc..91acc11 100644 --- a/app/Domains/Tenant/Models/WebsiteType.php +++ b/app/Domains/Tenant/Models/WebsiteType.php @@ -3,6 +3,7 @@ namespace App\Domains\Tenant\Models; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Integration\Models\WebsiteTypeIntegration; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -36,6 +37,12 @@ class WebsiteType extends Model protected $table = 'website_type'; + /** @return HasMany */ + public function integrations(): HasMany + { + return $this->hasMany(WebsiteTypeIntegration::class, 'website_type_code', 'codigo'); + } + /** * @return BelongsTo */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 557ef96..e687726 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Domains\Integration\Models\Integration; +use App\Domains\Integration\Policies\IntegrationPolicy; use App\Domains\Notification\Events\PasswordResetRequested; use App\Domains\Notification\Events\UserRegistered; use App\Domains\Notification\Listeners\SendPasswordResetEmail; @@ -13,6 +15,7 @@ use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; @@ -31,6 +34,10 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + Gate::policy( + Integration::class, + IntegrationPolicy::class, + ); Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class); Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class); Event::listen(UserRegistered::class, SendWelcomeEmail::class); diff --git a/database/migrations/2026_09_04_000000_create_integration_instances_table.php b/database/migrations/2026_09_04_000000_create_integration_instances_table.php new file mode 100644 index 0000000..dfa36eb --- /dev/null +++ b/database/migrations/2026_09_04_000000_create_integration_instances_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('integration_code'); + $table->string('name'); + $table->longText('integration_data')->nullable(); // Encrypted JSON. + $table->timestamps(); + + $table->foreign('integration_code')->references('integration_code')->on('integrations')->restrictOnDelete(); + // Allows associations to enforce one instance per integration and owner. + $table->unique(['id', 'integration_code'], 'integration_instances_id_code_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('integration_instances'); + } +}; diff --git a/database/migrations/2026_09_04_000001_move_client_configuration_to_integration_instances.php b/database/migrations/2026_09_04_000001_move_client_configuration_to_integration_instances.php new file mode 100644 index 0000000..9c40dbc --- /dev/null +++ b/database/migrations/2026_09_04_000001_move_client_configuration_to_integration_instances.php @@ -0,0 +1,81 @@ +unsignedBigInteger('integration_instance_id')->nullable(); + }); + } + + DB::table('client_integrations')->whereNull('integration_instance_id')->orderBy('id')->chunkById(100, function ($associations): void { + foreach ($associations as $association) { + DB::transaction(function () use ($association): void { + $instanceId = DB::table('integration_instances')->insertGetId([ + 'integration_code' => $association->integration_code, + 'name' => $association->integration_code.' / client '.$association->client_id, + // Copy ciphertext verbatim: no decryption or re-encryption during migration. + 'integration_data' => $association->integration_data, + 'created_at' => $association->created_at, + 'updated_at' => $association->updated_at, + ]); + + DB::table('client_integrations')->where('id', $association->id)->update([ + 'integration_instance_id' => $instanceId, + ]); + }); + } + }); + + Schema::table('client_integrations', function (Blueprint $table): void { + $table->unsignedBigInteger('integration_instance_id')->nullable(false)->change(); + }); + + $hasInstanceForeignKey = collect(Schema::getForeignKeys('client_integrations')) + ->contains(fn (array $key): bool => $key['columns'] === ['integration_instance_id', 'integration_code']); + + if (! $hasInstanceForeignKey) { + Schema::table('client_integrations', function (Blueprint $table): void { + $table->foreign(['integration_instance_id', 'integration_code'], 'client_integrations_instance_code_fk') + ->references(['id', 'integration_code'])->on('integration_instances')->restrictOnDelete(); + }); + } + + if (Schema::hasColumn('client_integrations', 'integration_data')) { + Schema::table('client_integrations', function (Blueprint $table): void { + $table->dropColumn('integration_data'); + }); + } + } + + public function down(): void + { + Schema::table('client_integrations', function (Blueprint $table): void { + $table->longText('integration_data')->nullable(); + }); + + DB::table('client_integrations')->orderBy('id')->chunkById(100, function ($associations): void { + foreach ($associations as $association) { + DB::table('client_integrations')->where('id', $association->id)->update([ + 'integration_data' => DB::table('integration_instances') + ->where('id', $association->integration_instance_id)->value('integration_data'), + ]); + } + }); + + Schema::table('client_integrations', function (Blueprint $table): void { + // MySQL uses the short name; SQLite needs the columns to rebuild the table. + $table->dropForeign('client_integrations_instance_code_fk') + ->columns(['integration_instance_id', 'integration_code']); + $table->dropColumn('integration_instance_id'); + }); + } +}; diff --git a/database/migrations/2026_09_04_000002_create_website_type_integrations_table.php b/database/migrations/2026_09_04_000002_create_website_type_integrations_table.php new file mode 100644 index 0000000..07fe0b5 --- /dev/null +++ b/database/migrations/2026_09_04_000002_create_website_type_integrations_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('website_type_code'); + $table->string('integration_code'); + $table->unsignedBigInteger('integration_instance_id'); + $table->timestamps(); + + $table->foreign('website_type_code')->references('codigo')->on('website_type')->cascadeOnDelete(); + $table->foreign(['integration_instance_id', 'integration_code'], 'website_type_integrations_instance_code_fk') + ->references(['id', 'integration_code'])->on('integration_instances')->restrictOnDelete(); + $table->unique(['website_type_code', 'integration_code'], 'website_type_integrations_owner_code_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('website_type_integrations'); + } +}; diff --git a/database/migrations/2026_09_04_000003_rename_integration_configuration_requirement.php b/database/migrations/2026_09_04_000003_rename_integration_configuration_requirement.php new file mode 100644 index 0000000..d4bb9ac --- /dev/null +++ b/database/migrations/2026_09_04_000003_rename_integration_configuration_requirement.php @@ -0,0 +1,22 @@ +renameColumn('requires_client_configuration', 'requires_configuration'); + }); + } + + public function down(): void + { + Schema::table('integrations', function (Blueprint $table): void { + $table->renameColumn('requires_configuration', 'requires_client_configuration'); + }); + } +}; diff --git a/database/seeders/EmailIntegrationSeeder.php b/database/seeders/EmailIntegrationSeeder.php index 6217bcf..1eeff2c 100644 --- a/database/seeders/EmailIntegrationSeeder.php +++ b/database/seeders/EmailIntegrationSeeder.php @@ -17,7 +17,7 @@ class EmailIntegrationSeeder extends Seeder [ 'name' => 'Email', 'url' => null, - 'requires_client_configuration' => false, + 'requires_configuration' => false, 'integration_data_schema' => [ 'MAIL_MAILER' => 'required|string|in:smtp', 'MAIL_SCHEME' => 'required|string|in:smtp', diff --git a/database/seeders/TelepagosIntegrationSeeder.php b/database/seeders/TelepagosIntegrationSeeder.php index fad8b4d..bbaf657 100644 --- a/database/seeders/TelepagosIntegrationSeeder.php +++ b/database/seeders/TelepagosIntegrationSeeder.php @@ -17,7 +17,7 @@ class TelepagosIntegrationSeeder extends Seeder [ 'name' => 'Telepagos', 'url' => 'https://api.telepagos.com.ar', - 'requires_client_configuration' => true, + 'requires_configuration' => true, 'integration_data_schema' => [ 'username' => 'required|string', 'password' => 'required|string', @@ -30,7 +30,7 @@ class TelepagosIntegrationSeeder extends Seeder [ 'name' => 'Telepagos Homologación', 'url' => 'https://api.homo.telepagos.com.ar', - 'requires_client_configuration' => true, + 'requires_configuration' => true, 'integration_data_schema' => [ 'username' => 'required|string', 'password' => 'required|string', diff --git a/postman/generate-shopit-collection.php b/postman/generate-shopit-collection.php index 2aaf14a..ad2498e 100644 --- a/postman/generate-shopit-collection.php +++ b/postman/generate-shopit-collection.php @@ -75,9 +75,8 @@ function bodyFor(string $method, string $uri): ?array 'POST api/clients' => ['code' => 'cliente-demo', 'name' => 'Cliente Demo'], 'PUT api/clients/{client}' => ['code' => 'cliente-demo', 'name' => 'Cliente Demo Actualizado'], 'PATCH api/clients/{client}' => ['name' => 'Cliente Demo Actualizado'], - 'POST api/integrations' => ['integration_code' => 'telepagos', 'name' => 'Telepagos', 'url' => 'https://api.example.com', 'integration_data_schema' => ['api_key' => ['required', 'string']], 'requires_client_configuration' => true], - 'PUT api/integrations/{integration}' => ['name' => 'Telepagos', 'url' => 'https://api.example.com', 'requires_client_configuration' => true], 'PUT api/clients/{client}/integrations/{integration_code}' => ['integration_data' => ['api_key' => 'replace-me']], + 'PUT api/website-types/{websiteType:codigo}/integrations/{integration_code}' => ['integration_data' => ['api_key' => 'replace-me']], 'POST api/menues' => ['code' => 'demo', 'label' => 'Demo', 'parent_menu_code' => null, 'content_type' => 'static', 'static_content_schema' => ['title' => ['required', 'string']], 'route' => '/demo'], 'PUT api/menues/{menue}' => ['label' => 'Demo actualizado', 'route' => '/demo'], 'PATCH api/menues/{menue}' => ['label' => 'Demo actualizado'], @@ -276,7 +275,8 @@ function requestName(string $method, string $action, bool $multiMethod): string function pathFor(string $uri): string { $variables = [ - 'tenant:codigo' => 'tenant_code', 'tenant' => 'tenant_id', 'client' => 'client_id', + 'tenant:codigo' => 'tenant_code', 'tenant' => 'tenant_id', 'client' => 'client_code', + 'websiteType:codigo' => 'website_type_code', 'integration_code' => 'integration_code', 'integration' => 'integration_id', 'menue' => 'menu_id', 'menu_code' => 'menu_code', 'tenant_code' => 'tenant_code', 'catalogItem' => 'catalog_item_id', 'featuredGroup' => 'featured_group_id', 'category' => 'category_id', 'cartItem' => 'cart_item_id', @@ -331,6 +331,111 @@ function tokenCaptureEvent(string $uri): array ]; } +/** @return array}> */ +function integrationConfigurationPresets(): array +{ + return [ + 'email' => [ + 'name' => 'Email (SMTP)', + 'description' => 'Configura el transporte SMTP usado para el envío de correos.', + 'integration_data' => [ + 'MAIL_MAILER' => 'smtp', + 'MAIL_SCHEME' => 'smtp', + 'MAIL_HOST' => 'smtp.example.com', + 'MAIL_PORT' => 587, + 'MAIL_USERNAME' => 'usuario@example.com', + 'MAIL_PASSWORD' => 'replace-me', + 'MAIL_FROM_ADDRESS' => 'no-reply@example.com', + 'MAIL_FROM_NAME' => 'ShopIt', + ], + ], + 'telepagos' => [ + 'name' => 'Telepagos Producción', + 'description' => 'Configura las credenciales productivas de Telepagos.', + 'integration_data' => [ + 'username' => 'replace-me', + 'password' => 'replace-me', + ], + ], + 'telepagos_homo' => [ + 'name' => 'Telepagos Homologación', + 'description' => 'Configura las credenciales del entorno de homologación de Telepagos.', + 'integration_data' => [ + 'username' => 'replace-me', + 'password' => 'replace-me', + ], + ], + ]; +} + +/** @param array> $requests + * @return array> + */ +function integrationOwnerFolders(array $requests): array +{ + $owners = [ + 'Client' => '/api/clients/', + 'Website Type' => '/api/website-types/', + ]; + $folders = []; + + foreach ($owners as $ownerName => $pathFragment) { + $ownerRequests = array_values(array_filter( + $requests, + fn (array $item): bool => str_contains($item['request']['url']['raw'], $pathFragment), + )); + $putTemplate = null; + foreach ($ownerRequests as $ownerRequest) { + if ($ownerRequest['request']['method'] === 'PUT') { + $putTemplate = $ownerRequest; + break; + } + } + + if ($putTemplate === null) { + continue; + } + + foreach ($ownerRequests as &$request) { + if ($request['request']['method'] === 'PUT') { + $request['name'] = 'Configure Integration (generic)'; + } + } + unset($request); + + $ownerItems = [[ + 'name' => 'Management', + 'description' => 'Consulta, configura o desvincula cualquier integración usando `{{integration_code}}`.', + 'item' => $ownerRequests, + ]]; + + foreach (integrationConfigurationPresets() as $integrationCode => $preset) { + $request = $putTemplate; + $request['name'] = 'Configure '.$preset['name']; + $request['request']['description'] .= "\n\nPreset: `{$integrationCode}`. {$preset['description']}"; + $request['request']['url']['raw'] = str_replace('{{integration_code}}', $integrationCode, $request['request']['url']['raw']); + $request['request']['url']['path'] = array_map( + fn (string $segment): string => $segment === '{{integration_code}}' ? $integrationCode : $segment, + $request['request']['url']['path'], + ); + $request['request']['body'] = jsonBody(['integration_data' => $preset['integration_data']]); + + $ownerItems[] = [ + 'name' => $preset['name'], + 'description' => $preset['description'], + 'item' => [$request], + ]; + } + + $folders[] = [ + 'name' => $ownerName, + 'item' => $ownerItems, + ]; + } + + return $folders; +} + $tree = []; $registeredOperations = 0; @@ -412,7 +517,9 @@ $items = []; foreach ($tree as $section => $folders) { $children = []; foreach ($folders as $folder => $requests) { - $children[] = ['name' => humanize($folder), 'item' => $requests]; + $children[] = $section === 'Platform Management' && $folder === 'Integration' + ? ['name' => 'Integration', 'item' => integrationOwnerFolders($requests)] + : ['name' => humanize($folder), 'item' => $requests]; } $items[] = [ @@ -430,7 +537,8 @@ $variables = [ 'admin_email' => 'admin@example.com', 'admin_password' => 'Password!123', 'scanner_email' => 'scanner@example.com', 'scanner_password' => 'Password!123', 'tenant_code' => 'demo', 'tenant_id' => '1', 'tenant_domain' => 'demo.test', - 'client_id' => '1', 'integration_id' => '1', 'integration_code' => 'telepagos', + 'client_id' => '1', 'client_code' => 'cliente-demo', 'website_type_code' => 'shopit', + 'integration_code' => 'telepagos', 'menu_id' => '1', 'menu_code' => 'demo', 'catalog_item_id' => '1', 'variant_id' => '1', 'category_id' => '1', 'featured_group_id' => '1', 'cart_id' => '1', 'cart_item_id' => '1', 'purchase_id' => '1', 'purchase_item_id' => '1', 'sale_id' => '1', 'staff_id' => '1', @@ -444,7 +552,7 @@ $collection = [ '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 {$registeredOperations} 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 {$registeredOperations} operaciones HTTP, presets de configuración para cada integración, 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' => $items, diff --git a/tests/Concerns/CreatesIntegrationInstances.php b/tests/Concerns/CreatesIntegrationInstances.php new file mode 100644 index 0000000..8b37c0d --- /dev/null +++ b/tests/Concerns/CreatesIntegrationInstances.php @@ -0,0 +1,21 @@ + $attributes['integration_code'], + 'name' => 'Test instance', + 'integration_data' => $attributes['integration_data'], + ]); + unset($attributes['integration_data']); + + return ClientIntegration::create($attributes + ['integration_instance_id' => $instance->id]); + } +} diff --git a/tests/Feature/Integration/ClientIntegrationControllerTest.php b/tests/Feature/Integration/ClientIntegrationControllerTest.php index fce9b5f..ab808e1 100644 --- a/tests/Feature/Integration/ClientIntegrationControllerTest.php +++ b/tests/Feature/Integration/ClientIntegrationControllerTest.php @@ -2,11 +2,13 @@ namespace Tests\Feature\Integration; +use App\Domains\Auth\Models\User; use App\Domains\Client\Models\Client; use App\Domains\Integration\Models\ClientIntegration; use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Services\ClientIntegrationService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Laravel\Sanctum\Sanctum; use Mockery; use Tests\TestCase; @@ -16,6 +18,7 @@ class ClientIntegrationControllerTest extends TestCase public function test_store_returns_success_message_without_integration_data(): void { + Sanctum::actingAs(User::factory()->create(['rol_codigo' => 'admin'])); $integration = Integration::create([ 'integration_code' => 'test_integration', 'name' => 'Test Integration', @@ -36,7 +39,7 @@ class ClientIntegrationControllerTest extends TestCase ->andReturn(new ClientIntegration); }); - $this->putJson('/api/clients/test-client/integrations/test_integration', [ + $this->withHeader('Accept-Language', 'es')->putJson('/api/clients/test-client/integrations/test_integration', [ 'integration_data' => [ 'api_key' => 'secret', ], diff --git a/tests/Feature/Integration/IntegrationInstanceTest.php b/tests/Feature/Integration/IntegrationInstanceTest.php new file mode 100644 index 0000000..dbb7026 --- /dev/null +++ b/tests/Feature/Integration/IntegrationInstanceTest.php @@ -0,0 +1,228 @@ + 'instance-test-secret']); + $this->integration = Integration::create([ + 'integration_code' => 'test', 'name' => 'Test', 'requires_configuration' => false, + 'integration_data_schema' => ['api_key' => 'required|string'], + ]); + } + + public function test_management_requires_an_authenticated_global_admin(): void + { + Client::create(['code' => 'acme', 'name' => 'Acme']); + WebsiteType::create(['codigo' => 'demo', 'nombre' => 'Demo']); + + $this->getJson('/api/clients/acme/integrations')->assertUnauthorized(); + Sanctum::actingAs(User::factory()->create()); + $this->getJson('/api/clients/acme/integrations')->assertForbidden(); + $this->getJson('/api/website-types/demo/integrations')->assertForbidden(); + } + + public function test_only_owner_endpoints_can_manage_configuration(): void + { + $this->admin(); + $client = Client::create(['code' => 'acme', 'name' => 'Acme']); + $type = WebsiteType::create(['codigo' => 'demo', 'nombre' => 'Demo']); + + $this->getJson('/api/integrations')->assertNotFound(); + $this->getJson('/api/integration-instances')->assertNotFound(); + $this->putJson('/api/clients/acme/integrations/test/instance', [])->assertNotFound(); + + $this->putJson('/api/clients/acme/integrations/test', ['integration_data' => []]) + ->assertUnprocessable()->assertJsonValidationErrors('integration_data.api_key'); + $this->putJson('/api/website-types/demo/integrations/test', ['integration_data' => []]) + ->assertUnprocessable()->assertJsonValidationErrors('integration_data.api_key'); + + $this->putJson('/api/clients/acme/integrations/test', [ + 'integration_data' => ['api_key' => 'client-secret'], + ])->assertOk()->assertJsonMissingPath('integration_data'); + $clientInstanceId = $client->integrations()->firstOrFail()->integration_instance_id; + self::assertSame('client-secret', IntegrationInstance::findOrFail($clientInstanceId)->integration_data['api_key']); + + $this->putJson('/api/website-types/demo/integrations/test', [ + 'integration_data' => ['api_key' => 'type-secret'], + ])->assertCreated() + ->assertJsonPath('data.integration_instance.name', 'Test / Demo') + ->assertJsonMissingPath('data.integration_instance.integration_data'); + $typeInstanceId = $type->integrations()->firstOrFail()->integration_instance_id; + $this->getJson('/api/website-types/demo/integrations/test') + ->assertOk()->assertJsonPath('data.integration_instance_id', $typeInstanceId); + + $this->deleteJson('/api/clients/acme/integrations/test')->assertNoContent(); + $this->deleteJson('/api/website-types/demo/integrations/test')->assertNoContent(); + } + + public function test_tenant_prefers_client_then_type_and_client_context_does_not_inherit(): void + { + $type = WebsiteType::create(['codigo' => 'demo', 'nombre' => 'Demo']); + $tenant = $this->tenantForType($type); + $associations = new IntegrationAssociationService; + $associations->associate($type, 'test', $this->makeInstance('type')); + $service = $this->probe(); + self::assertSame('type', $service->forTenant($tenant->codigo)->setting()); + self::assertNull($service->forClient($tenant->client)->setting()); + $associations->associate($tenant->client, 'test', $this->makeInstance('client')); + self::assertSame('client', $service->forTenant($tenant->codigo)->setting()); + $associations->detach($tenant->client, 'test'); + self::assertSame('type', $service->forTenant($tenant->codigo)->setting()); + $associations->detach($type, 'test'); + self::assertNull($service->forTenant($tenant->codigo)->setting()); + $this->integration->update(['requires_configuration' => true]); + $this->expectException(\Exception::class); + $service->forTenant($tenant->codigo); + } + + public function test_required_configuration_can_come_from_the_website_type(): void + { + $this->integration->update(['requires_configuration' => true]); + $type = WebsiteType::create(['codigo' => 'demo', 'nombre' => 'Demo']); + $tenant = $this->tenantForType($type); + (new IntegrationAssociationService)->associate($type, 'test', $this->makeInstance('type')); + self::assertSame('type', $this->probe()->forTenant($tenant->codigo)->setting()); + } + + public function test_legacy_save_does_not_modify_a_shared_instance(): void + { + $a = Client::create(['code' => 'a', 'name' => 'A']); + $b = Client::create(['code' => 'b', 'name' => 'B']); + $shared = $this->makeInstance('shared'); + $associations = new IntegrationAssociationService; + $associations->associate($a, 'test', $shared); + $associations->associate($b, 'test', $shared); + (new ClientIntegrationService)->updateOrCreateIntegration($a, $this->integration, ['api_key' => 'private']); + self::assertSame('private', $this->probe()->forClient($a)->setting()); + self::assertSame('shared', $this->probe()->forClient($b)->setting()); + (new IntegrationInstanceService)->update($shared, ['integration_data' => ['api_key' => 'changed']]); + self::assertSame('changed', $this->probe()->forClient($b)->setting()); + } + + public function test_reconfiguring_deletes_the_previous_instance_when_it_is_no_longer_used(): void + { + $client = Client::create(['code' => 'acme', 'name' => 'Acme']); + $associations = new IntegrationAssociationService; + $previous = $this->makeInstance('previous'); + + $associations->associate($client, 'test', $previous); + $current = $associations->configure($client, $this->integration, ['api_key' => 'current']); + + $this->assertDatabaseMissing('integration_instances', ['id' => $previous->id]); + $this->assertDatabaseHas('integration_instances', ['id' => $current->integration_instance_id]); + } + + public function test_an_instance_is_deleted_only_after_its_last_association_is_removed(): void + { + $client = Client::create(['code' => 'acme', 'name' => 'Acme']); + $type = WebsiteType::create(['codigo' => 'demo', 'nombre' => 'Demo']); + $shared = $this->makeInstance('shared'); + $associations = new IntegrationAssociationService; + + $associations->associate($client, 'test', $shared); + $associations->associate($type, 'test', $shared); + + $associations->detach($client, 'test'); + $this->assertDatabaseHas('integration_instances', ['id' => $shared->id]); + + $associations->detach($type, 'test'); + $this->assertDatabaseMissing('integration_instances', ['id' => $shared->id]); + } + + public function test_telepagos_shares_tokens_by_instance_and_refreshes_after_credential_changes(): void + { + Integration::create(['integration_code' => 'telepagos_homo', 'name' => 'Telepagos', 'url' => 'https://payments.test']); + $instance = IntegrationInstance::create([ + 'integration_code' => 'telepagos_homo', 'name' => 'Payments', + 'integration_data' => ['username' => 'first', 'password' => 'secret'], + ]); + $a = Client::create(['code' => 'a', 'name' => 'A']); + $b = Client::create(['code' => 'b', 'name' => 'B']); + $associations = new IntegrationAssociationService; + $associations->associate($a, 'telepagos_homo', $instance); + $associations->associate($b, 'telepagos_homo', $instance); + Cache::flush(); + Http::fake(['https://payments.test/v2/auth/token' => Http::sequence() + ->push(['status' => 'ok', 'token' => 'first-token', 'expires_at' => now()->addHour()->toDateTimeString()]) + ->push(['status' => 'ok', 'token' => 'new-token', 'expires_at' => now()->addHour()->toDateTimeString()])]); + self::assertSame('first-token', (new TelepagosIntegrationService('telepagos_homo'))->forClient($a)->getToken()); + self::assertSame('first-token', (new TelepagosIntegrationService('telepagos_homo'))->forClient($b)->getToken()); + Http::assertSentCount(1); + (new IntegrationInstanceService)->update($instance, ['integration_data' => ['username' => 'second', 'password' => 'new-secret']]); + self::assertSame('new-token', (new TelepagosIntegrationService('telepagos_homo'))->forClient($b)->getToken()); + Http::assertSentCount(2); + } + + private function tenantForType(WebsiteType $type): Tenant + { + $logo = Attachment::create([ + 'path' => 'tenants/logo.png', 'filename' => 'logo.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + + return Tenant::create([ + 'codigo' => 'acme', 'nombre' => 'Acme', 'dominio' => 'acme.test', + 'website_type_code' => $type->codigo, + 'primary_color' => '#112233', 'secondary_color' => '#445566', + 'danger_color' => '#ff0000', 'success_color' => '#00ff00', + 'header_bg_color' => '#112233', 'footer_bg_color' => '#112233', + 'header_logo_id' => $logo->id, 'footer_logo_id' => $logo->id, + ]); + } + + private function makeInstance(string $name): IntegrationInstance + { + return IntegrationInstance::create(['integration_code' => 'test', 'name' => $name, 'integration_data' => ['api_key' => $name]]); + } + + private function admin(): void + { + Sanctum::actingAs(User::factory()->create(['rol_codigo' => 'admin'])); + } + + private function probe(): BaseIntegrationService + { + return new class extends BaseIntegrationService + { + protected string $integrationCode = 'test'; + + public function getHeaders(): array + { + return []; + } + + public function setting(): mixed + { + return $this->getIntegrationSetting('api_key'); + } + }; + } +} diff --git a/tests/Feature/Integration/IntegrationServiceTest.php b/tests/Feature/Integration/IntegrationServiceTest.php index b14440c..847a4e6 100644 --- a/tests/Feature/Integration/IntegrationServiceTest.php +++ b/tests/Feature/Integration/IntegrationServiceTest.php @@ -4,7 +4,6 @@ namespace Tests\Feature\Integration; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; -use App\Domains\Integration\Models\ClientIntegration; use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Services\TelepagosIntegrationService; use App\Domains\Tenant\Models\Tenant; @@ -15,10 +14,12 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; +use Tests\Concerns\CreatesIntegrationInstances; use Tests\TestCase; class IntegrationServiceTest extends TestCase { + use CreatesIntegrationInstances; use RefreshDatabase; private Tenant $tenant; @@ -110,7 +111,7 @@ class IntegrationServiceTest extends TestCase ], ]); - ClientIntegration::create([ + $this->createClientIntegration([ 'client_id' => $this->tenant->client_id, 'integration_code' => 'telepagos_homo', 'integration_data' => [ @@ -139,7 +140,7 @@ class IntegrationServiceTest extends TestCase ], ]); - ClientIntegration::create([ + $this->createClientIntegration([ 'client_id' => $this->tenant->client_id, 'integration_code' => 'telepagos_homo', 'integration_data' => [ @@ -198,7 +199,7 @@ class IntegrationServiceTest extends TestCase ], ]); - ClientIntegration::create([ + $this->createClientIntegration([ 'client_id' => $this->tenant->client_id, 'integration_code' => 'telepagos_homo', 'integration_data' => [ @@ -242,7 +243,7 @@ class IntegrationServiceTest extends TestCase ], ]); - ClientIntegration::create([ + $this->createClientIntegration([ 'client_id' => $this->tenant->client_id, 'integration_code' => 'telepagos_homo', 'integration_data' => [ @@ -272,7 +273,7 @@ class IntegrationServiceTest extends TestCase ], ]); - ClientIntegration::create([ + $this->createClientIntegration([ 'client_id' => $this->tenant->client_id, 'integration_code' => 'telepagos_homo', 'integration_data' => [ @@ -323,7 +324,7 @@ class IntegrationServiceTest extends TestCase ], ]); - ClientIntegration::create([ + $this->createClientIntegration([ 'client_id' => $this->tenant->client_id, 'integration_code' => 'telepagos_homo', 'integration_data' => [ @@ -375,7 +376,7 @@ class IntegrationServiceTest extends TestCase ], ]); - ClientIntegration::create([ + $this->createClientIntegration([ 'client_id' => $this->tenant->client_id, 'integration_code' => 'telepagos_homo', 'integration_data' => [ @@ -427,7 +428,7 @@ class IntegrationServiceTest extends TestCase ], ]); - ClientIntegration::create([ + $this->createClientIntegration([ 'client_id' => $this->tenant->client_id, 'integration_code' => 'telepagos_homo', 'integration_data' => [ @@ -480,7 +481,7 @@ class IntegrationServiceTest extends TestCase ], ]); - ClientIntegration::create([ + $this->createClientIntegration([ 'client_id' => $this->tenant->client_id, 'integration_code' => 'telepagos_homo', 'integration_data' => [ @@ -541,7 +542,7 @@ class IntegrationServiceTest extends TestCase ], ]); - ClientIntegration::create([ + $this->createClientIntegration([ 'client_id' => $this->tenant->client_id, 'integration_code' => 'telepagos_homo', 'integration_data' => [ @@ -566,7 +567,7 @@ class IntegrationServiceTest extends TestCase Log::shouldReceive('error') ->once() ->with('Telepagos get cash-in details failed: Cashin no encontrado', \Mockery::on(function ($context) { - return $context['cashin_id'] === 6351 + return $context['cashin_id'] === '6351' && $context['response_status'] === 404 && $context['response_body'] === ['status' => 'error', 'message' => 'Cashin no encontrado']; })); diff --git a/tests/Feature/Integration/MailServiceTest.php b/tests/Feature/Integration/MailServiceTest.php index 622e976..6e7ffeb 100644 --- a/tests/Feature/Integration/MailServiceTest.php +++ b/tests/Feature/Integration/MailServiceTest.php @@ -4,28 +4,32 @@ namespace Tests\Feature\Integration; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; -use App\Domains\Integration\Models\ClientIntegration; use App\Domains\Integration\Models\Integration; +use App\Domains\Integration\Models\IntegrationInstance; use App\Domains\Integration\Services\ClientIntegrationService; +use App\Domains\Integration\Services\IntegrationAssociationService; use App\Domains\Integration\Services\MailService; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Tenant\Models\WebsiteType; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailer; use Illuminate\Mail\MailManager; use Illuminate\Support\Facades\Mail; use Mockery; +use Tests\Concerns\CreatesIntegrationInstances; use Tests\TestCase; class MailServiceTest extends TestCase { + use CreatesIntegrationInstances; use RefreshDatabase; public function test_it_builds_an_isolated_smtp_mailer_from_the_client_integration(): void { $tenant = $this->createTenant(); $this->createEmailIntegration(); - ClientIntegration::create([ + $this->createClientIntegration([ 'client_id' => $tenant->client_id, 'integration_code' => 'email', 'integration_data' => $this->emailData(), @@ -40,7 +44,7 @@ class MailServiceTest extends TestCase $manager->shouldReceive('build') ->once() ->with(Mockery::on(fn (array $config): bool => $config === [ - 'name' => 'client-smtp-'.$tenant->client_id, + 'name' => 'integration-smtp-'.IntegrationInstance::firstOrFail()->id, 'transport' => 'smtp', 'scheme' => 'smtp', 'host' => 'smtp.example.com', @@ -54,7 +58,7 @@ class MailServiceTest extends TestCase $service = (new MailService($manager))->forTenant($tenant->codigo); - $this->assertSame('client-smtp', $service->mailerName()); + $this->assertSame('integration-smtp', $service->mailerName()); } public function test_it_uses_the_default_mailer_when_client_configuration_is_not_required(): void @@ -65,7 +69,7 @@ class MailServiceTest extends TestCase Integration::create([ 'integration_code' => 'email', 'name' => 'Email', - 'requires_client_configuration' => false, + 'requires_configuration' => false, ]); $service = (new MailService)->forTenant($tenant->codigo); @@ -84,7 +88,7 @@ class MailServiceTest extends TestCase Mail::fake(); $tenant = $this->createTenant(); $this->createEmailIntegration(); - ClientIntegration::create([ + $this->createClientIntegration([ 'client_id' => $tenant->client_id, 'integration_code' => 'email', 'integration_data' => $this->emailData(), @@ -118,6 +122,24 @@ class MailServiceTest extends TestCase Mail::assertSent(Mailable::class, 1); } + public function test_it_uses_the_website_type_smtp_instance_without_a_client_association(): void + { + Mail::fake(); + $tenant = $this->createTenant(); + $this->createEmailIntegration(); + $type = WebsiteType::create(['codigo' => 'mail-brand', 'nombre' => 'Mail Brand']); + $tenant->update(['website_type_code' => $type->codigo]); + $instance = IntegrationInstance::create([ + 'integration_code' => 'email', 'name' => 'Brand SMTP', 'integration_data' => $this->emailData(), + ]); + (new IntegrationAssociationService)->associate($type, 'email', $instance); + + $service = (new MailService)->forTenant($tenant->codigo); + self::assertSame('integration-smtp', $service->mailerName()); + $service->send('customer@example.com', 'Brand mail', '

Brand SMTP

'); + Mail::assertSent(Mailable::class, 1); + } + private function createTenant(): Tenant { $logo = Attachment::create([ diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index b3d6e51..0edfed9 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -11,7 +11,6 @@ use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; -use App\Domains\Integration\Models\ClientIntegration; use App\Domains\Integration\Models\Integration; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\TelepagosPayment; @@ -22,10 +21,12 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Str; +use Tests\Concerns\CreatesIntegrationInstances; use Tests\TestCase; class TelepagosWebhookTest extends TestCase { + use CreatesIntegrationInstances; use RefreshDatabase; protected function setUp(): void @@ -633,7 +634,7 @@ class TelepagosWebhookTest extends TestCase ], ]); - ClientIntegration::create([ + $this->createClientIntegration([ 'client_id' => $tenant->client_id, 'integration_code' => 'telepagos_homo', 'integration_data' => [ diff --git a/tests/Feature/MailTest/MailTestControllerTest.php b/tests/Feature/MailTest/MailTestControllerTest.php index 05a3bab..f30d565 100644 --- a/tests/Feature/MailTest/MailTestControllerTest.php +++ b/tests/Feature/MailTest/MailTestControllerTest.php @@ -4,17 +4,18 @@ namespace Tests\Feature\MailTest; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; -use App\Domains\Integration\Models\ClientIntegration; use App\Domains\Integration\Models\Integration; use App\Domains\MailTest\Mailables\TestMail; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Mail\Mailable; use Illuminate\Support\Facades\Mail; +use Tests\Concerns\CreatesIntegrationInstances; use Tests\TestCase; class MailTestControllerTest extends TestCase { + use CreatesIntegrationInstances; use RefreshDatabase; public function test_it_sends_a_test_email(): void @@ -22,7 +23,7 @@ class MailTestControllerTest extends TestCase Mail::fake(); $tenant = $this->createTenant(); - $response = $this->postJson('/api/acme/mail-test/send', [ + $response = $this->withHeader('Accept-Language', 'es')->postJson('/api/acme/mail-test/send', [ 'to' => 'recipient@example.com', 'subject' => 'SMTP test', 'message' => 'Test message', @@ -32,7 +33,7 @@ class MailTestControllerTest extends TestCase ->assertJsonPath('message', 'Correo de prueba enviado correctamente.') ->assertJsonPath('recipient', 'recipient@example.com') ->assertJsonPath('tenant_code', 'acme') - ->assertJsonPath('mailer', 'tenant-smtp') + ->assertJsonPath('mailer', 'integration-smtp') ->assertJsonStructure(['sent_at']); Mail::assertSent(Mailable::class, function (Mailable $mail) use ($tenant): bool { @@ -48,7 +49,7 @@ class MailTestControllerTest extends TestCase Mail::fake(); $this->createTenant(); - $this->postJson('/api/acme/mail-test/send', [ + $this->withHeader('Accept-Language', 'es')->postJson('/api/acme/mail-test/send', [ 'to' => 'recipient@example.com', ])->assertOk(); @@ -63,7 +64,7 @@ class MailTestControllerTest extends TestCase Mail::fake(); $this->createTenant(); - $this->postJson('/api/acme/mail-test/send', [ + $this->withHeader('Accept-Language', 'es')->postJson('/api/acme/mail-test/send', [ 'to' => 'invalid-email', ])->assertUnprocessable() ->assertJsonValidationErrors(['to']); @@ -117,7 +118,7 @@ class MailTestControllerTest extends TestCase { Mail::fake(); - $this->postJson('/api/unknown/mail-test/send', [ + $this->withHeader('Accept-Language', 'es')->postJson('/api/unknown/mail-test/send', [ 'to' => 'recipient@example.com', ])->assertNotFound(); @@ -129,7 +130,7 @@ class MailTestControllerTest extends TestCase Mail::fake(); $tenant = $this->createTenant(); - $this->postJson("/api/{$tenant->id}/mail-test/send", [ + $this->withHeader('Accept-Language', 'es')->postJson("/api/{$tenant->id}/mail-test/send", [ 'to' => 'recipient@example.com', ])->assertNotFound(); @@ -171,7 +172,7 @@ class MailTestControllerTest extends TestCase 'integration_code' => 'email', 'name' => 'Email', ]); - ClientIntegration::create([ + $this->createClientIntegration([ 'client_id' => $tenant->client_id, 'integration_code' => 'email', 'integration_data' => [ diff --git a/tests/Feature/Notification/NotificationMailServiceTest.php b/tests/Feature/Notification/NotificationMailServiceTest.php index 6190db9..eef0c1c 100644 --- a/tests/Feature/Notification/NotificationMailServiceTest.php +++ b/tests/Feature/Notification/NotificationMailServiceTest.php @@ -36,7 +36,7 @@ class NotificationMailServiceTest extends TestCase 'integration_code' => 'email', 'name' => 'Email', 'url' => null, - 'requires_client_configuration' => false, + 'requires_configuration' => false, 'integration_data_schema' => [], ]); $header = Attachment::query()->create([ diff --git a/tests/Unit/IntegrationInstanceSchemaTest.php b/tests/Unit/IntegrationInstanceSchemaTest.php new file mode 100644 index 0000000..d00560c --- /dev/null +++ b/tests/Unit/IntegrationInstanceSchemaTest.php @@ -0,0 +1,173 @@ +previousFacadeApplication = Facade::getFacadeApplication(); + $this->previousContainer = Container::getInstance(); + $container = new Container; + Container::setInstance($container); + $container->instance('config', new Repository([ + 'services' => ['integrations' => ['secret' => 'schema-test-secret']], + 'app' => ['cipher' => 'AES-256-CBC'], + ])); + $this->database = new Manager($container); + $this->database->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'foreign_key_constraints' => true]); + $this->database->bootEloquent(); + $container->instance('db', $this->database->getDatabaseManager()); + $container->bind('db.schema', fn () => $this->database->getConnection()->getSchemaBuilder()); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($container); + + $schema = $this->database->getConnection()->getSchemaBuilder(); + $schema->create('clients', function (Blueprint $table): void { + $table->id(); + }); + $schema->create('website_type', function (Blueprint $table): void { + $table->id(); + $table->string('codigo')->unique(); + }); + (require __DIR__.'/../../database/migrations/2026_07_03_000001_create_integrations_table.php')->up(); + $schema->table('integrations', function (Blueprint $table): void { + $table->boolean('requires_client_configuration')->default(true); + }); + $schema->create('client_integrations', function (Blueprint $table): void { + $table->id(); + $table->foreignId('client_id')->constrained('clients')->cascadeOnDelete(); + $table->string('integration_code'); + $table->longText('integration_data')->nullable(); + $table->timestamps(); + $table->foreign('integration_code')->references('integration_code')->on('integrations')->cascadeOnDelete(); + $table->unique(['client_id', 'integration_code']); + }); + $db = $this->database->getConnection(); + $db->table('clients')->insert(['id' => 1]); + $db->table('website_type')->insert(['codigo' => 'onticket']); + $db->table('integrations')->insert([ + ['integration_code' => 'email', 'name' => 'Email'], + ['integration_code' => 'telepagos', 'name' => 'Telepagos'], + ]); + $db->table('client_integrations')->insert([ + 'client_id' => 1, 'integration_code' => 'email', 'integration_data' => 'existing-ciphertext', + ]); + $this->migrations = array_map(fn (string $file) => require $file, glob(__DIR__.'/../../database/migrations/2026_09_04_*.php')); + foreach ($this->migrations as $migration) { + $migration->up(); + } + } + + protected function tearDown(): void + { + $this->database->getConnection()->disconnect(); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($this->previousFacadeApplication); + Container::setInstance($this->previousContainer); + parent::tearDown(); + } + + public function test_migration_preserves_ciphertext_and_rollback_restores_it(): void + { + $db = $this->database->getConnection(); + $association = $db->table('client_integrations')->first(); + self::assertSame('existing-ciphertext', $db->table('integration_instances')->where('id', $association->integration_instance_id)->value('integration_data')); + self::assertFalse($db->getSchemaBuilder()->hasColumn('client_integrations', 'integration_data')); + + foreach (array_reverse($this->migrations) as $migration) { + $migration->down(); + } + self::assertSame('existing-ciphertext', $db->table('client_integrations')->value('integration_data')); + self::assertFalse($db->getSchemaBuilder()->hasTable('integration_instances')); + } + + public function test_instances_encrypt_data_and_can_be_shared_through_relations(): void + { + $instance = IntegrationInstance::firstOrFail(); + $instance->update(['integration_data' => ['password' => 'private-value']]); + self::assertNotSame('private-value', $instance->getRawOriginal('integration_data')); + self::assertSame(['password' => 'private-value'], $instance->fresh()->integration_data); + self::assertArrayNotHasKey('integration_data', $instance->toArray()); + WebsiteTypeIntegration::create([ + 'website_type_code' => 'onticket', 'integration_code' => 'email', 'integration_instance_id' => $instance->id, + ]); + self::assertTrue(ClientIntegration::firstOrFail()->integrationInstance->is($instance)); + self::assertTrue(WebsiteType::firstOrFail()->integrations->first()->integrationInstance->is($instance)); + self::assertSame(1, $instance->clientIntegrations()->count()); + self::assertSame(1, $instance->websiteTypeIntegrations()->count()); + self::assertTrue(Integration::where('integration_code', 'email')->firstOrFail()->instances->first()->is($instance)); + } + + public function test_retry_after_foreign_key_failure_preserves_existing_instances(): void + { + $db = $this->database->getConnection(); + $instanceId = $db->table('client_integrations')->value('integration_instance_id'); + // Reproduce the state left by MySQL when ADD CONSTRAINT fails after the backfill. + $db->getSchemaBuilder()->table('client_integrations', function (Blueprint $table): void { + $table->dropForeign('client_integrations_instance_code_fk') + ->columns(['integration_instance_id', 'integration_code']); + $table->longText('integration_data')->nullable(); + }); + $db->table('client_integrations')->update(['integration_data' => 'existing-ciphertext']); + + $this->migrations[1]->up(); + $this->migrations[1]->up(); + + self::assertSame(1, $db->table('integration_instances')->count()); + self::assertSame($instanceId, $db->table('client_integrations')->value('integration_instance_id')); + self::assertSame('existing-ciphertext', $db->table('integration_instances')->value('integration_data')); + self::assertFalse($db->getSchemaBuilder()->hasColumn('client_integrations', 'integration_data')); + $keys = $db->getSchemaBuilder()->getForeignKeys('client_integrations'); + self::assertCount(1, array_filter($keys, fn (array $key): bool => $key['columns'] === ['integration_instance_id', 'integration_code'])); + } + + public function test_association_rejects_an_instance_from_another_integration(): void + { + $this->expectException(QueryException::class); + WebsiteTypeIntegration::create([ + 'website_type_code' => 'onticket', 'integration_code' => 'telepagos', 'integration_instance_id' => IntegrationInstance::firstOrFail()->id, + ]); + } + + public function test_client_cannot_have_two_instances_of_the_same_integration(): void + { + $instance = IntegrationInstance::create(['integration_code' => 'email', 'name' => 'Second']); + $this->expectException(QueryException::class); + ClientIntegration::create(['client_id' => 1, 'integration_code' => 'email', 'integration_instance_id' => $instance->id]); + } + + public function test_website_type_cannot_have_two_instances_of_the_same_integration(): void + { + WebsiteTypeIntegration::create([ + 'website_type_code' => 'onticket', 'integration_code' => 'email', 'integration_instance_id' => IntegrationInstance::firstOrFail()->id, + ]); + $instance = IntegrationInstance::create(['integration_code' => 'email', 'name' => 'Second']); + $this->expectException(QueryException::class); + WebsiteTypeIntegration::create([ + 'website_type_code' => 'onticket', 'integration_code' => 'email', 'integration_instance_id' => $instance->id, + ]); + } +}