feat: Introduce client management and integration updates

- Added client_id field to StoreTenantRequest and UpdateTenantRequest for tenant management.
- Updated TenantResource to include client_id in the response.
- Created migration to establish clients table and link tenants to clients.
- Migrated existing tenant integrations to client_integrations table.
- Grouped specific tenants under a shared client (OnTicket) in a new migration.
- Updated seeders to reflect new client structure and relationships.
- Adjusted integration configurations to require client instead of tenant.
- Added tests for client integration functionality and ensured existing tests reflect the new client structure.
This commit is contained in:
2026-08-18 16:18:34 -03:00
parent e6785eb5af
commit d73b4d1daf
42 changed files with 931 additions and 392 deletions

View File

@@ -2,14 +2,15 @@
namespace Tests\Feature\Integration;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration;
use App\Domains\Integration\Services\TenantIntegrationService;
use App\Domains\Integration\Services\ClientIntegrationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class TenantIntegrationControllerTest extends TestCase
class ClientIntegrationControllerTest extends TestCase
{
use RefreshDatabase;
@@ -22,19 +23,20 @@ class TenantIntegrationControllerTest extends TestCase
'api_key' => 'required|string',
],
]);
$client = Client::query()->create(['code' => 'test-client', 'name' => 'Test Client']);
$this->mock(TenantIntegrationService::class, function ($mock) use ($integration) {
$this->mock(ClientIntegrationService::class, function ($mock) use ($client, $integration) {
$mock->shouldReceive('updateOrCreateIntegration')
->once()
->with(
'test-tenant',
Mockery::on(fn (Client $argument) => $argument->is($client)),
Mockery::on(fn (Integration $argument) => $argument->is($integration)),
['api_key' => 'secret']
)
->andReturn(new TenantIntegration);
->andReturn(new ClientIntegration);
});
$this->postJson('/api/test-tenant/integrations/test_integration', [
$this->putJson('/api/clients/test-client/integrations/test_integration', [
'integration_data' => [
'api_key' => 'secret',
],

View File

@@ -2,15 +2,20 @@
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\TenantIntegration;
use App\Domains\Integration\Services\TelepagosIntegrationService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
use Exception;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Tests\TestCase;
class IntegrationServiceTest extends TestCase
{
@@ -23,26 +28,26 @@ class IntegrationServiceTest extends TestCase
parent::setUp();
// Set the integrations secret for tests
config(['services.integrations.secret' => 'base64:' . base64_encode(random_bytes(32))]);
config(['services.integrations.secret' => 'base64:'.base64_encode(random_bytes(32))]);
// Clear cache to prevent test pollution
Cache::flush();
$hdrKey = (string) \Illuminate\Support\Str::uuid();
$ftrKey = (string) \Illuminate\Support\Str::uuid();
$hdrKey = (string) Str::uuid();
$ftrKey = (string) Str::uuid();
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([
$headerAttachment = Attachment::create([
'key' => $hdrKey,
'path' => 'tenants/' . $hdrKey . '.png',
'path' => 'tenants/'.$hdrKey.'.png',
'filename' => 'logo_header.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([
$footerAttachment = Attachment::create([
'key' => $ftrKey,
'path' => 'tenants/' . $ftrKey . '.png',
'path' => 'tenants/'.$ftrKey.'.png',
'filename' => 'logo_footer.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
'type' => AttachmentType::Image,
'mime_type' => 'image/png',
]);
@@ -72,7 +77,7 @@ class IntegrationServiceTest extends TestCase
$service->forTenant($this->tenant->codigo);
}
public function test_it_throws_exception_if_tenant_integration_is_not_configured(): void
public function test_it_throws_exception_if_client_integration_is_not_configured(): void
{
// Seed integration but don't configure for tenant
Integration::create([
@@ -82,13 +87,13 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
]
],
]);
$service = new TelepagosIntegrationService('telepagos');
$this->expectException(Exception::class);
$this->expectExceptionMessage("Tenant 'test-tenant' does not have integration 'telepagos_homo' configured.");
$this->expectExceptionMessage("Client 'test-tenant' does not have integration 'telepagos_homo' configured.");
$service->forTenant($this->tenant->codigo);
}
@@ -102,16 +107,16 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
]
],
]);
TenantIntegration::create([
'tenant_code' => $this->tenant->codigo,
ClientIntegration::create([
'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo',
'integration_data' => [
'username' => 'user123',
'password' => 'pass123',
]
],
]);
$service = new TelepagosIntegrationService('telepagos');
@@ -131,16 +136,16 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
]
],
]);
TenantIntegration::create([
'tenant_code' => $this->tenant->codigo,
ClientIntegration::create([
'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo',
'integration_data' => [
'username' => 'tele_user',
'password' => 'tele_pass',
]
],
]);
// Mock HTTP response sequence for authentication
@@ -149,13 +154,13 @@ class IntegrationServiceTest extends TestCase
->push([
'status' => 'ok',
'token' => 'mock-jwt-token-123',
'expires_at' => now()->addHour()->toDateTimeString()
'expires_at' => now()->addHour()->toDateTimeString(),
], 200)
->push([
'status' => 'ok',
'token' => 'new-mock-jwt-token',
'expires_at' => now()->addHour()->toDateTimeString()
], 200)
'expires_at' => now()->addHour()->toDateTimeString(),
], 200),
]);
$service = new TelepagosIntegrationService('telepagos');
@@ -181,6 +186,50 @@ class IntegrationServiceTest extends TestCase
Http::assertSentCount(2);
}
public function test_tenants_from_the_same_client_share_configuration_and_cached_token(): void
{
Integration::create([
'integration_code' => 'telepagos_homo',
'name' => 'Telepagos',
'url' => 'https://api.telepagos.com.ar',
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
],
]);
ClientIntegration::create([
'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo',
'integration_data' => [
'username' => 'shared-user',
'password' => 'shared-password',
],
]);
$secondTenant = $this->tenant->replicate()->fill([
'codigo' => 'second-tenant',
'nombre' => 'Second Tenant',
'dominio' => 'second.test.com',
]);
$secondTenant->save();
Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok',
'token' => 'shared-token',
'expires_at' => now()->addHour()->toDateTimeString(),
]),
]);
$firstService = (new TelepagosIntegrationService('telepagos'))->forTenant($this->tenant->codigo);
$secondService = (new TelepagosIntegrationService('telepagos'))->forTenant($secondTenant->codigo);
$this->assertSame('shared-token', $firstService->getToken());
$this->assertSame('shared-token', $secondService->getToken());
Http::assertSentCount(1);
}
public function test_it_throws_exception_if_credentials_are_missing(): void
{
Integration::create([
@@ -190,23 +239,23 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
]
],
]);
TenantIntegration::create([
'tenant_code' => $this->tenant->codigo,
ClientIntegration::create([
'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo',
'integration_data' => [
'username' => '',
'password' => 'tele_pass',
]
],
]);
$service = new TelepagosIntegrationService('telepagos');
$service->forTenant($this->tenant->codigo);
$this->expectException(Exception::class);
$this->expectExceptionMessage("Missing username or password in Telepagos integration settings.");
$this->expectExceptionMessage('Missing username or password in Telepagos integration settings.');
$service->getToken();
}
@@ -220,26 +269,26 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
]
],
]);
TenantIntegration::create([
'tenant_code' => $this->tenant->codigo,
ClientIntegration::create([
'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo',
'integration_data' => [
'username' => 'tele_user',
'password' => 'tele_pass',
]
],
]);
Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'error',
'message' => 'Invalid credentials'
], 401)
'message' => 'Invalid credentials',
], 401),
]);
\Illuminate\Support\Facades\Log::shouldReceive('error')
Log::shouldReceive('error')
->once()
->with('Telepagos authentication failed: Invalid credentials', \Mockery::on(function ($context) {
return $context['username'] === 'tele_user'
@@ -251,7 +300,7 @@ class IntegrationServiceTest extends TestCase
$service->forTenant($this->tenant->codigo);
$this->expectException(Exception::class);
$this->expectExceptionMessage("Telepagos authentication failed: Invalid credentials");
$this->expectExceptionMessage('Telepagos authentication failed: Invalid credentials');
$service->getToken();
}
@@ -265,28 +314,28 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
]
],
]);
TenantIntegration::create([
'tenant_code' => $this->tenant->codigo,
ClientIntegration::create([
'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo',
'integration_data' => [
'username' => 'tele_user',
'password' => 'tele_pass',
]
],
]);
Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok',
'token' => 'mock-jwt-token-123',
'expires_at' => now()->addHour()->toDateTimeString()
'expires_at' => now()->addHour()->toDateTimeString(),
], 200),
'https://api.telepagos.com.ar/v1/payments' => Http::response([
'status' => 'success',
'payment_id' => 999
], 200)
'payment_id' => 999,
], 200),
]);
$service = new TelepagosIntegrationService('telepagos');
@@ -294,7 +343,7 @@ class IntegrationServiceTest extends TestCase
// Get configured client and perform GET request
$client = $service->client();
$this->assertInstanceOf(\Illuminate\Http\Client\PendingRequest::class, $client);
$this->assertInstanceOf(PendingRequest::class, $client);
$response = $client->get('/v1/payments');
@@ -317,29 +366,29 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
]
],
]);
TenantIntegration::create([
'tenant_code' => $this->tenant->codigo,
ClientIntegration::create([
'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo',
'integration_data' => [
'username' => 'tele_user',
'password' => 'tele_pass',
]
],
]);
Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok',
'token' => 'mock-jwt-token-123',
'expires_at' => now()->addHour()->toDateTimeString()
'expires_at' => now()->addHour()->toDateTimeString(),
], 200),
'https://api.telepagos.com.ar/v2/payment/cashin/qr/generate' => Http::response([
'status' => 'ok',
'qr_code' => 'mock-qr-code-data',
'qr_order_id' => 6353
], 200)
'qr_order_id' => 6353,
], 200),
]);
$service = new TelepagosIntegrationService('telepagos');
@@ -369,31 +418,31 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
]
],
]);
TenantIntegration::create([
'tenant_code' => $this->tenant->codigo,
ClientIntegration::create([
'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo',
'integration_data' => [
'username' => 'tele_user',
'password' => 'tele_pass',
]
],
]);
Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok',
'token' => 'mock-jwt-token-123',
'expires_at' => now()->addHour()->toDateTimeString()
'expires_at' => now()->addHour()->toDateTimeString(),
], 200),
'https://api.telepagos.com.ar/v2/payment/cashin/qr/generate' => Http::response([
'status' => 'error',
'message' => 'Importe inválido'
], 422)
'message' => 'Importe inválido',
], 422),
]);
\Illuminate\Support\Facades\Log::shouldReceive('error')
Log::shouldReceive('error')
->once()
->with('Telepagos QR generation failed: Importe inválido', \Mockery::on(function ($context) {
return $context['amount'] === 1200.00
@@ -407,7 +456,7 @@ class IntegrationServiceTest extends TestCase
$service->forTenant($this->tenant->codigo);
$this->expectException(Exception::class);
$this->expectExceptionMessage("Telepagos QR generation failed: Importe inválido");
$this->expectExceptionMessage('Telepagos QR generation failed: Importe inválido');
$service->generateQr(1200.00, 'Test Concept', 'Test Description');
}
@@ -421,29 +470,29 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
]
],
]);
TenantIntegration::create([
'tenant_code' => $this->tenant->codigo,
ClientIntegration::create([
'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo',
'integration_data' => [
'username' => 'tele_user',
'password' => 'tele_pass',
]
],
]);
Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok',
'token' => 'mock-jwt-token-123',
'expires_at' => now()->addHour()->toDateTimeString()
'expires_at' => now()->addHour()->toDateTimeString(),
], 200),
'https://api.telepagos.com.ar/v2/payment/cashin/6351' => Http::response([
'status' => 'ok',
'buyer' => [
'cuit' => '20416561398',
'cvu' => '0000124900000000011974'
'cvu' => '0000124900000000011974',
],
'amount' => 1200,
'concept' => 'VAR',
@@ -452,8 +501,8 @@ class IntegrationServiceTest extends TestCase
'description' => 'Pago prueba',
'transaction_id' => '2026070364',
'qr_order_id' => 6351,
'link_id' => null
], 200)
'link_id' => null,
], 200),
]);
$service = new TelepagosIntegrationService('telepagos');
@@ -482,31 +531,31 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
]
],
]);
TenantIntegration::create([
'tenant_code' => $this->tenant->codigo,
ClientIntegration::create([
'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo',
'integration_data' => [
'username' => 'tele_user',
'password' => 'tele_pass',
]
],
]);
Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok',
'token' => 'mock-jwt-token-123',
'expires_at' => now()->addHour()->toDateTimeString()
'expires_at' => now()->addHour()->toDateTimeString(),
], 200),
'https://api.telepagos.com.ar/v2/payment/cashin/6351' => Http::response([
'status' => 'error',
'message' => 'Cashin no encontrado'
], 404)
'message' => 'Cashin no encontrado',
], 404),
]);
\Illuminate\Support\Facades\Log::shouldReceive('error')
Log::shouldReceive('error')
->once()
->with('Telepagos get cash-in details failed: Cashin no encontrado', \Mockery::on(function ($context) {
return $context['cashin_id'] === 6351
@@ -518,7 +567,7 @@ class IntegrationServiceTest extends TestCase
$service->forTenant($this->tenant->codigo);
$this->expectException(Exception::class);
$this->expectExceptionMessage("Telepagos get cash-in details failed: Cashin no encontrado");
$this->expectExceptionMessage('Telepagos get cash-in details failed: Cashin no encontrado');
$service->getCashinDetails(6351);
}

View File

@@ -4,10 +4,10 @@ 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\TenantIntegration;
use App\Domains\Integration\Services\ClientIntegrationService;
use App\Domains\Integration\Services\MailService;
use App\Domains\Integration\Services\TenantIntegrationService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Mail\Mailable;
@@ -21,12 +21,12 @@ class MailServiceTest extends TestCase
{
use RefreshDatabase;
public function test_it_builds_an_isolated_smtp_mailer_from_the_tenant_integration(): void
public function test_it_builds_an_isolated_smtp_mailer_from_the_client_integration(): void
{
$tenant = $this->createTenant();
$this->createEmailIntegration();
TenantIntegration::create([
'tenant_code' => $tenant->codigo,
ClientIntegration::create([
'client_id' => $tenant->client_id,
'integration_code' => 'email',
'integration_data' => $this->emailData(),
]);
@@ -40,7 +40,7 @@ class MailServiceTest extends TestCase
$manager->shouldReceive('build')
->once()
->with(Mockery::on(fn (array $config): bool => $config === [
'name' => 'tenant-smtp-acme',
'name' => 'client-smtp-'.$tenant->client_id,
'transport' => 'smtp',
'scheme' => 'smtp',
'host' => 'smtp.example.com',
@@ -54,10 +54,10 @@ class MailServiceTest extends TestCase
$service = (new MailService($manager))->forTenant($tenant->codigo);
$this->assertSame('tenant-smtp', $service->mailerName());
$this->assertSame('client-smtp', $service->mailerName());
}
public function test_it_uses_the_default_mailer_when_tenant_configuration_is_not_required(): void
public function test_it_uses_the_default_mailer_when_client_configuration_is_not_required(): void
{
Mail::fake();
config(['mail.default' => 'array']);
@@ -65,7 +65,7 @@ class MailServiceTest extends TestCase
Integration::create([
'integration_code' => 'email',
'name' => 'Email',
'requires_tenant_configuration' => false,
'requires_client_configuration' => false,
]);
$service = (new MailService)->forTenant($tenant->codigo);
@@ -84,8 +84,8 @@ class MailServiceTest extends TestCase
Mail::fake();
$tenant = $this->createTenant();
$this->createEmailIntegration();
TenantIntegration::create([
'tenant_code' => $tenant->codigo,
ClientIntegration::create([
'client_id' => $tenant->client_id,
'integration_code' => 'email',
'integration_data' => $this->emailData(),
]);
@@ -109,8 +109,8 @@ class MailServiceTest extends TestCase
$tenant = $this->createTenant();
$integration = $this->createEmailIntegration();
app(TenantIntegrationService::class)->updateOrCreateIntegration(
$tenant->codigo,
app(ClientIntegrationService::class)->updateOrCreateIntegration(
$tenant->client,
$integration,
$this->emailData(),
);

View File

@@ -11,8 +11,8 @@ 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\Integration\Models\TenantIntegration;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
@@ -357,8 +357,8 @@ class TelepagosWebhookTest extends TestCase
],
]);
TenantIntegration::create([
'tenant_code' => $tenant->codigo,
ClientIntegration::create([
'client_id' => $tenant->client_id,
'integration_code' => 'telepagos_homo',
'integration_data' => [
'username' => 'user123',

View File

@@ -4,8 +4,8 @@ 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\Integration\Models\TenantIntegration;
use App\Domains\MailTest\Mailables\TestMail;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -171,8 +171,8 @@ class MailTestControllerTest extends TestCase
'integration_code' => 'email',
'name' => 'Email',
]);
TenantIntegration::create([
'tenant_code' => $tenant->codigo,
ClientIntegration::create([
'client_id' => $tenant->client_id,
'integration_code' => 'email',
'integration_data' => [
'MAIL_SCHEME' => 'smtp',

View File

@@ -35,7 +35,7 @@ class NotificationMailServiceTest extends TestCase
'integration_code' => 'email',
'name' => 'Email',
'url' => null,
'requires_tenant_configuration' => false,
'requires_client_configuration' => false,
'integration_data_schema' => [],
]);
$header = Attachment::query()->create([