Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

+479
View File
@@ -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']);
}
}