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, ]); } }