From 65f7b75f29f402c44261f703f3bc9b3edb36d39d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Valentin?= Date: Sun, 13 Sep 2026 19:32:55 +0000 Subject: [PATCH] Follow the API's move to versionless database types The Laravel Cloud API now describes each database type without the engine version baked into its identifier and lists the supported versions separately. The CLI still keyed its presets on the old versioned identifiers, so the type list came back empty and both `cloud ship` and `database-cluster:create` failed before reaching the create step. Key the presets on the versionless types, keep the versions the API lists for each type, and send `type` plus `version` when creating a cluster. `--database` aliases without a version (`postgres`, `mysql`) track the newest release available, `postgres17` and friends stay pinned, and the previous identifiers remain accepted as input. `database-cluster:create` gains `--engine-version` (`--version` is taken by the console) and prompts for a version interactively, defaulting to the newest. Add feature tests that mock the types endpoint with the shape the API serves today. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TW1oE3eQZGhADwxayPwVsa --- app/Client/README.md | 5 +- .../CreateDatabaseClusterRequestData.php | 2 + app/Commands/DatabaseClusterCreate.php | 3 +- app/Commands/Ship.php | 64 +++++--- app/Concerns/CreatesDatabaseCluster.php | 27 +++- app/Dto/DatabaseType.php | 19 +++ app/Enums/DatabaseClusterPreset.php | 75 +++++---- tests/Feature/DatabaseClusterCreateTest.php | 92 +++++++++++ tests/Feature/ShipDatabaseTest.php | 153 ++++++++++++++++++ tests/Helpers.php | 44 +++++ 10 files changed, 423 insertions(+), 61 deletions(-) create mode 100644 tests/Feature/DatabaseClusterCreateTest.php create mode 100644 tests/Feature/ShipDatabaseTest.php diff --git a/app/Client/README.md b/app/Client/README.md index ca7e8a2b..ca79d58c 100644 --- a/app/Client/README.md +++ b/app/Client/README.md @@ -219,10 +219,11 @@ use App\Client\Resources\DatabaseClusters\CreateDatabaseClusterRequest; $connector = new Connector('your-api-token'); $response = $connector->send(new CreateDatabaseClusterRequest(new CreateDatabaseClusterRequestData( - type: 'neon_serverless_postgres_18', + type: 'neon_serverless_postgres', + version: '18', name: 'my-database', region: 'us-east-1', - clusterConfig: [ + config: [ 'cu_min' => 0.25, 'cu_max' => 2, 'suspend_seconds' => 300, diff --git a/app/Client/Requests/CreateDatabaseClusterRequestData.php b/app/Client/Requests/CreateDatabaseClusterRequestData.php index 4c491d52..6fd03016 100644 --- a/app/Client/Requests/CreateDatabaseClusterRequestData.php +++ b/app/Client/Requests/CreateDatabaseClusterRequestData.php @@ -9,6 +9,7 @@ public function __construct( public readonly string $name, public readonly string $region, public readonly array $config, + public readonly ?string $version = null, public readonly ?int $clusterId = null, ) { // @@ -18,6 +19,7 @@ public function toRequestData(): array { return $this->filter([ 'type' => $this->type, + 'version' => $this->version, 'name' => $this->name, 'region' => $this->region, 'config' => $this->config, diff --git a/app/Commands/DatabaseClusterCreate.php b/app/Commands/DatabaseClusterCreate.php index 391fe258..6ddb7f7d 100644 --- a/app/Commands/DatabaseClusterCreate.php +++ b/app/Commands/DatabaseClusterCreate.php @@ -17,7 +17,8 @@ class DatabaseClusterCreate extends BaseCommand protected $signature = 'database-cluster:create {--name= : Database cluster name} - {--type= : Database type} + {--type= : Database type (laravel_mysql, neon_serverless_postgres)} + {--engine-version= : Database engine version (e.g. 18, 8.4). Default: newest available} {--region= : Database region}'; protected $description = 'Create a new database cluster'; diff --git a/app/Commands/Ship.php b/app/Commands/Ship.php index d42d714e..b57abe37 100644 --- a/app/Commands/Ship.php +++ b/app/Commands/Ship.php @@ -60,7 +60,7 @@ class Ship extends BaseCommand use UpdatesBuildDeployCommands; protected $signature = 'ship - {--database= : Database type or alias (postgres, postgres18, postgres17, mysql, or a full type like neon_serverless_postgres_18). Default: postgres18} + {--database= : Database type or alias (postgres, postgres18, postgres17, mysql, or a full type like neon_serverless_postgres). Unversioned aliases use the newest available version. Default: postgres} {--database-preset= : Preset tier for the database (dev, prod, scale — case-insensitive). Default: dev} {--name= : Application name (non-interactive). Default: derived from repository} {--region= : Region (non-interactive). Default: most-used or us-east-2} @@ -591,30 +591,43 @@ function ($errors) use ($environmentParams, $environment) { } } - protected function resolveDatabaseType(): ?string + /** + * Resolve the `--database` option into a type and, when the option names one, a version. + * + * A null version means "the newest the API offers for that type", so `postgres` + * keeps tracking new releases while `postgres17` stays pinned. + * + * @return array{0: string, 1: string|null}|null + */ + protected function resolveDatabaseType(): ?array { - $aliases = [ - 'postgres' => DatabaseClusterPreset::NeonServerlessPostgres18->value, - 'postgres18' => DatabaseClusterPreset::NeonServerlessPostgres18->value, - 'postgres17' => DatabaseClusterPreset::NeonServerlessPostgres17->value, - 'mysql' => DatabaseClusterPreset::LaravelMysql8->value, - ]; + $input = strtolower((string) $this->option('database')); + + if ($input === '') { + return [DatabaseClusterPreset::NeonServerlessPostgres->value, null]; + } - $input = $this->option('database'); + if ($input === 'postgres') { + return [DatabaseClusterPreset::NeonServerlessPostgres->value, null]; + } - if ($input === null || $input === '') { - return DatabaseClusterPreset::NeonServerlessPostgres18->value; + if ($input === 'mysql') { + return [DatabaseClusterPreset::LaravelMysql->value, null]; } - if (isset($aliases[strtolower($input)])) { - return $aliases[strtolower($input)]; + if (preg_match('/^postgres(\d+)$/', $input, $matches)) { + return [DatabaseClusterPreset::NeonServerlessPostgres->value, $matches[1]]; } if (DatabaseClusterPreset::tryFrom($input) !== null) { - return $input; + return [$input, null]; } - $validValues = implode(', ', [...array_keys($aliases), ...array_map(fn (DatabaseClusterPreset $e) => $e->value, DatabaseClusterPreset::cases())]); + if (($legacy = DatabaseClusterPreset::fromLegacyType($input)) !== null) { + return [$legacy[0]->value, $legacy[1]]; + } + + $validValues = implode(', ', ['postgres', 'postgres (e.g. postgres18)', 'mysql', ...array_map(fn (DatabaseClusterPreset $e) => $e->value, DatabaseClusterPreset::cases())]); $this->outputErrorOrThrow('Invalid --database value "'.$input.'". Must be one of: '.$validValues); @@ -645,18 +658,23 @@ protected function provisionDatabaseOpinionated(): ?string $types = $this->client->databaseClusters()->types(); $types = collect($types)->filter(fn (DatabaseType $type) => DatabaseClusterPreset::tryFrom($type->type) !== null)->values(); - $resolvedType = $this->resolveDatabaseType(); + [$resolvedType, $resolvedVersion] = $this->resolveDatabaseType(); $type = $types->firstWhere('type', $resolvedType); if ($type === null) { - if ($resolvedType === DatabaseClusterPreset::NeonServerlessPostgres18->value) { - $type = $types->firstWhere('type', DatabaseClusterPreset::NeonServerlessPostgres17->value); - } + $this->outputErrorOrThrow('Database type "'.$resolvedType.'" is not available from the API.'); + } - if ($type === null) { - $this->outputErrorOrThrow('Database type "'.$resolvedType.'" is not available from the API.'); - } + $version = $resolvedVersion ?? $type->latestVersion(); + + if ($version === null || ! in_array($version, $type->versions, true)) { + $this->outputErrorOrThrow(sprintf( + 'Version "%s" is not available for database type "%s". Available versions: %s', + $version ?? 'unknown', + $type->type, + implode(', ', $type->versions) ?: 'none', + )); } $preset = $this->resolveDatabasePreset($type->type); @@ -669,7 +687,7 @@ protected function provisionDatabaseOpinionated(): ?string $databaseName = $this->appName ? str($this->appName)->snake()->replace('-', '_')->toString() : 'main'; if (! $cluster) { - $cluster = $this->createDatabaseClusterWithOptions($type->type, $preset, $name, $region); + $cluster = $this->createDatabaseClusterWithOptions($type->type, $version, $preset, $name, $region); $cluster = $this->client->databaseClusters()->include('databases')->get($cluster->id); } diff --git a/app/Concerns/CreatesDatabaseCluster.php b/app/Concerns/CreatesDatabaseCluster.php index 930748bf..fc149ee3 100644 --- a/app/Concerns/CreatesDatabaseCluster.php +++ b/app/Concerns/CreatesDatabaseCluster.php @@ -59,6 +59,27 @@ protected function createDatabaseCluster(array $defaults = []): DatabaseCluster $selectedType = $types->firstWhere('type', $this->form()->get('type')); + // Newest first so the default lands on the version most users want. + $versionOptions = collect($selectedType->versions) + ->sort('version_compare') + ->reverse() + ->values(); + + $this->form()->prompt( + 'version', + fn ($resolver) => $resolver + ->fromInput( + fn (?string $value) => select( + label: 'Version', + options: $versionOptions->mapWithKeys(fn (string $version) => [$version => $selectedType->label.' '.$version])->toArray(), + default: $value ?? $defaults['version'] ?? $versionOptions->first(), + required: true, + ), + ) + ->nonInteractively(fn () => $defaults['version'] ?? $versionOptions->first()), + 'engine-version', + ); + $regions = spin( fn () => $this->client->meta()->regions(), 'Fetching regions...', @@ -97,6 +118,8 @@ protected function createDatabaseCluster(array $defaults = []): DatabaseCluster name: $this->form()->get('name'), region: $this->form()->get('region'), config: $config, + // Numeric keys come back from the prompt as integers. + version: (string) $this->form()->get('version'), ), ), 'Creating database cluster...', @@ -105,7 +128,6 @@ protected function createDatabaseCluster(array $defaults = []): DatabaseCluster protected function databaseClusterConfigFromPreset(DatabaseType $type): ?array { - $clusterPreset = DatabaseClusterPreset::from($type->type); $presets = $clusterPreset->presets(); $presets['Custom'] = []; @@ -213,7 +235,7 @@ protected function promptForDatabaseClusterConfig(DatabaseType $type): array ])->toArray(); } - protected function createDatabaseClusterWithOptions(string $type, string $preset, string $name, string $region): DatabaseCluster + protected function createDatabaseClusterWithOptions(string $type, string $version, string $preset, string $name, string $region): DatabaseCluster { $enum = DatabaseClusterPreset::tryFrom($type); @@ -240,6 +262,7 @@ protected function createDatabaseClusterWithOptions(string $type, string $preset name: $name, region: $region, config: $config, + version: $version, ), ), 'Creating database cluster...', diff --git a/app/Dto/DatabaseType.php b/app/Dto/DatabaseType.php index aab01e61..ce04e734 100644 --- a/app/Dto/DatabaseType.php +++ b/app/Dto/DatabaseType.php @@ -13,10 +13,28 @@ public function __construct( public readonly array $regions, #[DataCollectionOf(ConfigSchema::class)] public readonly array $configSchema, + /** @var list The engine versions a cluster may be created with. */ + public readonly array $versions = [], ) { // } + /** + * Get the newest version a cluster of this type may be created with. + */ + public function latestVersion(): ?string + { + if ($this->versions === []) { + return null; + } + + $versions = $this->versions; + + usort($versions, 'version_compare'); + + return end($versions); + } + public static function createFromResponse(array $response): self { $data = $response['data'] ?? []; @@ -25,6 +43,7 @@ public static function createFromResponse(array $response): self 'type' => $data['type'], 'label' => $data['label'], 'regions' => $data['regions'] ?? [], + 'versions' => array_values(array_map('strval', $data['versions'] ?? [])), 'configSchema' => collect($data['config_schema'] ?? [])->map(fn (array $schema) => ConfigSchema::from($schema)->toArray())->toArray(), ]); } diff --git a/app/Enums/DatabaseClusterPreset.php b/app/Enums/DatabaseClusterPreset.php index 84276a4d..ea56864e 100644 --- a/app/Enums/DatabaseClusterPreset.php +++ b/app/Enums/DatabaseClusterPreset.php @@ -4,16 +4,51 @@ use Closure; +/** + * The database types the CLI ships opinionated presets for. + * + * The API describes a database by a versionless type plus a separate engine + * version (see GET /databases/types), so the cases here mirror the API's type + * values and carry no version of their own. The retired versioned identifiers + * (`neon_serverless_postgres_18`, `laravel_mysql_8`, ...) are still accepted as + * input and split into a type and version by `fromLegacyType()`. + */ enum DatabaseClusterPreset: string { - case LaravelMysql8 = 'laravel_mysql_8'; - case NeonServerlessPostgres18 = 'neon_serverless_postgres_18'; - case NeonServerlessPostgres17 = 'neon_serverless_postgres_17'; + case LaravelMysql = 'laravel_mysql'; + case NeonServerlessPostgres = 'neon_serverless_postgres'; + + /** + * Resolve a retired versioned type identifier into a type and version. + * + * @return array{0: self, 1: string}|null + */ + public static function fromLegacyType(string $type): ?array + { + if (! preg_match('/^(?[a-z_]+?)_(?\d+)$/', $type, $matches)) { + return null; + } + + $preset = self::tryFrom($matches['type']); + + if ($preset === null) { + return null; + } + + // The retired MySQL types squashed the version into the identifier, but the + // API expects it dotted and 8.4 is the only creatable release. + $version = match ([$preset, $matches['version']]) { + [self::LaravelMysql, '8'], [self::LaravelMysql, '84'] => '8.4', + default => $matches['version'], + }; + + return [$preset, $version]; + } public function presets(): array { return match ($this) { - self::LaravelMysql8 => [ + self::LaravelMysql => [ 'Dev' => [ 'size' => 'db-flex.m-1vcpu-512mb', 'storage' => 5, @@ -36,27 +71,7 @@ public function presets(): array 'is_public' => false, ], ], - self::NeonServerlessPostgres18 => [ - 'Dev' => [ - 'cu_min' => 0.25, - 'cu_max' => 0.25, - 'suspend_seconds' => 300, - 'retention_days' => 0, - ], - 'Prod' => [ - 'cu_min' => 0.25, - 'cu_max' => 1, - 'suspend_seconds' => 0, - 'retention_days' => 7, - ], - 'Scale' => [ - 'cu_min' => 1, - 'cu_max' => 4, - 'suspend_seconds' => 0, - 'retention_days' => 14, - ], - ], - self::NeonServerlessPostgres17 => [ + self::NeonServerlessPostgres => [ 'Dev' => [ 'cu_min' => 0.25, 'cu_max' => 0.25, @@ -82,7 +97,7 @@ public function presets(): array public function description(): Closure { return match ($this) { - self::LaravelMysql8 => fn ($preset) => sprintf( + self::LaravelMysql => fn ($preset) => sprintf( '%s · %sGB storage · %d %s backups', str($preset['size']) ->replaceMatches( @@ -97,18 +112,12 @@ public function description(): Closure $preset['retention_days'], str('day')->plural($preset['retention_days']), ), - self::NeonServerlessPostgres18 => fn ($preset) => sprintf( + self::NeonServerlessPostgres => fn ($preset) => sprintf( '%s vCPU units · %s · %s', $preset['cu_min'] === $preset['cu_max'] ? $this->formatNumber($preset['cu_min']) : $this->formatNumber($preset['cu_min']).' – '.$this->formatNumber($preset['cu_max']), $preset['suspend_seconds'] > 0 ? 'Scale to zero after '.$preset['suspend_seconds'].' seconds' : 'No scale to zero', $preset['retention_days'] === 0 ? 'No backups' : $preset['retention_days'].' days PITR', ), - self::NeonServerlessPostgres17 => fn ($preset) => sprintf( - '%s vCPU units · %s · %s', - $preset['cu_min'] === $preset['cu_max'] ? $this->formatNumber($preset['cu_min']) : $this->formatNumber($preset['cu_min']).' – '.$this->formatNumber($preset['cu_max']), - $preset['suspend_seconds'] === 0 ? 'No scale to zero' : 'Scale to zero after '.$preset['suspend_seconds'].' seconds', - $preset['retention_days'] === 0 ? 'No backups' : $preset['retention_days'].' days PITR', - ), }; } diff --git a/tests/Feature/DatabaseClusterCreateTest.php b/tests/Feature/DatabaseClusterCreateTest.php new file mode 100644 index 00000000..70f953f3 --- /dev/null +++ b/tests/Feature/DatabaseClusterCreateTest.php @@ -0,0 +1,92 @@ +mockConfig = Mockery::mock(ConfigRepository::class); + $this->mockConfig->shouldReceive('apiTokens')->andReturn(collect(['test-api-token'])); + $this->app->instance(ConfigRepository::class, $this->mockConfig); +}); + +afterEach(function () { + MockClient::destroyGlobal(); +}); + +function setupDatabaseClusterCreateMocks(): Closure +{ + $sentBody = new stdClass; + + MockClient::global([ + GetOrganizationRequest::class => MockResponse::make(organizationResponse(), 200), + ListDatabaseTypesRequest::class => MockResponse::make(versionlessDatabaseTypesResponse(), 200), + ListRegionsRequest::class => MockResponse::make(regionsResponse(), 200), + CreateDatabaseClusterRequest::class => function (PendingRequest $request) use ($sentBody) { + $sentBody->value = $request->body()->all(); + + return MockResponse::make(databaseClusterResponse([ + 'attributes' => ['type' => $sentBody->value['type']], + ]), 201); + }, + ]); + + return fn () => $sentBody->value ?? null; +} + +it('creates a cluster on the newest version when none is given', function () { + $sentBody = setupDatabaseClusterCreateMocks(); + + $this->artisan('database-cluster:create', [ + '--name' => 'my-cluster', + '--type' => 'neon_serverless_postgres', + '--region' => 'us-east-1', + '--no-interaction' => true, + ])->assertSuccessful(); + + expect($sentBody()['type'])->toBe('neon_serverless_postgres') + ->and($sentBody()['version'])->toBe('18') + ->and($sentBody()['config'])->toBe([ + 'cu_min' => 0.25, + 'cu_max' => 0.25, + 'suspend_seconds' => 300, + 'retention_days' => 0, + ]); +}); + +it('pins the cluster to the requested engine version', function () { + $sentBody = setupDatabaseClusterCreateMocks(); + + $this->artisan('database-cluster:create', [ + '--name' => 'my-cluster', + '--type' => 'neon_serverless_postgres', + '--engine-version' => '17', + '--region' => 'us-east-1', + '--no-interaction' => true, + ])->assertSuccessful(); + + expect($sentBody()['type'])->toBe('neon_serverless_postgres') + ->and($sentBody()['version'])->toBe('17'); +}); + +it('sends the dotted version for MySQL', function () { + $sentBody = setupDatabaseClusterCreateMocks(); + + $this->artisan('database-cluster:create', [ + '--name' => 'my-cluster', + '--type' => 'laravel_mysql', + '--region' => 'us-east-1', + '--no-interaction' => true, + ])->assertSuccessful(); + + expect($sentBody()['type'])->toBe('laravel_mysql') + ->and($sentBody()['version'])->toBe('8.4'); +}); diff --git a/tests/Feature/ShipDatabaseTest.php b/tests/Feature/ShipDatabaseTest.php new file mode 100644 index 00000000..4d5a9eda --- /dev/null +++ b/tests/Feature/ShipDatabaseTest.php @@ -0,0 +1,153 @@ +getDefinition(); + $definition->addOption(new InputOption('no-interaction', 'n', InputOption::VALUE_NONE)); + + $input = new ArrayInput([...$options, '--no-interaction' => true], $definition); + $input->setInteractive(false); + + (function () use ($input) { + $this->input = $input; + $this->output = new BufferedOutput; + $this->client = new Connector('test-api-token'); + $this->appName = 'my-app'; + $this->region = 'us-east-1'; + })->call($command); + + return $command; +} + +function setupShipDatabaseMocks(): Closure +{ + $sentBody = new stdClass; + + MockClient::global([ + ListDatabaseTypesRequest::class => MockResponse::make(versionlessDatabaseTypesResponse(), 200), + ListDatabaseClustersRequest::class => MockResponse::make([ + 'data' => [], + 'included' => [], + 'links' => ['next' => null], + ], 200), + CreateDatabaseClusterRequest::class => function (PendingRequest $request) use ($sentBody) { + $sentBody->value = $request->body()->all(); + + return MockResponse::make(databaseClusterResponse([ + 'attributes' => ['type' => $sentBody->value['type']], + ]), 201); + }, + GetDatabaseClusterRequest::class => MockResponse::make(databaseClusterResponse(), 200), + CreateDatabaseRequest::class => MockResponse::make(['data' => databaseSchemaResponse()], 201), + ]); + + return fn () => $sentBody->value ?? null; +} + +it('resolves the database option into a type and version', function (string $option, string $type, ?string $version) { + $command = shipCommandProvisioningDatabase(['--database' => $option]); + + expect((fn () => $this->resolveDatabaseType())->call($command))->toBe([$type, $version]); +})->with([ + 'postgres tracks the newest release' => ['postgres', 'neon_serverless_postgres', null], + 'postgres18 pins the version' => ['postgres18', 'neon_serverless_postgres', '18'], + 'postgres17 pins the version' => ['postgres17', 'neon_serverless_postgres', '17'], + 'mysql tracks the newest release' => ['mysql', 'laravel_mysql', null], + 'a versionless API type' => ['neon_serverless_postgres', 'neon_serverless_postgres', null], + 'a retired versioned Postgres type' => ['neon_serverless_postgres_17', 'neon_serverless_postgres', '17'], + 'a retired versioned MySQL type' => ['laravel_mysql_8', 'laravel_mysql', '8.4'], +]); + +it('defaults to the newest Serverless Postgres the API offers', function () { + $sentBody = setupShipDatabaseMocks(); + + $command = shipCommandProvisioningDatabase([]); + + $schemaId = (fn () => $this->provisionDatabaseOpinionated())->call($command); + + expect($schemaId)->toBe('schema-1') + ->and($sentBody()['type'])->toBe('neon_serverless_postgres') + ->and($sentBody()['version'])->toBe('18') + ->and($sentBody()['name'])->toBe('my_app') + ->and($sentBody()['region'])->toBe('us-east-1') + ->and($sentBody()['config'])->toBe([ + 'cu_min' => 0.25, + 'cu_max' => 0.25, + 'suspend_seconds' => 300, + 'retention_days' => 0, + ]); +}); + +it('provisions the pinned Postgres version', function () { + $sentBody = setupShipDatabaseMocks(); + + $command = shipCommandProvisioningDatabase(['--database' => 'postgres17', '--database-preset' => 'prod']); + + (fn () => $this->provisionDatabaseOpinionated())->call($command); + + expect($sentBody()['type'])->toBe('neon_serverless_postgres') + ->and($sentBody()['version'])->toBe('17') + ->and($sentBody()['config']['retention_days'])->toBe(7); +}); + +it('still accepts the retired versioned type identifiers', function () { + $sentBody = setupShipDatabaseMocks(); + + $command = shipCommandProvisioningDatabase(['--database' => 'neon_serverless_postgres_18']); + + (fn () => $this->provisionDatabaseOpinionated())->call($command); + + expect($sentBody()['type'])->toBe('neon_serverless_postgres') + ->and($sentBody()['version'])->toBe('18'); +}); + +it('rejects a version the API does not offer', function () { + setupShipDatabaseMocks(); + + $command = shipCommandProvisioningDatabase(['--database' => 'postgres15']); + + expect(fn () => (fn () => $this->provisionDatabaseOpinionated())->call($command)) + ->toThrow(RuntimeException::class, 'Version "15" is not available for database type "neon_serverless_postgres". Available versions: 16, 17, 18'); + + MockClient::global()->assertNotSent(CreateDatabaseClusterRequest::class); +}); + +it('rejects a database type it has no presets for', function () { + setupShipDatabaseMocks(); + + $command = shipCommandProvisioningDatabase(['--database' => 'aws_rds_postgres']); + + expect(fn () => (fn () => $this->provisionDatabaseOpinionated())->call($command)) + ->toThrow(RuntimeException::class, 'Invalid --database value "aws_rds_postgres"'); +}); diff --git a/tests/Helpers.php b/tests/Helpers.php index d5079789..3c2fa6ed 100644 --- a/tests/Helpers.php +++ b/tests/Helpers.php @@ -372,3 +372,47 @@ function usageResponse(array $overrides = []): array return array_replace_recursive($base, $overrides); } + +/** + * The shape GET /databases/types serves: a versionless type with the creatable + * versions listed alongside it. The CLI must not expect a version in the type. + */ +function versionlessDatabaseTypesResponse(): array +{ + return [ + 'data' => [ + [ + 'type' => 'laravel_mysql', + 'label' => 'Laravel MySQL', + 'versions' => ['8.4'], + 'regions' => ['us-east-1'], + 'config_schema' => [ + ['name' => 'size', 'type' => 'string', 'required' => true, 'example' => 'db-flex.m-1vcpu-512mb'], + ['name' => 'storage', 'type' => 'integer', 'required' => true, 'example' => '5'], + ['name' => 'retention_days', 'type' => 'integer', 'required' => true, 'example' => '1'], + ['name' => 'uses_scheduled_snapshots', 'type' => 'boolean', 'required' => true, 'example' => 'false'], + ['name' => 'is_public', 'type' => 'boolean', 'required' => true, 'example' => 'false'], + ], + ], + [ + 'type' => 'aws_rds_postgres', + 'label' => 'AWS RDS Postgres', + 'versions' => ['18'], + 'regions' => ['us-east-1'], + 'config_schema' => [], + ], + [ + 'type' => 'neon_serverless_postgres', + 'label' => 'Laravel Serverless Postgres', + 'versions' => ['16', '17', '18'], + 'regions' => ['us-east-1'], + 'config_schema' => [ + ['name' => 'cu_min', 'type' => 'number', 'required' => true, 'example' => '0.25'], + ['name' => 'cu_max', 'type' => 'number', 'required' => true, 'example' => '0.25'], + ['name' => 'suspend_seconds', 'type' => 'integer', 'required' => true, 'example' => '300'], + ['name' => 'retention_days', 'type' => 'integer', 'required' => true, 'example' => '0'], + ], + ], + ], + ]; +}