feat(integration): add IntegrationInstance and WebsiteTypeIntegration models with migrations; refactor ClientIntegration and Integration models
This commit is contained in:
parent
1e7a9b6876
commit
f11ba5a470
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue