Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,479 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Console\Commands\ConfigMerge;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* config:merge copies new keys' SOURCE from the .example file into the active
|
||||
* config and leaves the rest of the file as it was.
|
||||
*
|
||||
* It used to write the whole file back from evaluated arrays, which froze
|
||||
* every env() call into a fixed value and dropped all comments. On production
|
||||
* health_monitor.healthcheck_url became null that way, and the URL set in
|
||||
* .env afterwards was never read (2026-10-07).
|
||||
*/
|
||||
class ConfigMergeCommandTest extends TestCase
|
||||
{
|
||||
private string $path;
|
||||
private string $examplePath;
|
||||
private string $backupDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->path = base_path('config/test-config.php');
|
||||
$this->examplePath = $this->path . '.example';
|
||||
$this->backupDir = storage_path('config-backups');
|
||||
|
||||
// The real command only accepts the platform's own config files.
|
||||
$this->app->make(Kernel::class)->registerCommand(new class extends ConfigMerge {
|
||||
protected $mergeableConfigs = ['test-config' => 'config/test-config.php'];
|
||||
});
|
||||
|
||||
$this->cleanup();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->cleanup();
|
||||
putenv('CONFIG_MERGE_TEST_URL');
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function cleanup(): void
|
||||
{
|
||||
File::delete([$this->path, $this->examplePath]);
|
||||
|
||||
foreach (File::glob("{$this->backupDir}/test-config.php.backup.*") as $backup) {
|
||||
File::delete($backup);
|
||||
}
|
||||
}
|
||||
|
||||
private function write(string $current, string $example): void
|
||||
{
|
||||
File::put($this->path, $current);
|
||||
File::put($this->examplePath, $example);
|
||||
}
|
||||
|
||||
private function merge(array $options = []): \Illuminate\Testing\PendingCommand
|
||||
{
|
||||
return $this->artisan('config:merge', ['file' => 'test-config', '--force' => true] + $options);
|
||||
}
|
||||
|
||||
private function load(): array
|
||||
{
|
||||
return include $this->path;
|
||||
}
|
||||
|
||||
public function test_a_new_key_keeps_its_env_call_and_comment(): void
|
||||
{
|
||||
$this->write(<<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'live',
|
||||
];
|
||||
PHP, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'example',
|
||||
|
||||
// Ping URL for healthchecks.io.
|
||||
'monitor' => [
|
||||
'url' => env('CONFIG_MERGE_TEST_URL'),
|
||||
'limit' => 90,
|
||||
],
|
||||
];
|
||||
PHP);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$source = File::get($this->path);
|
||||
|
||||
$this->assertStringContainsString("'url' => env('CONFIG_MERGE_TEST_URL'),", $source);
|
||||
$this->assertStringContainsString('// Ping URL for healthchecks.io.', $source);
|
||||
|
||||
// And it reads the environment when loaded, not at merge time.
|
||||
putenv('CONFIG_MERGE_TEST_URL=https://hc-ping.test/abc');
|
||||
$this->assertSame('https://hc-ping.test/abc', $this->load()['monitor']['url']);
|
||||
$this->assertSame('live', $this->load()['name']);
|
||||
}
|
||||
|
||||
public function test_the_rest_of_the_file_is_left_exactly_as_it_was(): void
|
||||
{
|
||||
$current = <<<'PHP'
|
||||
<?php
|
||||
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/*
|
||||
* Platform settings.
|
||||
*/
|
||||
return [
|
||||
// Kept: a comment on an existing key.
|
||||
'mail' => env('CONFIG_MERGE_TEST_MAIL', 'admin@example.test'),
|
||||
'rules' => [
|
||||
'name' => fn ($value) => strlen($value) > 2,
|
||||
],
|
||||
'list' => ['a', 'b'],
|
||||
];
|
||||
PHP;
|
||||
$this->write($current, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'mail' => 'other@example.test',
|
||||
'rules' => [
|
||||
'name' => null,
|
||||
'slug' => 'required',
|
||||
],
|
||||
'list' => ['x', 'y'],
|
||||
'added' => true,
|
||||
];
|
||||
PHP);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$merged = File::get($this->path);
|
||||
|
||||
// Everything that was there is still there, untouched...
|
||||
$this->assertStringContainsString("'mail' => env('CONFIG_MERGE_TEST_MAIL', 'admin@example.test'),", $merged);
|
||||
$this->assertStringContainsString('// Kept: a comment on an existing key.', $merged);
|
||||
$this->assertStringContainsString("'name' => fn (\$value) => strlen(\$value) > 2,", $merged);
|
||||
$this->assertStringContainsString("use Illuminate\\Validation\\Rule;", $merged);
|
||||
|
||||
// ...and removing the two added lines gives back the original file.
|
||||
$withoutAdded = preg_replace("/\n[ ]*'slug' => 'required',|\n[ ]*'added' => true,/", '', $merged);
|
||||
$this->assertSame($current, $withoutAdded);
|
||||
|
||||
$config = $this->load();
|
||||
$this->assertSame('required', $config['rules']['slug']);
|
||||
$this->assertTrue($config['added']);
|
||||
$this->assertSame(['a', 'b'], $config['list'], 'Existing values win.');
|
||||
}
|
||||
|
||||
public function test_several_keys_into_one_array_and_awkward_layouts(): void
|
||||
{
|
||||
$this->write(<<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'empty' => [],
|
||||
'inline' => ['a' => 1],
|
||||
'no_trailing_comma' => [
|
||||
'a' => 1
|
||||
],
|
||||
'nested' => [
|
||||
'deep' => [
|
||||
'keep' => 'live',
|
||||
],
|
||||
]
|
||||
];
|
||||
PHP, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'empty' => ['x' => 1],
|
||||
'inline' => ['a' => 0, 'b' => 2],
|
||||
'no_trailing_comma' => [
|
||||
'a' => 0,
|
||||
'b' => 2,
|
||||
'c' => 3,
|
||||
],
|
||||
'nested' => [
|
||||
'deep' => [
|
||||
'keep' => 'example',
|
||||
'new' => env('CONFIG_MERGE_TEST_URL', 'fallback'),
|
||||
],
|
||||
],
|
||||
'top' => 'level',
|
||||
];
|
||||
PHP);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$config = $this->load();
|
||||
|
||||
$this->assertSame(['x' => 1], $config['empty']);
|
||||
$this->assertSame(['a' => 1, 'b' => 2], $config['inline']);
|
||||
$this->assertSame(['a' => 1, 'b' => 2, 'c' => 3], $config['no_trailing_comma']);
|
||||
$this->assertSame(['keep' => 'live', 'new' => 'fallback'], $config['nested']['deep']);
|
||||
$this->assertSame('level', $config['top']);
|
||||
$this->assertStringContainsString("'new' => env('CONFIG_MERGE_TEST_URL', 'fallback'),", File::get($this->path));
|
||||
}
|
||||
|
||||
/**
|
||||
* A list is the platform's own choice as a whole. Merging it by position gave Uuro (2026-10-18) the
|
||||
* template's footer sections and links, and put the template's 'route' into Uuro's mailto link;
|
||||
* a platform with fewer payment types would have been given the missing ones.
|
||||
*/
|
||||
public function test_an_existing_list_is_kept_as_a_whole(): void
|
||||
{
|
||||
$this->write(<<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'langs' => ['en', 'nl'],
|
||||
'payment_types' => ['work'],
|
||||
'footer' => [
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Contact',
|
||||
'links' => [
|
||||
['url' => 'mailto:hello@example.test', 'title' => 'Mail us', 'order' => 1],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
PHP, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'langs' => ['en', 'nl', 'de'],
|
||||
'payment_types' => ['work', 'gift', 'donation'],
|
||||
'footer' => [
|
||||
'sections' => [
|
||||
[
|
||||
'title' => 'Who we are',
|
||||
'links' => [
|
||||
['route' => 'static-events', 'title' => 'Events', 'order' => 1, 'auth_required' => false],
|
||||
['route' => 'static-faq', 'title' => 'FAQ', 'order' => 2],
|
||||
],
|
||||
],
|
||||
['title' => 'Help', 'links' => []],
|
||||
],
|
||||
'tagline' => 'Your time is currency',
|
||||
],
|
||||
];
|
||||
PHP);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$config = $this->load();
|
||||
$this->assertSame(['en', 'nl'], $config['langs']);
|
||||
$this->assertSame(['work'], $config['payment_types'], 'no payment types added');
|
||||
$this->assertSame([[
|
||||
'title' => 'Contact',
|
||||
'links' => [['url' => 'mailto:hello@example.test', 'title' => 'Mail us', 'order' => 1]],
|
||||
]], $config['footer']['sections'], 'no sections, links or keys inside a link added');
|
||||
$this->assertSame('Your time is currency', $config['footer']['tagline'], 'keys next to a list still merge');
|
||||
}
|
||||
|
||||
public function test_a_missing_list_is_added_whole_and_an_empty_one_is_kept(): void
|
||||
{
|
||||
$this->write(<<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'model_indices' => [],
|
||||
'empty_keyed' => [],
|
||||
];
|
||||
PHP, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'model_indices' => ['users_index', 'posts_index'],
|
||||
'empty_keyed' => ['limit' => 5],
|
||||
'mimes' => ['png', 'jpg'],
|
||||
];
|
||||
PHP);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$config = $this->load();
|
||||
$this->assertSame(['png', 'jpg'], $config['mimes'], 'a missing list is added whole');
|
||||
$this->assertSame([], $config['model_indices'], 'an empty list is the platform\'s choice');
|
||||
$this->assertSame(['limit' => 5], $config['empty_keyed'], 'an empty [] still takes keyed template keys');
|
||||
}
|
||||
|
||||
public function test_a_platform_whose_only_differences_are_lists_is_up_to_date(): void
|
||||
{
|
||||
$this->write("<?php\n\nreturn [\n 'langs' => ['en'],\n];\n", "<?php\n\nreturn [\n 'langs' => ['en', 'nl'],\n];\n");
|
||||
$before = File::get($this->path);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$this->assertSame($before, File::get($this->path));
|
||||
$this->assertSame([], File::glob("{$this->backupDir}/test-config.php.backup.*"), 'no backup for a file that is not changed');
|
||||
}
|
||||
|
||||
public function test_a_trailing_comment_of_the_line_above_is_not_copied(): void
|
||||
{
|
||||
$this->write("<?php\n\nreturn [\n 'days' => 365 // 1 year\n];\n",
|
||||
"<?php\n\nreturn [\n 'days' => 365, // 1 year\n // Allow edits below the minimum.\n 'edits' => false,\n];\n");
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$source = File::get($this->path);
|
||||
$this->assertSame(1, substr_count($source, '// 1 year'), $source);
|
||||
$this->assertStringContainsString(" // Allow edits below the minimum.\n 'edits' => false,", $source);
|
||||
$this->assertFalse($this->load()['edits']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A key defined twice: PHP keeps the last definition, so a new key must go into that one. Platform
|
||||
* configs copied from the old default template define profile_inactive twice; merging into the
|
||||
* first block failed with "keys still missing after merge" (2026-10-14).
|
||||
*/
|
||||
public function test_a_key_defined_twice_gets_new_keys_in_its_last_definition(): void
|
||||
{
|
||||
$this->write(<<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'inactive' => [
|
||||
'days' => 100,
|
||||
],
|
||||
'other' => 1,
|
||||
'inactive' => [
|
||||
'days' => 200,
|
||||
],
|
||||
];
|
||||
PHP, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'inactive' => [
|
||||
'days' => 200,
|
||||
'scheduler' => ['time' => '04:30'],
|
||||
],
|
||||
'other' => 1,
|
||||
];
|
||||
PHP);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$this->assertSame(['days' => 200, 'scheduler' => ['time' => '04:30']], $this->load()['inactive']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The check that existing values are unchanged compares closures by their source text, read by
|
||||
* line number. Read after the merge was written, a key inserted above a closure shifted those
|
||||
* lines and every merge into timebank_cc.php failed with "existing values changed" (2026-10-14).
|
||||
*/
|
||||
public function test_a_key_inserted_above_a_closure_merges(): void
|
||||
{
|
||||
// New top-level keys are appended at the end; a key inside an earlier array lands above the closure.
|
||||
$this->write(<<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'calls' => [
|
||||
'a' => 1,
|
||||
],
|
||||
'rule' => function ($value) {
|
||||
return $value > 1;
|
||||
},
|
||||
];
|
||||
PHP, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return [
|
||||
'calls' => [
|
||||
'a' => 1,
|
||||
'b' => [
|
||||
'one' => 1,
|
||||
'two' => 2,
|
||||
],
|
||||
],
|
||||
'rule' => function ($value) {
|
||||
return $value > 1;
|
||||
},
|
||||
];
|
||||
PHP);
|
||||
|
||||
$this->merge()->assertExitCode(0);
|
||||
|
||||
$merged = $this->load();
|
||||
$this->assertSame(['one' => 1, 'two' => 2], $merged['calls']['b']);
|
||||
$this->assertTrue($merged['rule'](2));
|
||||
}
|
||||
|
||||
public function test_dry_run_changes_nothing(): void
|
||||
{
|
||||
$current = "<?php\n\nreturn [\n 'a' => 1,\n];\n";
|
||||
$this->write($current, "<?php\n\nreturn [\n 'a' => 1,\n 'b' => env('CONFIG_MERGE_TEST_URL'),\n];\n");
|
||||
|
||||
$this->artisan('config:merge', ['file' => 'test-config', '--dry-run' => true])
|
||||
->expectsOutput('Found 1 new configuration key(s):')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertSame($current, File::get($this->path));
|
||||
}
|
||||
|
||||
public function test_an_up_to_date_config_is_left_alone(): void
|
||||
{
|
||||
$current = "<?php\n\nreturn [\n 'a' => env('X', 1),\n];\n";
|
||||
$this->write($current, "<?php\n\nreturn [\n 'a' => 2,\n];\n");
|
||||
|
||||
$this->merge()->expectsOutput(' test-config: No new keys found')->assertExitCode(0);
|
||||
|
||||
$this->assertSame($current, File::get($this->path));
|
||||
}
|
||||
|
||||
public function test_a_backup_is_made_and_only_five_are_kept(): void
|
||||
{
|
||||
for ($i = 1; $i <= 7; $i++) {
|
||||
$this->write("<?php\n\nreturn [\n 'a' => 1,\n];\n", "<?php\n\nreturn [\n 'a' => 1,\n 'k{$i}' => {$i},\n];\n");
|
||||
$this->merge()->assertExitCode(0);
|
||||
// Backups are named by the second.
|
||||
touch(collect(File::glob("{$this->backupDir}/test-config.php.backup.*"))->last(), time() - 100 + $i);
|
||||
sleep(1);
|
||||
}
|
||||
|
||||
$this->assertCount(5, File::glob("{$this->backupDir}/test-config.php.backup.*"));
|
||||
}
|
||||
|
||||
public function test_a_key_whose_parent_is_not_a_literal_array_is_skipped_safely(): void
|
||||
{
|
||||
$current = "<?php\n\nreturn [\n 'computed' => array_merge(['a' => 1], []),\n 'plain' => [\n 'a' => 1,\n ],\n];\n";
|
||||
$this->write($current, "<?php\n\nreturn [\n 'computed' => [\n 'a' => 1,\n 'b' => 2,\n ],\n 'plain' => [\n 'a' => 1,\n 'b' => 2,\n ],\n];\n");
|
||||
|
||||
$this->merge()
|
||||
->expectsOutputToContain('computed.b: not merged')
|
||||
->assertExitCode(0);
|
||||
|
||||
$config = $this->load();
|
||||
$this->assertSame(['a' => 1, 'b' => 2], $config['plain']);
|
||||
$this->assertSame(['a' => 1], $config['computed']);
|
||||
}
|
||||
|
||||
public function test_missing_files_are_reported(): void
|
||||
{
|
||||
File::put($this->path, "<?php\n\nreturn [];\n");
|
||||
|
||||
$this->artisan('config:merge', ['file' => 'test-config'])
|
||||
->expectsOutput('⊘ test-config: Example file not found (config/test-config.php.example)')
|
||||
->assertExitCode(0);
|
||||
|
||||
File::delete($this->path);
|
||||
File::put($this->examplePath, "<?php\n\nreturn [];\n");
|
||||
|
||||
$this->artisan('config:merge', ['file' => 'test-config'])
|
||||
->expectsOutput('⊘ test-config: Active config not found (config/test-config.php) - run deployment first')
|
||||
->assertExitCode(0);
|
||||
}
|
||||
|
||||
public function test_it_works_from_any_working_directory(): void
|
||||
{
|
||||
$this->write("<?php\n\nreturn [\n 'a' => 1,\n];\n", "<?php\n\nreturn [\n 'a' => 1,\n 'b' => 2,\n];\n");
|
||||
|
||||
$cwd = getcwd();
|
||||
chdir(sys_get_temp_dir());
|
||||
|
||||
try {
|
||||
$this->merge()->assertExitCode(0);
|
||||
} finally {
|
||||
chdir($cwd);
|
||||
}
|
||||
|
||||
$this->assertSame(2, $this->load()['b']);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user