mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-19 20:14:59 +00:00
Merge remote-tracking branch 'origin/next' into feat/configurable-stop-grace-period
This commit is contained in:
commit
d1220895d9
193 files changed
+10693
-3525
No files matched your search
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
use App\Exceptions\DeploymentException;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use Illuminate\Support\Collection;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
class TestableRailpackDeploymentJob extends ApplicationDeploymentJob
|
||||
{
|
||||
public array $recordedCommands = [];
|
||||
|
||||
public function __construct() {}
|
||||
|
||||
public function execute_remote_command(...$commands)
|
||||
{
|
||||
$this->recordedCommands[] = $commands;
|
||||
}
|
||||
}
|
||||
|
||||
function makeRailpackDeploymentJob(array $applicationAttributes = [], array $savedOutputs = []): array
|
||||
{
|
||||
$job = new TestableRailpackDeploymentJob;
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
|
||||
$application = new Application($applicationAttributes);
|
||||
|
||||
foreach ([
|
||||
'application' => $application,
|
||||
'workdir' => '/artifacts/test-app',
|
||||
'deployment_uuid' => 'deployment-uuid',
|
||||
'saved_outputs' => new Collection($savedOutputs),
|
||||
'env_railpack_args' => "--env 'RAILPACK_NODE_VERSION=22'",
|
||||
'force_rebuild' => false,
|
||||
'addHosts' => '',
|
||||
'secrets_hash_key' => 'testing-app-key',
|
||||
] as $property => $value) {
|
||||
$reflectionProperty = $reflection->getProperty($property);
|
||||
$reflectionProperty->setAccessible(true);
|
||||
$reflectionProperty->setValue($job, $value);
|
||||
}
|
||||
|
||||
return [$job, $reflection];
|
||||
}
|
||||
|
||||
function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $method, array $arguments = []): mixed
|
||||
{
|
||||
$reflectionMethod = $reflection->getMethod($method);
|
||||
$reflectionMethod->setAccessible(true);
|
||||
|
||||
return $reflectionMethod->invokeArgs($job, $arguments);
|
||||
}
|
||||
|
||||
it('deep merges repository railpack config with coolify overrides', function () {
|
||||
$repositoryConfigJson = json_encode([
|
||||
'$schema' => 'https://schema.railpack.com',
|
||||
'packages' => [
|
||||
'node' => '20',
|
||||
],
|
||||
'steps' => [
|
||||
'build' => [
|
||||
'inputs' => [['step' => 'install']],
|
||||
'commands' => ['npm run build'],
|
||||
],
|
||||
],
|
||||
'deploy' => [
|
||||
'variables' => [
|
||||
'NODE_ENV' => 'production',
|
||||
],
|
||||
'startCommand' => 'node index.js',
|
||||
],
|
||||
], JSON_THROW_ON_ERROR);
|
||||
|
||||
[$job, $reflection] = makeRailpackDeploymentJob(
|
||||
[
|
||||
'install_command' => 'npm ci',
|
||||
'build_command' => 'npm run build:prod',
|
||||
'start_command' => 'node server.js',
|
||||
],
|
||||
[
|
||||
'railpack_config_exists' => 'exists',
|
||||
'railpack_repository_config' => $repositoryConfigJson,
|
||||
],
|
||||
);
|
||||
|
||||
$repositoryConfig = invokeRailpackMethod(
|
||||
$job,
|
||||
$reflection,
|
||||
'decode_railpack_config',
|
||||
[$repositoryConfigJson, 'repository railpack.json'],
|
||||
);
|
||||
$overrides = [
|
||||
'deploy' => [
|
||||
'variables' => [
|
||||
'APP_ENV' => 'production',
|
||||
],
|
||||
],
|
||||
'packages' => [
|
||||
'python' => '3.13',
|
||||
],
|
||||
];
|
||||
$generatedConfig = invokeRailpackMethod($job, $reflection, 'merge_railpack_config', [$repositoryConfig, $overrides]);
|
||||
|
||||
expect($generatedConfig)->toMatchArray([
|
||||
'$schema' => 'https://schema.railpack.com',
|
||||
'packages' => [
|
||||
'node' => '20',
|
||||
'python' => '3.13',
|
||||
],
|
||||
'steps' => [
|
||||
'build' => [
|
||||
'inputs' => [['step' => 'install']],
|
||||
'commands' => ['npm run build'],
|
||||
],
|
||||
],
|
||||
'deploy' => [
|
||||
'variables' => [
|
||||
'NODE_ENV' => 'production',
|
||||
'APP_ENV' => 'production',
|
||||
],
|
||||
'startCommand' => 'node index.js',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes a generated railpack config file when repository config exists', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob(
|
||||
['build_command' => 'npm run build'],
|
||||
[
|
||||
'railpack_config_exists' => 'exists',
|
||||
'railpack_repository_config' => json_encode([
|
||||
'$schema' => 'https://schema.railpack.com',
|
||||
'steps' => [
|
||||
'build' => [
|
||||
'commands' => ['npm run build'],
|
||||
],
|
||||
],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
],
|
||||
);
|
||||
|
||||
$configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file');
|
||||
|
||||
expect($configPath)->toBe('.coolify/railpack.generated.json');
|
||||
expect($job->recordedCommands)->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('does not generate a railpack config file for command overrides alone', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob([
|
||||
'install_command' => 'npm ci',
|
||||
'build_command' => 'npm run build',
|
||||
'start_command' => 'node server.js',
|
||||
]);
|
||||
|
||||
$configPath = invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file');
|
||||
|
||||
expect($configPath)->toBeNull();
|
||||
expect($job->recordedCommands)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('fails fast when repository railpack config is invalid json', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob(
|
||||
['build_command' => 'npm run build'],
|
||||
[
|
||||
'railpack_config_exists' => 'exists',
|
||||
'railpack_repository_config' => '{"steps":{"build":',
|
||||
],
|
||||
);
|
||||
|
||||
expect(fn () => invokeRailpackMethod($job, $reflection, 'generate_railpack_config_file'))
|
||||
->toThrow(DeploymentException::class, 'Invalid repository railpack.json');
|
||||
});
|
||||
|
||||
it('builds railpack prepare command using railpack env for install and cli flags for build/start overrides', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob(
|
||||
[
|
||||
'install_command' => 'npm ci',
|
||||
'build_command' => 'npm run build',
|
||||
'start_command' => 'node server.js',
|
||||
],
|
||||
);
|
||||
$envRailpackArgsProperty = $reflection->getProperty('env_railpack_args');
|
||||
$envRailpackArgsProperty->setAccessible(true);
|
||||
$envRailpackArgsProperty->setValue($job, "--env 'RAILPACK_NODE_VERSION=22' --env 'RAILPACK_INSTALL_CMD=npm ci'");
|
||||
|
||||
$command = invokeRailpackMethod(
|
||||
$job,
|
||||
$reflection,
|
||||
'railpack_prepare_command',
|
||||
['.coolify/railpack.generated.json'],
|
||||
);
|
||||
|
||||
expect($command)->toContain('railpack prepare');
|
||||
expect($command)->toContain("--env 'RAILPACK_NODE_VERSION=22'");
|
||||
expect($command)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci'");
|
||||
expect($command)->toContain('--build-cmd '.escapeshellarg('npm run build'));
|
||||
expect($command)->toContain('--start-cmd '.escapeshellarg('node server.js'));
|
||||
expect($command)->toContain('--config-file '.escapeshellarg('.coolify/railpack.generated.json'));
|
||||
expect($command)->toContain('--plan-out /artifacts/railpack-plan.json /artifacts/test-app');
|
||||
expect($command)->not->toContain("--env 'RAILPACK_BUILD_CMD=");
|
||||
expect($command)->not->toContain("--env 'RAILPACK_START_CMD=");
|
||||
expect($command)->not->toContain('RAILPACK_BUILD_CMD=');
|
||||
expect($command)->not->toContain('RAILPACK_START_CMD=');
|
||||
});
|
||||
|
||||
it('fails fast when docker buildx is unavailable for railpack builds', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob();
|
||||
|
||||
$dockerBuildxAvailableProperty = $reflection->getProperty('dockerBuildxAvailable');
|
||||
$dockerBuildxAvailableProperty->setAccessible(true);
|
||||
$dockerBuildxAvailableProperty->setValue($job, false);
|
||||
|
||||
expect(fn () => invokeRailpackMethod($job, $reflection, 'ensure_docker_buildx_available_for_railpack'))
|
||||
->toThrow(DeploymentException::class, 'Railpack deployments require the Docker buildx CLI plugin');
|
||||
});
|
||||
|
||||
it('builds railpack docker command with matching env and secret flags for all railpack variables', function () {
|
||||
[$job, $reflection] = makeRailpackDeploymentJob([
|
||||
'uuid' => 'application-uuid',
|
||||
]);
|
||||
|
||||
$command = invokeRailpackMethod(
|
||||
$job,
|
||||
$reflection,
|
||||
'railpack_build_command',
|
||||
[
|
||||
'coollabsio/coolify:test',
|
||||
collect([
|
||||
'RAILPACK_NODE_VERSION' => '22',
|
||||
'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
|
||||
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
|
||||
'SECRET_JSON' => '{"token":"abc"}',
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
expect($command)->toContain("env 'RAILPACK_NODE_VERSION=22'");
|
||||
expect($command)->toContain("'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
|
||||
expect($command)->toContain("'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
|
||||
expect($command)->toContain("'SECRET_JSON={\"token\":\"abc\"}'");
|
||||
expect($command)->toContain("--secret 'id=RAILPACK_NODE_VERSION,env=RAILPACK_NODE_VERSION'");
|
||||
expect($command)->toContain("--secret 'id=RAILPACK_INSTALL_CMD,env=RAILPACK_INSTALL_CMD'");
|
||||
expect($command)->toContain("--secret 'id=RAILPACK_DEPLOY_APT_PACKAGES,env=RAILPACK_DEPLOY_APT_PACKAGES'");
|
||||
expect($command)->toContain("--secret 'id=SECRET_JSON,env=SECRET_JSON'");
|
||||
expect($command)->toContain(' --build-arg secrets-hash=');
|
||||
expect($command)->toContain('--build-arg BUILDKIT_SYNTAX="ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version').'"');
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Server;
|
||||
|
||||
it('generates escaped railpack env args from resolved values and includes install command', function () {
|
||||
$application = Mockery::mock(Application::class);
|
||||
$application->shouldReceive('getAttribute')->with('install_command')->andReturn('npm ci && npm run postinstall');
|
||||
|
||||
$nodeVersion = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$nodeVersion->forceFill([
|
||||
'key' => 'RAILPACK_NODE_VERSION',
|
||||
'is_literal' => false,
|
||||
'is_multiline' => false,
|
||||
]);
|
||||
$nodeVersion->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('22');
|
||||
|
||||
$literalValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$literalValue->forceFill([
|
||||
'key' => 'RAILPACK_CUSTOM_FLAG',
|
||||
'is_literal' => true,
|
||||
'is_multiline' => false,
|
||||
]);
|
||||
$literalValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn("'hello world'");
|
||||
|
||||
$jsonValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$jsonValue->forceFill([
|
||||
'key' => 'RAILPACK_JSON',
|
||||
'is_literal' => false,
|
||||
'is_multiline' => false,
|
||||
]);
|
||||
$jsonValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('{"token":"abc"}');
|
||||
|
||||
$nullValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$nullValue->forceFill([
|
||||
'key' => 'RAILPACK_NULL',
|
||||
'is_literal' => false,
|
||||
'is_multiline' => false,
|
||||
]);
|
||||
$nullValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn(null);
|
||||
|
||||
$envQuery = Mockery::mock();
|
||||
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
|
||||
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
|
||||
$envQuery->shouldReceive('get')->once()->andReturn(collect([]));
|
||||
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
|
||||
|
||||
$railpackQuery = Mockery::mock();
|
||||
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([$nodeVersion, $literalValue, $jsonValue, $nullValue]));
|
||||
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
|
||||
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
$job->shouldAllowMockingProtectedMethods();
|
||||
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
|
||||
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, $application);
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, 0);
|
||||
|
||||
$mainServerProperty = $reflection->getProperty('mainServer');
|
||||
$mainServerProperty->setAccessible(true);
|
||||
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
|
||||
|
||||
$method = $reflection->getMethod('generate_railpack_env_variables');
|
||||
$method->setAccessible(true);
|
||||
$variables = $method->invoke($job);
|
||||
|
||||
$envArgsProperty = $reflection->getProperty('env_railpack_args');
|
||||
$envArgsProperty->setAccessible(true);
|
||||
$envArgs = $envArgsProperty->getValue($job);
|
||||
|
||||
expect($variables->all())->toBe([
|
||||
'RAILPACK_NODE_VERSION' => '22',
|
||||
'RAILPACK_CUSTOM_FLAG' => 'hello world',
|
||||
'RAILPACK_JSON' => '{"token":"abc"}',
|
||||
'RAILPACK_INSTALL_CMD' => 'npm ci && npm run postinstall',
|
||||
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
|
||||
]);
|
||||
expect($envArgs)->toContain("--env 'RAILPACK_NODE_VERSION=22'");
|
||||
expect($envArgs)->toContain("--env 'RAILPACK_CUSTOM_FLAG=hello world'");
|
||||
expect($envArgs)->toContain("--env 'RAILPACK_JSON={\"token\":\"abc\"}'");
|
||||
expect($envArgs)->toContain("--env 'RAILPACK_INSTALL_CMD=npm ci && npm run postinstall'");
|
||||
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
|
||||
expect($envArgs)->not->toContain('RAILPACK_NULL');
|
||||
});
|
||||
|
||||
it('uses preview railpack environment variables for preview deployments', function () {
|
||||
$application = Mockery::mock(Application::class);
|
||||
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
|
||||
|
||||
$previewValue = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$previewValue->forceFill([
|
||||
'key' => 'RAILPACK_PREVIEW_ONLY',
|
||||
'is_literal' => false,
|
||||
'is_multiline' => false,
|
||||
]);
|
||||
$previewValue->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('preview-value');
|
||||
|
||||
$previewQuery = Mockery::mock();
|
||||
$previewQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
|
||||
$previewQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
|
||||
$previewQuery->shouldReceive('get')->once()->andReturn(collect([]));
|
||||
$application->shouldReceive('environment_variables_preview')->once()->andReturn($previewQuery);
|
||||
|
||||
$railpackPreviewQuery = Mockery::mock();
|
||||
$railpackPreviewQuery->shouldReceive('get')->once()->andReturn(collect([$previewValue]));
|
||||
$application->shouldReceive('railpack_environment_variables_preview')->once()->andReturn($railpackPreviewQuery);
|
||||
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
$job->shouldAllowMockingProtectedMethods();
|
||||
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
|
||||
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, $application);
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, 42);
|
||||
|
||||
$mainServerProperty = $reflection->getProperty('mainServer');
|
||||
$mainServerProperty->setAccessible(true);
|
||||
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
|
||||
|
||||
$method = $reflection->getMethod('generate_railpack_env_variables');
|
||||
$method->setAccessible(true);
|
||||
$variables = $method->invoke($job);
|
||||
|
||||
expect($variables->all())->toBe([
|
||||
'RAILPACK_PREVIEW_ONLY' => 'preview-value',
|
||||
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
|
||||
]);
|
||||
});
|
||||
|
||||
it('merges coolify env variables into railpack build variables', function () {
|
||||
$application = Mockery::mock(Application::class);
|
||||
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
|
||||
|
||||
$userVar = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$userVar->forceFill([
|
||||
'key' => 'MY_BUILD_VAR',
|
||||
'is_literal' => false,
|
||||
'is_multiline' => false,
|
||||
]);
|
||||
$userVar->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('hello');
|
||||
|
||||
$envQuery = Mockery::mock();
|
||||
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
|
||||
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
|
||||
$envQuery->shouldReceive('get')->once()->andReturn(collect([$userVar]));
|
||||
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
|
||||
|
||||
$railpackQuery = Mockery::mock();
|
||||
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([]));
|
||||
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
|
||||
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
$job->shouldAllowMockingProtectedMethods();
|
||||
$job->shouldReceive('generate_coolify_env_variables')
|
||||
->with(true)
|
||||
->andReturn(collect([
|
||||
'COOLIFY_URL' => 'https://app.example.com',
|
||||
'COOLIFY_FQDN' => 'app.example.com',
|
||||
'COOLIFY_BRANCH' => 'main',
|
||||
'COOLIFY_RESOURCE_UUID' => 'app-uuid',
|
||||
'SOURCE_COMMIT' => 'abc123',
|
||||
'EMPTY_VAR' => '',
|
||||
'NULL_VAR' => null,
|
||||
]));
|
||||
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, $application);
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, 0);
|
||||
|
||||
$mainServerProperty = $reflection->getProperty('mainServer');
|
||||
$mainServerProperty->setAccessible(true);
|
||||
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
|
||||
|
||||
$method = $reflection->getMethod('generate_railpack_env_variables');
|
||||
$method->setAccessible(true);
|
||||
$variables = $method->invoke($job);
|
||||
|
||||
expect($variables->all())->toBe([
|
||||
'MY_BUILD_VAR' => 'hello',
|
||||
'RAILPACK_DEPLOY_APT_PACKAGES' => 'curl wget',
|
||||
'COOLIFY_URL' => 'https://app.example.com',
|
||||
'COOLIFY_FQDN' => 'app.example.com',
|
||||
'COOLIFY_BRANCH' => 'main',
|
||||
'COOLIFY_RESOURCE_UUID' => 'app-uuid',
|
||||
'SOURCE_COMMIT' => 'abc123',
|
||||
]);
|
||||
|
||||
$envArgsProperty = $reflection->getProperty('env_railpack_args');
|
||||
$envArgsProperty->setAccessible(true);
|
||||
$envArgs = $envArgsProperty->getValue($job);
|
||||
|
||||
expect($envArgs)->toContain("--env 'COOLIFY_URL=https://app.example.com'");
|
||||
expect($envArgs)->toContain("--env 'SOURCE_COMMIT=abc123'");
|
||||
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=curl wget'");
|
||||
expect($envArgs)->not->toContain('EMPTY_VAR');
|
||||
expect($envArgs)->not->toContain('NULL_VAR');
|
||||
});
|
||||
|
||||
it('preserves user railpack deploy apt packages while adding healthcheck tools once', function () {
|
||||
$application = Mockery::mock(Application::class);
|
||||
$application->shouldReceive('getAttribute')->with('install_command')->andReturn(null);
|
||||
|
||||
$deployPackages = Mockery::mock(EnvironmentVariable::class)->makePartial();
|
||||
$deployPackages->forceFill([
|
||||
'key' => 'RAILPACK_DEPLOY_APT_PACKAGES',
|
||||
'is_literal' => false,
|
||||
'is_multiline' => false,
|
||||
]);
|
||||
$deployPackages->shouldReceive('getResolvedValueWithServer')->once()->with(Mockery::type(Server::class))->andReturn('ffmpeg curl');
|
||||
|
||||
$envQuery = Mockery::mock();
|
||||
$envQuery->shouldReceive('withoutBuildpackControlVariables')->once()->andReturnSelf();
|
||||
$envQuery->shouldReceive('where')->with('is_buildtime', true)->once()->andReturnSelf();
|
||||
$envQuery->shouldReceive('get')->once()->andReturn(collect([]));
|
||||
$application->shouldReceive('environment_variables')->once()->andReturn($envQuery);
|
||||
|
||||
$railpackQuery = Mockery::mock();
|
||||
$railpackQuery->shouldReceive('get')->once()->andReturn(collect([$deployPackages]));
|
||||
$application->shouldReceive('railpack_environment_variables')->once()->andReturn($railpackQuery);
|
||||
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
$job->shouldAllowMockingProtectedMethods();
|
||||
$job->shouldReceive('generate_coolify_env_variables')->andReturn(collect([]));
|
||||
|
||||
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$applicationProperty = $reflection->getProperty('application');
|
||||
$applicationProperty->setAccessible(true);
|
||||
$applicationProperty->setValue($job, $application);
|
||||
|
||||
$pullRequestProperty = $reflection->getProperty('pull_request_id');
|
||||
$pullRequestProperty->setAccessible(true);
|
||||
$pullRequestProperty->setValue($job, 0);
|
||||
|
||||
$mainServerProperty = $reflection->getProperty('mainServer');
|
||||
$mainServerProperty->setAccessible(true);
|
||||
$mainServerProperty->setValue($job, Mockery::mock(Server::class));
|
||||
|
||||
$method = $reflection->getMethod('generate_railpack_env_variables');
|
||||
$method->setAccessible(true);
|
||||
$variables = $method->invoke($job);
|
||||
|
||||
expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('ffmpeg curl wget');
|
||||
|
||||
$envArgsProperty = $reflection->getProperty('env_railpack_args');
|
||||
$envArgsProperty->setAccessible(true);
|
||||
$envArgs = $envArgsProperty->getValue($job);
|
||||
|
||||
expect($envArgs)->toContain("--env 'RAILPACK_DEPLOY_APT_PACKAGES=ffmpeg curl wget'");
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
|
||||
$harness = new class
|
||||
{
|
||||
use DetectsSkipDeployCommits;
|
||||
};
|
||||
|
||||
$harnessClass = get_class($harness);
|
||||
|
||||
describe('shouldSkipDeploy (all-must-match)', function () use ($harnessClass) {
|
||||
test('returns false when messages array is empty', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeploy([]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeploy([null, '', null]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns true when all messages contain [skip ci]', function () use ($harnessClass) {
|
||||
$messages = [
|
||||
'Update docs [skip ci]',
|
||||
'Fix typo [skip ci]',
|
||||
];
|
||||
expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true when single message contains [skip cd]', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeploy(['Update README [skip cd]']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true with mixed [skip ci] and [skip cd] (case-insensitive)', function () use ($harnessClass) {
|
||||
$messages = [
|
||||
'Docs [SKIP CI]',
|
||||
'Changelog [Skip Cd]',
|
||||
];
|
||||
expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns false when at least one message has no skip marker', function () use ($harnessClass) {
|
||||
$messages = [
|
||||
'Update docs [skip ci]',
|
||||
'Actual feature change',
|
||||
];
|
||||
expect($harnessClass::shouldSkipDeploy($messages))->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns false when single message has no skip marker', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeploy(['Deploy this please']))->toBeFalse();
|
||||
});
|
||||
|
||||
test('null entries are filtered before evaluation', function () use ($harnessClass) {
|
||||
$messages = [
|
||||
null,
|
||||
'Docs [skip ci]',
|
||||
null,
|
||||
];
|
||||
expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
|
||||
});
|
||||
|
||||
test('matches PR title scenario (single string)', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeploy(['chore: update readme [skip ci]']))->toBeTrue();
|
||||
expect($harnessClass::shouldSkipDeploy(['feat: real change']))->toBeFalse();
|
||||
expect($harnessClass::shouldSkipDeploy([null]))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSkipDeployAny (any-marker)', function () use ($harnessClass) {
|
||||
test('returns false when messages array is empty', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeployAny([]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns true when any one message contains [skip ci]', function () use ($harnessClass) {
|
||||
$messages = [
|
||||
'Real feature change',
|
||||
'docs: update readme [skip ci]',
|
||||
];
|
||||
expect($harnessClass::shouldSkipDeployAny($messages))->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true when any one message contains [skip cd]', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeployAny(['feature change', 'chore [skip cd]']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true case-insensitively', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeployAny(['feat: docs [SKIP CI]']))->toBeTrue();
|
||||
expect($harnessClass::shouldSkipDeployAny(['feat: docs [Skip Cd]']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns false when no message contains a skip marker', function () use ($harnessClass) {
|
||||
$messages = [
|
||||
'feat: add new endpoint',
|
||||
'fix: handle edge case',
|
||||
];
|
||||
expect($harnessClass::shouldSkipDeployAny($messages))->toBeFalse();
|
||||
});
|
||||
|
||||
test('null and empty entries are skipped, real markers still match', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeployAny([null, '', 'docs [skip ci]', null]))->toBeTrue();
|
||||
expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('PR title alone with skip marker triggers skip', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeployAny(['chore: update readme [skip ci]']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('PR title without skip marker but commit message with skip marker triggers skip', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeployAny(['feat: real change', 'wip [skip cd]']))->toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
|
||||
it('flags NIXPACKS_ keys as buildpack control variables', function () {
|
||||
$env = new EnvironmentVariable;
|
||||
$env->key = 'NIXPACKS_NODE_VERSION';
|
||||
|
||||
expect($env->is_buildpack_control)->toBeTrue();
|
||||
});
|
||||
|
||||
it('flags RAILPACK_ keys as buildpack control variables', function () {
|
||||
$env = new EnvironmentVariable;
|
||||
$env->key = 'RAILPACK_NODE_VERSION';
|
||||
|
||||
expect($env->is_buildpack_control)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not flag user-defined keys as buildpack control variables', function () {
|
||||
$env = new EnvironmentVariable;
|
||||
$env->key = 'MY_BUILD_VAR';
|
||||
|
||||
expect($env->is_buildpack_control)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not flag empty key as buildpack control variable', function () {
|
||||
$env = new EnvironmentVariable;
|
||||
|
||||
expect($env->is_buildpack_control)->toBeFalse();
|
||||
});
|
||||
|
||||
it('lists is_buildpack_control in appends and drops legacy is_nixpacks', function () {
|
||||
$env = new EnvironmentVariable;
|
||||
|
||||
expect($env->getAppends())->toContain('is_buildpack_control');
|
||||
expect($env->getAppends())->not->toContain('is_nixpacks');
|
||||
});
|
||||
|
||||
it('normalizes environment variable keys before storing them on the model', function () {
|
||||
$env = new EnvironmentVariable;
|
||||
$env->key = ' node.name ';
|
||||
|
||||
expect($env->key)->toBe('node.name');
|
||||
});
|
||||
|
||||
it('allows Docker-compatible environment variable keys on the model', function (string $key) {
|
||||
$env = new EnvironmentVariable;
|
||||
$env->key = $key;
|
||||
|
||||
expect($env->key)->toBe($key);
|
||||
})->with([
|
||||
'starts with digit' => '1BAD',
|
||||
'hyphen' => 'BAD-KEY',
|
||||
'dot' => 'node.name',
|
||||
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
|
||||
'semicolon' => 'BAD;KEY',
|
||||
]);
|
||||
|
||||
it('rejects environment variable keys Docker cannot represent on the model', function () {
|
||||
$env = new EnvironmentVariable;
|
||||
|
||||
expect(function () use ($env) {
|
||||
$env->key = 'BAD=KEY';
|
||||
})->toThrow(InvalidArgumentException::class, 'Docker-compatible');
|
||||
});
|
||||
|
||||
it('rejects shared environment variable keys Docker cannot represent on the model', function () {
|
||||
$env = new SharedEnvironmentVariable;
|
||||
|
||||
expect(function () use ($env) {
|
||||
$env->key = 'BAD=KEY';
|
||||
})->toThrow(InvalidArgumentException::class, 'Docker-compatible');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
test('hex magic variables generate valid hex strings with expected lengths', function (string $command, int $expectedLength) {
|
||||
$value = generateEnvValue($command);
|
||||
|
||||
expect($value)
|
||||
->toBeString()
|
||||
->toMatch('/^[0-9a-f]+$/');
|
||||
|
||||
expect(strlen($value))->toBe($expectedLength);
|
||||
})->with([
|
||||
'HEX_32' => ['HEX_32', 32],
|
||||
'HEX_64' => ['HEX_64', 64],
|
||||
'HEX_128' => ['HEX_128', 128],
|
||||
]);
|
||||
|
||||
test('real base64 magic variables generate valid base64 strings from expected byte lengths', function (string $command, int $expectedBytes) {
|
||||
$value = generateEnvValue($command);
|
||||
$decodedValue = base64_decode($value, true);
|
||||
|
||||
expect($value)->toBeString();
|
||||
expect($decodedValue)->not->toBeFalse();
|
||||
expect(strlen($decodedValue))->toBe($expectedBytes);
|
||||
})->with([
|
||||
'REALBASE64' => ['REALBASE64', 32],
|
||||
'REALBASE64_32' => ['REALBASE64_32', 32],
|
||||
'REALBASE64_64' => ['REALBASE64_64', 64],
|
||||
'REALBASE64_128' => ['REALBASE64_128', 128],
|
||||
]);
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
function makeServerForReachabilityTest(bool $isReachable, bool $notificationSent, int $unreachableCount): Server
|
||||
{
|
||||
$settings = Mockery::mock();
|
||||
$settings->is_reachable = $isReachable;
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
$server->shouldReceive('refresh')->andReturnSelf();
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn($notificationSent);
|
||||
$server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
it('sends Reachable notification when reachable and notification was previously sent', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: true, notificationSent: true, unreachableCount: 0);
|
||||
$server->shouldReceive('sendReachableNotification')->once();
|
||||
$server->shouldNotReceive('sendUnreachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
|
||||
it('does not send any notification when reachable and notification was never sent', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: true, notificationSent: false, unreachableCount: 0);
|
||||
$server->shouldNotReceive('sendReachableNotification');
|
||||
$server->shouldNotReceive('sendUnreachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
|
||||
it('sends Unreachable notification when count >= 2 and not yet notified', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 2);
|
||||
$server->shouldReceive('sendUnreachableNotification')->once();
|
||||
$server->shouldNotReceive('sendReachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
|
||||
it('does not send Unreachable notification on first transient failure (count=1)', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 1);
|
||||
$server->shouldNotReceive('sendUnreachableNotification');
|
||||
$server->shouldNotReceive('sendReachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
|
||||
it('does not double-send Unreachable when already notified', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: true, unreachableCount: 5);
|
||||
$server->shouldNotReceive('sendUnreachableNotification');
|
||||
$server->shouldNotReceive('sendReachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests for LocalFileVolume content size handling.
|
||||
*
|
||||
* Related Issue: #4701 - Storages page becomes unusable when Docker volumes
|
||||
* mount large host files. Coolify previously stored full file content in the
|
||||
* encrypted `content` mediumText column, then serialized it to the Livewire
|
||||
* payload, crashing the browser.
|
||||
*/
|
||||
|
||||
use App\Models\LocalFileVolume;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('exposes a 5 MiB content size limit', function () {
|
||||
expect(LocalFileVolume::MAX_CONTENT_SIZE)->toBe(5_242_880);
|
||||
});
|
||||
|
||||
it('exposes binary and too-large placeholder constants', function () {
|
||||
expect(LocalFileVolume::BINARY_PLACEHOLDER)->toBe('[binary file]');
|
||||
expect(LocalFileVolume::TOO_LARGE_PLACEHOLDER)->toBe('[file too large to display]');
|
||||
});
|
||||
|
||||
it('flags is_too_large when content matches the placeholder', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
|
||||
|
||||
expect($volume->is_too_large)->toBeTrue();
|
||||
expect($volume->is_binary)->toBeFalse();
|
||||
});
|
||||
|
||||
it('flags is_binary when content matches the placeholder', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = LocalFileVolume::BINARY_PLACEHOLDER;
|
||||
|
||||
expect($volume->is_binary)->toBeTrue();
|
||||
expect($volume->is_too_large)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not flag normal content as binary or too large', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = "hello\nworld\n";
|
||||
|
||||
expect($volume->is_binary)->toBeFalse();
|
||||
expect($volume->is_too_large)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not flag empty content as binary or too large', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = null;
|
||||
|
||||
expect($volume->is_binary)->toBeFalse();
|
||||
expect($volume->is_too_large)->toBeFalse();
|
||||
});
|
||||
|
||||
it('exposes the too-large flag via toArray for Livewire serialization', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
|
||||
|
||||
$array = $volume->toArray();
|
||||
|
||||
expect($array)->toHaveKey('is_too_large');
|
||||
expect($array['is_too_large'])->toBeTrue();
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Jobs\ServerCheckJob;
|
||||
use App\Jobs\ServerConnectionCheckJob;
|
||||
use App\Jobs\ServerManagerJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Queue\TimeoutExceededException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
@@ -126,16 +128,21 @@ describe('shouldSkipDueToBackoff', function () {
|
||||
|
||||
describe('ServerConnectionCheckJob unreachable_count', function () {
|
||||
it('increments unreachable_count on timeout', function () {
|
||||
Event::fake([ServerReachabilityChanged::class]);
|
||||
|
||||
$settings = Mockery::mock();
|
||||
$settings->is_reachable = true;
|
||||
$settings->shouldReceive('update')
|
||||
->with(['is_reachable' => false, 'is_usable' => false])
|
||||
->once();
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn(false);
|
||||
$server->shouldReceive('increment')->with('unreachable_count')->once();
|
||||
$server->id = 1;
|
||||
$server->name = 'test-server';
|
||||
$server->unreachable_count = 1; // Will become 2 after increment in real code; mock keeps value as-is
|
||||
|
||||
$job = new ServerConnectionCheckJob($server);
|
||||
$job->failed(new TimeoutExceededException);
|
||||
@@ -152,6 +159,50 @@ describe('ServerConnectionCheckJob unreachable_count', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerConnectionCheckJob ServerReachabilityChanged dispatch', function () {
|
||||
// ServerReachabilityChanged's constructor calls $server->isReachableChanged() — verifying that
|
||||
// call is a clean proxy for "the event was dispatched", and avoids serializing a Mockery proxy
|
||||
// through the event dispatcher (which trips Eloquent static method lookups on the proxy class).
|
||||
$invoke = function (bool $wasReachable, bool $wasNotified, bool $isReachable, int $unreachableCount, bool $expectDispatch) {
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
$server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
|
||||
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||
if ($expectDispatch) {
|
||||
$server->shouldReceive('isReachableChanged')->once()->andReturnNull();
|
||||
} else {
|
||||
$server->shouldNotReceive('isReachableChanged');
|
||||
}
|
||||
|
||||
$job = new ServerConnectionCheckJob($server);
|
||||
$method = new ReflectionMethod($job, 'dispatchReachabilityChangedIfNeeded');
|
||||
$method->invoke($job, $wasReachable, $wasNotified, $isReachable);
|
||||
};
|
||||
|
||||
it('dispatches event when count crosses unreachable threshold', function () use ($invoke) {
|
||||
$invoke(true, false, false, 2, true);
|
||||
});
|
||||
|
||||
it('does not dispatch on first transient failure (count=1)', function () use ($invoke) {
|
||||
$invoke(true, false, false, 1, false);
|
||||
});
|
||||
|
||||
it('does not dispatch when already notified and still unreachable', function () use ($invoke) {
|
||||
$invoke(false, true, false, 5, false);
|
||||
});
|
||||
|
||||
it('dispatches recovery event when previously unreachable', function () use ($invoke) {
|
||||
$invoke(false, false, true, 0, true);
|
||||
});
|
||||
|
||||
it('dispatches recovery event when previously notified', function () use ($invoke) {
|
||||
$invoke(true, true, true, 0, true);
|
||||
});
|
||||
|
||||
it('does not dispatch when consistently reachable and never notified', function () use ($invoke) {
|
||||
$invoke(true, false, true, 0, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerCheckJob unreachable_count', function () {
|
||||
it('increments unreachable_count on timeout', function () {
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Models\StandaloneDocker;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Guards STANDALONE_DATABASE_MODELS against drift.
|
||||
*
|
||||
* MCP and API endpoints rely on this registry for team-scoped UUID lookups.
|
||||
* If a new App\Models\Standalone* model lands without a registry entry, the
|
||||
* helpers in bootstrap/helpers/shared.php silently fail to resolve it.
|
||||
*/
|
||||
test('STANDALONE_DATABASE_MODELS contains every Standalone* model on disk', function () {
|
||||
$files = glob(dirname(__DIR__, 2).'/app/Models/Standalone*.php');
|
||||
expect($files)->not->toBeEmpty();
|
||||
|
||||
$onDisk = collect($files)
|
||||
->map(fn (string $path) => 'App\\Models\\'.basename($path, '.php'))
|
||||
->reject(fn (string $class) => $class === StandaloneDocker::class)
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$registered = collect(STANDALONE_DATABASE_MODELS)->values()->sort()->values()->all();
|
||||
|
||||
expect($registered)->toBe(
|
||||
$onDisk,
|
||||
'STANDALONE_DATABASE_MODELS in bootstrap/helpers/constants.php is out of sync with the App\\Models\\Standalone* classes on disk. '
|
||||
.'Add the missing model(s) to the registry (and to DATABASE_TYPES) so MCP/API helpers can resolve them.'
|
||||
);
|
||||
});
|
||||
|
||||
test('STANDALONE_DATABASE_MODELS keys mirror DATABASE_TYPES', function () {
|
||||
expect(array_keys(STANDALONE_DATABASE_MODELS))->toEqualCanonicalizing(DATABASE_TYPES);
|
||||
});
|
||||
|
||||
test('every STANDALONE_DATABASE_MODELS entry is an Eloquent model with whereUuid scope', function () {
|
||||
foreach (STANDALONE_DATABASE_MODELS as $slug => $modelClass) {
|
||||
expect(class_exists($modelClass))->toBeTrue("{$slug} maps to non-existent class {$modelClass}");
|
||||
expect(is_subclass_of($modelClass, Model::class))
|
||||
->toBeTrue("{$modelClass} is not an Eloquent model");
|
||||
expect(method_exists($modelClass, 'team'))
|
||||
->toBeTrue("{$modelClass} is missing team() accessor required by queryDatabaseByUuidWithinTeam()");
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
it('accepts valid names with common characters', function (string $name) {
|
||||
@@ -130,3 +131,45 @@ it('generates nullable dockerNetworkRules when not required', function () {
|
||||
expect($rules)->toContain('nullable')
|
||||
->not->toContain('required');
|
||||
});
|
||||
|
||||
it('accepts Docker-compatible environment variable keys', function (string $key) {
|
||||
expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeTrue();
|
||||
})->with([
|
||||
'letters' => 'APP_ENV',
|
||||
'leading underscore' => '_TOKEN',
|
||||
'railpack control variable' => 'RAILPACK_NODE_VERSION',
|
||||
'digits after first character' => 'NODE_VERSION_20',
|
||||
'starts with digit' => '1BAD',
|
||||
'hyphen' => 'BAD-KEY',
|
||||
'dot' => 'node.name',
|
||||
'uppercase dots' => 'XPACK.SECURITY.ENABLED',
|
||||
'semicolon' => 'BAD;KEY',
|
||||
'space' => 'BAD KEY',
|
||||
]);
|
||||
|
||||
it('rejects environment variable keys Docker cannot represent', function (string $key) {
|
||||
expect(ValidationPatterns::isValidEnvironmentVariableKey($key))->toBeFalse();
|
||||
})->with([
|
||||
'equals' => 'BAD=KEY',
|
||||
'empty' => '',
|
||||
]);
|
||||
|
||||
it('generates environment variable key rules with correct defaults', function () {
|
||||
$rules = ValidationPatterns::environmentVariableKeyRules();
|
||||
|
||||
expect($rules)->toContain('required')
|
||||
->toContain('string')
|
||||
->toContain('max:255')
|
||||
->toContain('regex:'.ValidationPatterns::ENVIRONMENT_VARIABLE_KEY_PATTERN);
|
||||
});
|
||||
|
||||
it('normalizes environment variable keys by trimming surrounding whitespace', function () {
|
||||
expect(ValidationPatterns::normalizeEnvironmentVariableKey(' node.name '))->toBe('node.name');
|
||||
});
|
||||
|
||||
it('normalizes environment variable keys before model validation', function () {
|
||||
$environmentVariable = new EnvironmentVariable;
|
||||
$environmentVariable->key = ' APP_ENV ';
|
||||
|
||||
expect($environmentVariable->key)->toBe('APP_ENV');
|
||||
});
|
||||
Reference in new issue
Block a user