feat(integration): add IntegrationInstance and WebsiteTypeIntegration models with migrations; refactor ClientIntegration and Integration models

This commit is contained in:
ncoronel 2026-09-04 12:17:30 -03:00
parent 1e7a9b6876
commit f11ba5a470
9 changed files with 402 additions and 9 deletions

View File

@ -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<Client, $this> */
@ -32,4 +25,10 @@ class ClientIntegration extends Model
{
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
}
/** @return BelongsTo<IntegrationInstance, $this> */
public function integrationInstance(): BelongsTo
{
return $this->belongsTo(IntegrationInstance::class);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Domains\Integration\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Integration extends Model
{
@ -21,8 +22,21 @@ class Integration extends Model
'requires_client_configuration' => 'boolean',
];
public function clientIntegrations()
/** @return HasMany<IntegrationInstance, $this> */
public function instances(): HasMany
{
return $this->hasMany(IntegrationInstance::class, 'integration_code', 'integration_code');
}
/** @return HasMany<ClientIntegration, $this> */
public function clientIntegrations(): HasMany
{
return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
}
/** @return HasMany<WebsiteTypeIntegration, $this> */
public function websiteTypeIntegrations(): HasMany
{
return $this->hasMany(WebsiteTypeIntegration::class, 'integration_code', 'integration_code');
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Domains\Integration\Models;
use App\Domains\Integration\Casts\EncryptedIntegrationData;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class IntegrationInstance extends Model
{
protected $fillable = ['integration_code', 'name', 'integration_data'];
protected $hidden = ['integration_data'];
protected $casts = ['integration_data' => EncryptedIntegrationData::class];
/** @return BelongsTo<Integration, $this> */
public function integration(): BelongsTo
{
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
}
/** @return HasMany<ClientIntegration, $this> */
public function clientIntegrations(): HasMany
{
return $this->hasMany(ClientIntegration::class);
}
/** @return HasMany<WebsiteTypeIntegration, $this> */
public function websiteTypeIntegrations(): HasMany
{
return $this->hasMany(WebsiteTypeIntegration::class);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Domains\Integration\Models;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WebsiteTypeIntegration extends Model
{
protected $fillable = ['website_type_code', 'integration_code', 'integration_instance_id'];
/** @return BelongsTo<WebsiteType, $this> */
public function websiteType(): BelongsTo
{
return $this->belongsTo(WebsiteType::class, 'website_type_code', 'codigo');
}
/** @return BelongsTo<Integration, $this> */
public function integration(): BelongsTo
{
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
}
/** @return BelongsTo<IntegrationInstance, $this> */
public function integrationInstance(): BelongsTo
{
return $this->belongsTo(IntegrationInstance::class);
}
}

View File

@ -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<WebsiteTypeIntegration, $this> */
public function integrations(): HasMany
{
return $this->hasMany(WebsiteTypeIntegration::class, 'website_type_code', 'codigo');
}
/**
* @return BelongsTo<Attachment, $this>
*/

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('integration_instances', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -0,0 +1,81 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// MySQL commits DDL separately; allow retrying after a partially applied migration.
if (! Schema::hasColumn('client_integrations', 'integration_instance_id')) {
Schema::table('client_integrations', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('website_type_integrations', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -0,0 +1,170 @@
<?php
namespace Tests\Unit;
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\WebsiteType;
use Illuminate\Config\Repository;
use Illuminate\Container\Container;
use Illuminate\Database\Capsule\Manager;
use Illuminate\Database\QueryException;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Facade;
use PHPUnit\Framework\TestCase;
class IntegrationInstanceSchemaTest extends TestCase
{
private Manager $database;
private array $migrations;
private mixed $previousFacadeApplication;
private Container $previousContainer;
protected function setUp(): void
{
parent::setUp();
$this->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->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,
]);
}
}