$modelClass * @param array $attributes */ function transferCoverageRecord(string $modelClass, array $attributes = []): Model { $model = new $modelClass; foreach (Schema::getColumns($model->getTable()) as $column) { $name = $column['name']; if (array_key_exists($name, $attributes) || $column['nullable'] || $column['default'] !== null || $name === 'id' || $column['auto_increment']) { continue; } // The SQLite test schema reports dates as text, so the model casts decide first. $type = strtolower($column['type_name']); $attributes[$name] = match (true) { $name === 'uuid' => new_public_id(), $model->hasCast($name, ['date', 'datetime', 'immutable_date', 'immutable_datetime', 'timestamp']) => now(), $model->hasCast($name, ['bool', 'boolean']) || str_contains($type, 'bool') => false, $model->hasCast($name, ['array', 'json', 'collection', 'object']) || str_contains($type, 'json') => [], str_contains($type, 'int') => 1, default => 'coverage_'.$name, }; } return $modelClass::withoutEvents(function () use ($model, $attributes) { $model->forceFill($attributes)->save(); return $model; }); } /** * Every key that the records at these bundle paths contain, also the keys of their `attributes` object. * A record with a `model` key counts only for that model. * * @param array $bundle * @param list $paths * @return array{keys: list, records: int} */ function transferCoverageBundleKeys(array $bundle, array $paths, string $modelClass): array { $records = collect($paths) ->flatMap(function (string $path) use ($bundle) { $found = data_get($bundle, $path); // A path to one record returns the record itself; a path with `*` returns a list of records. return is_array($found) && ! array_is_list($found) ? [$found] : Arr::wrap($found); }) ->filter(fn ($record) => is_array($record) && ! array_is_list($record)) ->filter(fn (array $record) => ! isset($record['model']) || $record['model'] === $modelClass); $keys = $records->flatMap(fn (array $record) => [...array_keys($record), ...array_keys((array) ($record['attributes'] ?? []))]); return ['keys' => $keys->unique()->values()->all(), 'records' => $records->count()]; } beforeEach(function () { Server::flushIdentityMap(); Process::fake(); Storage::fake('ssh-keys'); Storage::fake('images'); Queue::fake([ValidateAndInstallServerJob::class]); InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']); $team = Team::factory()->create(); $teamId = ['team_id' => $team->id]; $privateKey = PrivateKey::factory()->create($teamId); $deployKey = PrivateKey::factory()->create([...$teamId, 'private_key' => generateSSHKey('ed25519')['private']]); $token = CloudProviderToken::factory()->create($teamId); $this->server = Server::factory()->create([...$teamId, 'private_key_id' => $privateKey->id, 'cloud_provider_token_id' => $token->id, 'ip' => '10.88.0.10']); $destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); transferCoverageRecord(SwarmDocker::class, ['server_id' => $this->server->id]); transferCoverageRecord(SslCertificate::class, ['server_id' => $this->server->id]); $project = Project::factory()->create($teamId); Storage::disk('images')->put("project-icons/{$project->uuid}/icon.jpg", "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00coverage"); $project->forceFill(['icon_path' => "project-icons/{$project->uuid}/icon.jpg", 'icon_storage_type' => 'local'])->saveQuietly(); $environment = $project->environments()->first() ?? Environment::factory()->create(['project_id' => $project->id]); $onDestination = ['environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()]; $githubApp = transferCoverageRecord(GithubApp::class, [...$teamId, 'is_system_wide' => false, 'private_key_id' => $deployKey->id]); $gitlabApp = transferCoverageRecord(GitlabApp::class, [...$teamId, 'is_system_wide' => false]); $application = Application::factory()->create([...$onDestination, 'source_type' => GithubApp::class, 'source_id' => $githubApp->id, 'private_key_id' => $deployKey->id]); Application::factory()->create([...$onDestination, 'source_type' => GitlabApp::class, 'source_id' => $gitlabApp->id]); $preview = transferCoverageRecord(ApplicationPreview::class, ['application_id' => $application->id]); $s3 = transferCoverageRecord(S3Storage::class, $teamId); $service = Service::factory()->create([...$onDestination, 'server_id' => $this->server->id]); transferCoverageRecord(ResourceLink::class, [ 'environment_id' => $environment->id, 'source_type' => $application->getMorphClass(), 'source_id' => $application->id, 'target_type' => $service->getMorphClass(), 'target_id' => $service->id, ]); transferCoverageRecord(GithubRunnerConfig::class, ['server_id' => $this->server->id, 'github_app_id' => $githubApp->id, 'labels' => ['coverage']]); transferCoverageRecord(ServiceApplication::class, ['service_id' => $service->id]); transferCoverageRecord(ServiceDatabase::class, ['service_id' => $service->id]); $databases = collect(ServerTransferCoverage::TRANSFERRED)->keys() ->filter(fn (string $class) => str_starts_with(class_basename($class), 'Standalone') && ! in_array($class, [StandaloneDocker::class], true)) ->map(fn (string $class) => transferCoverageRecord($class, $onDestination)); $volume = transferCoverageRecord(LocalPersistentVolume::class, ['resource_type' => $application->getMorphClass(), 'resource_id' => $application->id]); transferCoverageRecord(LocalFileVolume::class, ['resource_type' => $application->getMorphClass(), 'resource_id' => $application->id]); transferCoverageRecord(ScheduledVolumeBackup::class, [...$teamId, 'backupable_type' => $volume->getMorphClass(), 'backupable_id' => $volume->id, 's3_storage_id' => $s3->id]); transferCoverageRecord(ScheduledTask::class, [...$teamId, 'application_id' => $application->id]); transferCoverageRecord(ScheduledTask::class, [...$teamId, 'service_id' => $service->id]); transferCoverageRecord(ScheduledDatabaseBackup::class, [...$teamId, 'database_type' => $databases->first()->getMorphClass(), 'database_id' => $databases->first()->id, 's3_storage_id' => $s3->id]); $tag = transferCoverageRecord(Tag::class, $teamId); foreach ([$application, $databases->first(), $service] as $tagged) { $tagged->tags()->attach($tag->id); } foreach ([$application, $preview->application, $databases->first(), $service] as $resource) { transferCoverageRecord(EnvironmentVariable::class, ['resourceable_type' => $resource->getMorphClass(), 'resourceable_id' => $resource->id, 'key' => 'COVERAGE_'.fake()->unique()->numberBetween(), 'value' => '{{team.COVERAGE_TEAM}}']); } transferCoverageRecord(EnvironmentVariable::class, ['resourceable_type' => $application->getMorphClass(), 'resourceable_id' => $application->id, 'key' => 'COVERAGE_PREVIEW', 'value' => 'x', 'is_preview' => true]); transferCoverageRecord(SharedEnvironmentVariable::class, [...$teamId, 'type' => 'team', 'key' => 'COVERAGE_TEAM']); transferCoverageRecord(SharedEnvironmentVariable::class, [...$teamId, 'type' => 'server', 'server_id' => $this->server->id]); transferCoverageRecord(SharedEnvironmentVariable::class, [...$teamId, 'type' => 'project', 'project_id' => $project->id]); transferCoverageRecord(SharedEnvironmentVariable::class, [...$teamId, 'type' => 'environment', 'environment_id' => $environment->id]); $cloudflareToken = IntegrationToken::factory()->create([...$teamId, 'provider' => 'cloudflare', 'capabilities' => ['dns', 'tunnel'], 'metadata' => ['automatic_dns' => true]]); $secretsToken = IntegrationToken::factory()->create([...$teamId, 'provider' => 'infisical', 'capabilities' => ['secrets'], 'metadata' => ['client_id' => 'coverage']]); $zone = DnsProviderZone::factory()->create(['integration_token_id' => $cloudflareToken->id]); ManagedDnsRecord::factory()->owned()->create(['dns_provider_zone_id' => $zone->id])->addReference($application); $application->secretManagerLink()->create(['integration_token_id' => $secretsToken->id, 'settings' => ['project_id' => 'coverage', 'environment' => 'prod']]); $this->server->settings->forceFill(['cloudflare_tunnel_integration_token_id' => $cloudflareToken->id])->saveQuietly(); $this->bundle = app(ServerTransferExporter::class)->export($this->server->fresh()); }); test('every model has a server transfer decision', function () { $models = collect(glob(app_path('Models/*.php'))) ->map(fn (string $file) => 'App\\Models\\'.basename($file, '.php')) ->filter(fn (string $class) => is_subclass_of($class, Model::class) && ! (new ReflectionClass($class))->isAbstract()); $undecided = $models ->reject(fn (string $class) => array_key_exists($class, ServerTransferCoverage::TRANSFERRED) || array_key_exists($class, ServerTransferCoverage::NOT_TRANSFERRED)) ->values()->all(); expect($undecided)->toBe([], 'Add these models to the server transfer (ServerTransferExporter/Importer and ServerTransferCoverage::TRANSFERRED), or to ServerTransferCoverage::NOT_TRANSFERRED with a reason.'); }); test('every column of a transferred model is in the bundle or has a reason why not', function (string $modelClass) { ['keys' => $exported, 'records' => $records] = transferCoverageBundleKeys($this->bundle, ServerTransferCoverage::TRANSFERRED[$modelClass], $modelClass); expect($records)->toBeGreaterThan(0, "The coverage fixture exports no {$modelClass}. Add a record of it to the beforeEach of this test."); $notExported = [...ServerTransferCoverage::ALWAYS_NOT_EXPORTED, ...array_keys(ServerTransferCoverage::NOT_EXPORTED_COLUMNS[$modelClass] ?? [])]; $columns = Schema::getColumnListing((new $modelClass)->getTable()); expect(array_values(array_diff($columns, $exported, $notExported))) ->toBe([], "These {$modelClass} columns are not in the transfer bundle. Export and import them (with a round-trip test), or add them to ServerTransferCoverage::NOT_EXPORTED_COLUMNS with a reason.") ->and(array_values(array_intersect(array_keys(ServerTransferCoverage::NOT_EXPORTED_COLUMNS[$modelClass] ?? []), $exported))) ->toBe([], "These {$modelClass} columns are exported, so remove them from ServerTransferCoverage::NOT_EXPORTED_COLUMNS.") ->and(array_values(array_diff(array_keys(ServerTransferCoverage::NOT_EXPORTED_COLUMNS[$modelClass] ?? []), $columns))) ->toBe([], "These {$modelClass} columns no longer exist, so remove them from ServerTransferCoverage::NOT_EXPORTED_COLUMNS."); })->with(array_keys(ServerTransferCoverage::TRANSFERRED));