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' 'live', ]; PHP, <<<'PHP' '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' env('CONFIG_MERGE_TEST_MAIL', 'admin@example.test'), 'rules' => [ 'name' => fn ($value) => strlen($value) > 2, ], 'list' => ['a', 'b'], ]; PHP; $this->write($current, <<<'PHP' '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' [], 'inline' => ['a' => 1], 'no_trailing_comma' => [ 'a' => 1 ], 'nested' => [ 'deep' => [ 'keep' => 'live', ], ] ]; PHP, <<<'PHP' ['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' ['en', 'nl'], 'payment_types' => ['work'], 'footer' => [ 'sections' => [ [ 'title' => 'Contact', 'links' => [ ['url' => 'mailto:hello@example.test', 'title' => 'Mail us', 'order' => 1], ], ], ], ], ]; PHP, <<<'PHP' ['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' [], 'empty_keyed' => [], ]; PHP, <<<'PHP' ['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(" ['en'],\n];\n", " ['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(" 365 // 1 year\n];\n", " 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' [ 'days' => 100, ], 'other' => 1, 'inactive' => [ 'days' => 200, ], ]; PHP, <<<'PHP' [ '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' [ 'a' => 1, ], 'rule' => function ($value) { return $value > 1; }, ]; PHP, <<<'PHP' [ '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 = " 1,\n];\n"; $this->write($current, " 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 = " env('X', 1),\n];\n"; $this->write($current, " 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(" 1,\n];\n", " 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 = " array_merge(['a' => 1], []),\n 'plain' => [\n 'a' => 1,\n ],\n];\n"; $this->write($current, " [\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, "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, "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(" 1,\n];\n", " 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']); } }