Merge pull request 'Task: #53687 Support instance-specific cfg write targets and add coverage V2' (#4) from 53687/task-support-instance-specific-cfg-write-target-v2 into master

Reviewed-on: #4
Reviewed-by: norb <norbert.wagner@verua.ch>
This commit is contained in:
norb 2026-03-30 15:09:20 +00:00
commit 18860c799d
6 changed files with 731 additions and 58 deletions

276
bin/cfg
View file

@ -22,7 +22,12 @@ foreach ($autoloadFiles as $autoloadFile) {
} }
} }
$version = '0.3'; function verboseLog(bool $enabled, string $message): void
{
if ($enabled) fwrite(STDERR, "[verbose] $message".PHP_EOL);
}
$version = '0.4';
$actions = [ 'show', 'write', 'help' ]; $actions = [ 'show', 'write', 'help' ];
$settings = ['key' => '', 'value' => '']; $settings = ['key' => '', 'value' => ''];
@ -35,15 +40,55 @@ $collection = (new Input\InputCollection())
->description('Display help text') ->description('Display help text')
) // }}} ) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('in')->short('i') // {{{ ->add( Input\InputTypeFactory::build('LongOption')->name('verbose')->short('v') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) ->flags(AbstractInputType::FLAG_OPTIONAL)
->description('Path to a json data file to to read') ->description('Print debug details to STDERR')
) // }}} ) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('out')->short('o') // {{{ ->add( Input\InputTypeFactory::build('LongOption')->name('in')->short('i') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
->description('Path to a json file to to write to') ->description('Path to a JSON data file to read (for write)')
) // }}} ->validator(new Input\Validator(
function (AbstractInputType $input, AbstractInputHandler $context)
{
$path = $context->find('in');
if ($path === null || $path === '') {
return null;
}
if (!is_readable($path))
{
throw new \Exception("Input file is not readable: $path");
}
$content = file_get_contents($path);
if ($content === false)
{
throw new \Exception("Can not read input file: $path");
}
try
{
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
}
catch (\JsonException $ex)
{
throw new \Exception(sprintf(
'Input JSON is invalid (%s): %s',
(string)$ex->getCode(),
$ex->getMessage()
));
}
if (!is_array($data))
{
throw new \Exception('Input JSON must decode to an object/array');
}
return $data;
}
))
) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('mode')->short('m') // {{{ ->add( Input\InputTypeFactory::build('LongOption')->name('mode')->short('m') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED) ->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
@ -60,6 +105,60 @@ $collection = (new Input\InputCollection())
->description('Path where the config/ directory of the package conf files is located, defaults to the working dir') ->description('Path where the config/ directory of the package conf files is located, defaults to the working dir')
) // }}} ) // }}}
->add( Input\InputTypeFactory::build('LongOption')->name('siteDir')->short('s') // {{{
->flags(AbstractInputType::FLAG_OPTIONAL | Input\AbstractInputType::FLAG_VALUE_REQUIRED)
->description('Site/instance directory below config/. Accepts owner_xyz or config/owner_xyz')
->validator(new Input\Validator(
function (AbstractInputType $input, AbstractInputHandler $context)
{
$siteInput = $context->find('siteDir');
if ($siteInput === null || $siteInput === false) {
return null;
}
$siteInput = trim((string)$siteInput);
$appPath = $context->find('appPath');
if (!$appPath) {
$appPath = getcwd().'/';
}
$appPath = rtrim($appPath, '/').'/';
// Accept both "owner_xyz" and "config/owner_xyz" and normalize to site key.
if (str_starts_with($siteInput, $appPath.'config/'))
{
$siteInput = substr($siteInput, strlen($appPath.'config/'));
}
elseif (str_starts_with($siteInput, 'config/'))
{
$siteInput = substr($siteInput, strlen('config/'));
}
$siteInput = trim($siteInput, '/');
if ($siteInput === '')
{
throw new \Exception('Option --siteDir is empty.');
}
// Block directory traversal and hidden-dot segments.
if (str_contains($siteInput, '..') || preg_match('~(^|/)\.(?:/|$)~', $siteInput))
{
throw new \Exception("Invalid directory in --siteDir: '$siteInput'.");
}
// Allow only predictable path segments for instance directories.
foreach (explode('/', $siteInput) as $part)
{
if ($part === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $part))
{
throw new \Exception("Invalid directory name segment '$part' in --siteDir.");
}
}
return $siteInput;
}
))
) // }}}
->add( Input\InputTypeFactory::build('Argument')->name('action') // {{{ ->add( Input\InputTypeFactory::build('Argument')->name('action') // {{{
->flags(AbstractInputType::FLAG_REQUIRED) ->flags(AbstractInputType::FLAG_REQUIRED)
->description( ->description(
@ -102,18 +201,30 @@ $collection = (new Input\InputCollection())
->description( ->description(
'the settings you want to work on. With action "write" you can pass the value to set as JSON after an equal sign.' 'the settings you want to work on. With action "write" you can pass the value to set as JSON after an equal sign.'
) )
->validator(new Input\Validator( ->validator(new Input\Validator(
function (AbstractInputType $input, AbstractInputHandler $context) function (AbstractInputType $input, AbstractInputHandler $context)
{ {
$setting = $context->find('setting'); $setting = $context->find('setting');
$action = $context->find('action'); $action = $context->find('action');
$inputPayload = $context->find('in');
if ($action === 'write')
{
if (($setting !== null && $setting !== '') && $inputPayload) {
throw new \Exception('Please use either SETTING or --in, not both.');
}
}
if ($setting === null || $setting === '') {
return ['key' => '', 'value' => null];
}
$setting = explode('=', $setting); $setting = explode('=', $setting);
$settings['key'] = $setting[0]; $settings['key'] = $setting[0];
if ($action === 'write') if ($action === 'write')
{ {
$value = $setting[1]; $value = $setting[1] ?? null;
if (! (isset($value) || $context->find('data'))) { if (! (isset($value) || $context->find('in'))) {
throw new \Exception('You need a value to write'); throw new \Exception('You need a value to write');
} }
$specialValues = [ 'true', 'false', 'null' ]; $specialValues = [ 'true', 'false', 'null' ];
@ -139,18 +250,50 @@ $collection = (new Input\InputCollection())
$usage = Cli\manpage( basename(__FILE__), $version, $usage = Cli\manpage( basename(__FILE__), $version,
'read write settings', 'read write settings',
$collection, Colour::FG_GREEN, Colour::FG_WHITE, $collection, Colour::FG_GREEN, Colour::FG_WHITE,
[ [
'Examples' => 'Examples' =>
'cfg show VeruA db:host'.PHP_EOL. 'Basic usage:'
'cfg write VeruA \'db:host="newHost"\''.PHP_EOL .PHP_EOL
] .'cfg show VeruA db:host'
.PHP_EOL
.'cfg write VeruA \'db:host="newHost"\''
.PHP_EOL
.PHP_EOL
.'Advance usage:'
.PHP_EOL
.'cfg write prefix \'module:enabled=true\''
.PHP_EOL
.PHP_EOL
.'#Site specific write:'
.PHP_EOL
.'cfg write prefix \'module:enabled=true\' --siteDir=owner_xyz'
.PHP_EOL
.PHP_EOL
.'#Batch write from JSON file:'
.PHP_EOL
.'cfg write prefix --siteDir=owner_xyz -i /tmp/extension.json'
.PHP_EOL
.PHP_EOL
.'#Batch write from JSON string:'
.PHP_EOL
.'cfg write prefix \'module={"enabled":true,"timeout":30,"label":"example"}\' --siteDir=owner_xyz'
.PHP_EOL
.PHP_EOL
.'#Read merged value for a site:'
.PHP_EOL
.'cfg show prefix module:enabled --siteDir=owner_xyz'
.PHP_EOL
]
).PHP_EOL; ).PHP_EOL;
// Get the supplied input. Passing the collection will make the handler bind values // Get the supplied input. Passing the collection will make the handler bind values
// and validate the input according to our collection // and validate the input according to our collection
try { try
{
$argv = Input\InputHandlerFactory::build('Argv', $collection); $argv = Input\InputHandlerFactory::build('Argv', $collection);
} catch (\Exception $ex) { }
catch (\Exception $ex)
{
echo $usage; echo $usage;
if (isset($argv[1])) { if (isset($argv[1])) {
@ -172,42 +315,41 @@ if ($argv->find( 'help' ) || $argv->find('action') == 'help')
$prefix = $argv->find('prefix'); $prefix = $argv->find('prefix');
/* $verbose = (bool)$argv->find('verbose');
echo $argv->find('action').PHP_EOL;
echo ($prefix).PHP_EOL;
echo $argv->find('setting')['key'].PHP_EOL;
echo $argv->find('setting')['value'].PHP_EOL;
*/
// var_dump($cfg);
$appPath = $argv->find('appPath'); $appPath = $argv->find('appPath');
if (!$appPath) $appPath = getcwd().'/'; if (!$appPath) $appPath = getcwd().'/';
$appPath = rtrim($appPath, '/').'/';
/* $it = new RecursiveDirectoryIterator($appPath);
foreach(new RecursiveIteratorIterator($it) as $file)
{
$configDir = $file->getPath();
if ($file->isDir() && $file->getFilename() == '.' && basename($configDir) == 'config') {
echo "found config dir: $configDir\n";
}
}
*/
$mode = ($argv->find('mode') == '') ? null : $argv->find('mode'); $mode = ($argv->find('mode') == '') ? null : $argv->find('mode');
$cfg = (new Settings([], $mode))->appPath($appPath)->prefix($prefix); $cfg = (new Settings([], $mode))->appPath($appPath)->prefix($prefix);
if ($pkgPath = $argv->find('pkgPath')) $cfg->pkgPath($pkgPath); // pkgPath points to package defaults (e.g. <prefix>.default.conf.php)
if ($pkgPath = $argv->find('pkgPath')) $cfg->pkgPath(rtrim($pkgPath, '/').'/');
try { $site = null;
$siteFlag = 0x01;
$site = $argv->find('siteDir');
if ($site !== null && $site !== false)
{
// Reuse util-settings site resolution: config/<site>/<prefix>.conf.php
$cfg->site($site);
verboseLog($verbose, "siteDir resolved to '$site'");
}
try
{
if (is_readable($cfg->buildFileName('default'))) { if (is_readable($cfg->buildFileName('default'))) {
$cfg->load(); $cfg->load();
} }
elseif (is_readable($cfgFile = $cfg->buildFileName())) { elseif (is_readable($cfgFile = $cfg->buildFileName())) {
$cfg->load(require($cfgFile)); $cfg->load(require($cfgFile));
} }
} catch (Exception $e) { }
catch (\Throwable $e)
{
fwrite(STDERR, "Error: ".$e->getMessage().PHP_EOL); fwrite(STDERR, "Error: ".$e->getMessage().PHP_EOL);
exit(1); exit(1);
} }
//var_dump($cfg); verboseLog($verbose, 'config bootstrap loaded');
$result = $cfg; $result = $cfg;
$settings = $argv->find('setting') ?? $settings; $settings = $argv->find('setting') ?? $settings;
@ -232,33 +374,63 @@ if ($result instanceof Settings) $result = $result->toArray();
switch ($argv->find('action')) switch ($argv->find('action'))
{ {
case 'show': case 'show':
verboseLog($verbose, "show action for prefix '$prefix'");
$out = (is_string($result)) ? $result : json_encode($result, JSON_PRETTY_PRINT); $out = (is_string($result)) ? $result : json_encode($result, JSON_PRETTY_PRINT);
echo $out.PHP_EOL; echo $out.PHP_EOL;
break; break;
case 'write': case 'write':
$inputPayload = $argv->find('in');
if (!$inputPayload && $settings['key'] === '')
{
fwrite(STDERR, 'Nothing to write: provide SETTING or --in.'.PHP_EOL);
exit(1);
}
$path = ($settings['key'] !== '') ? explode(':', $settings['key']) : []; $path = ($settings['key'] !== '') ? explode(':', $settings['key']) : [];
var_dump($path); verboseLog($verbose, 'write source: '.($inputPayload ? '--in' : 'SETTING'));
$setting2write = $settings['value'];
if ($inputPayload)
{
$setting2write = $inputPayload;
}
else
{
$setting2write = $settings['value'];
}
verboseLog($verbose, 'write path: '.json_encode($path));
while ( ! empty($path)) while ( ! empty($path))
{ {
$setting2write = [array_pop($path) => $setting2write]; $setting2write = [array_pop($path) => $setting2write];
} }
if (is_readable($file = $cfg->buildFileName()))
$writeType = ($site !== null && $site !== false) ? $siteFlag : null;
$file = $cfg->buildFileName($writeType);
verboseLog($verbose, "write target: $file");
if (is_readable($file))
{ {
$setting2write = array_replace_recursive(require($file), $setting2write); $setting2write = array_replace_recursive(require($file), $setting2write);
copy($file, "$file.bak"); copy($file, "$file.bak");
verboseLog($verbose, "existing config merged from: $file");
} }
$targetDir = dirname($file);
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir))
{
fwrite(STDERR, "Can not create directory: $targetDir".PHP_EOL);
exit(1);
}
$writeCfg = $cfg->create($setting2write); $writeCfg = $cfg->create($setting2write);
// var_dump($writeCfg->toArray()); verboseLog($verbose, 'payload prepared for writer');
try { try
(new SettingsWriter($writeCfg))->write(); {
(new SettingsWriter($writeCfg, '', $writeType))->write();
echo "Written modified settings to: $file".PHP_EOL; echo "Written modified settings to: $file".PHP_EOL;
} }
catch (\Exception $e) { catch (\Exception $e) {
echo $e->getMessage().PHP_EOL; fwrite(STDERR, $e->getMessage().PHP_EOL);
exit(1);
} }
break; break;

View file

@ -159,7 +159,18 @@ class Settings implements \Iterator, \Countable
// if a mode was set in the constructor do not overwrite it // if a mode was set in the constructor do not overwrite it
if (! isset($this->mode)) { if (! isset($this->mode)) {
// if a localConf Mode is set use it, or take the default conf mode // if a localConf Mode is set use it, or take the default conf mode
$this->mode = (isset($localConf['mode'])) ? $localConf['mode'] : $this->settings['mode']; // if local/default config has no mode, fall back to the first known mode (normally "prod")
if (isset($localConf['mode']) && is_string($localConf['mode']) && $localConf['mode'] !== '') {
$this->mode = $localConf['mode'];
}
elseif (isset($this->settings['mode']) && is_string($this->settings['mode']) && $this->settings['mode'] !== '') {
$this->mode = $this->settings['mode'];
}
else {
// Backward-compatible fallback for configs without explicit mode.
$this->mode = (string)array_key_first($this->modes);
$this->settings['mode'] = $this->mode;
}
} else { } else {
$this->settings['mode'] = $this->mode; $this->settings['mode'] = $this->mode;
} }
@ -358,4 +369,4 @@ class Settings implements \Iterator, \Countable
/* jEdit buffer local properties {{{ /* jEdit buffer local properties {{{
* :folding=explicit:collapseFolds=1: * :folding=explicit:collapseFolds=1:
}}}*/ }}}*/

View file

@ -22,6 +22,8 @@
declare(strict_types=1); declare(strict_types=1);
namespace rabe\Util; namespace rabe\Util;
use ErrorException;
/** /**
* Write Settings into a File * Write Settings into a File
* @author Norbert.e.Wagner dev@norb.me * @author Norbert.e.Wagner dev@norb.me
@ -45,13 +47,16 @@ class SettingsWriter
* *
* @param Settings $settings an object of type settings * @param Settings $settings an object of type settings
* @param String $name The name midfix for the settings File * @param String $name The name midfix for the settings File
*/ * @param String|int|null $type Optional type for Settings::buildFileName(), e.g. site flag
public function __construct( Settings $settings, $name='' ) */
public function __construct( Settings $settings, $name='', $type=null )
{ {
$this->settings = $settings; $this->settings = $settings;
$file = $settings->buildFileName( $name ); $file = ( $type === null )
? $settings->buildFileName( $name )
: $settings->buildFileName( $type );
if ( ! $this->handle = fopen( $file, 'w' ) ) if ( ! $this->handle = fopen( $file, 'w' ) )
{ {
throw new ErrorException( "Can not open File: $file" ); throw new ErrorException( "Can not open File: $file" );

457
tests/CfgTest.php Normal file
View file

@ -0,0 +1,457 @@
<?php
declare(strict_types=1);
namespace rabe\Util\tests;
use PHPUnit\Framework\TestCase;
class CfgTest extends TestCase
{
private string $tmpDir;
/* Lifecycle {{{ */
protected function setUp(): void
{
$this->tmpDir = rtrim(sys_get_temp_dir(), '/').'/util-settings-cfg-'.bin2hex(random_bytes(8));
mkdir($this->tmpDir, 0775, true);
mkdir($this->tmpDir.'/config', 0775, true);
file_put_contents(
$this->tmpDir.'/config/Extension.default.conf.php',
"<?php return [\n\t'mode' => 'prod',\n\t'module' => [\n\t\t'code' => '',\n\t\t'label' => '',\n\t\t'flags' => [\n\t\t\t'enabled' => false,\n\t\t],\n\t],\n\t'feature' => [\n\t\t'endpoint' => '',\n\t],\n];\n"
);
}
protected function tearDown(): void
{
$this->removeDir($this->tmpDir);
}
/* }}} */
/* Write Flow {{{ */
public function testWriteWithoutDirectoryNameUsesLocalConfigFile(): void
{
$result = $this->runWrite([
'module:code="X100"',
]);
$this->assertCfgSuccess($result);
$localFile = $this->tmpDir.'/config/Extension.conf.php';
$this->assertFileExists($localFile);
$cfg = require $localFile;
$this->assertSame('X100', $cfg['module']['code']);
}
public function testWriteWithoutModeFallsBackToDefaultMode(): void
{
file_put_contents(
$this->tmpDir.'/config/Extension.default.conf.php',
"<?php return [\n\t'module' => [\n\t\t'code' => '',\n\t],\n];\n"
);
$result = $this->runWrite([
'module:code="X100"',
]);
$this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/Extension.conf.php');
}
public function testWriteWithInputFileWritesMultipleSettings(): void
{
$inFile = $this->createJsonInputFile([
'module' => [
'code' => 'X100',
'label' => 'demo-module',
],
'feature' => [
'endpoint' => 'https://example.invalid/v1/resource',
],
]);
$result = $this->runWrite([
'--siteDir=owner_xyz',
'-i',
$inFile,
]);
$this->assertCfgSuccess($result);
$cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertSame('X100', $cfg['module']['code']);
$this->assertSame('demo-module', $cfg['module']['label']);
$this->assertSame('https://example.invalid/v1/resource', $cfg['feature']['endpoint']);
}
public function testWriteAcceptsJsonStringValueInSetting(): void
{
$result = $this->runWrite([
'module={"code":"X100","label":"demo-module"}',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertSame('X100', $cfg['module']['code']);
$this->assertSame('demo-module', $cfg['module']['label']);
}
public function testWriteSupportsNestedPathWithColonNotation(): void
{
$result = $this->runWrite([
'module:flags:enabled=true',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertTrue($cfg['module']['flags']['enabled']);
}
public function testWriteCoercesSimpleStringValueToJsonString(): void
{
$result = $this->runWrite([
'module:code=X100',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertSame('X100', $cfg['module']['code']);
}
public function testWriteRejectsInvalidJsonStringValue(): void
{
$result = $this->runWrite([
'module={"code":',
'--siteDir=owner_xyz',
]);
$this->assertCfgFailure($result, 'The value does not appear to be a valid JSON string.');
}
public function testWriteWithSiteDirCreatesAndWritesSiteConfig(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$siteFile = $this->tmpDir.'/config/owner_xyz/Extension.conf.php';
$this->assertFileExists($siteFile);
$this->assertFileDoesNotExist($this->tmpDir.'/config/Extension.conf.php');
$cfg = require $siteFile;
$this->assertSame('X100', $cfg['module']['code']);
}
public function testWriteWithSiteDirMergesIntoExistingSiteConfig(): void
{
$firstWrite = $this->runWrite([
'module:code="X100"',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($firstWrite);
$secondWrite = $this->runWrite([
'module:label="demo-module"',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($secondWrite);
$cfg = $this->loadWrittenConfig('owner_xyz');
$this->assertSame('X100', $cfg['module']['code']);
$this->assertSame('demo-module', $cfg['module']['label']);
}
public function testWriteWithSiteDirUsingConfigPrefixIsNormalized(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir=config/owner_xyz',
]);
$this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php');
$this->assertFileDoesNotExist($this->tmpDir.'/config/config/owner_xyz/Extension.conf.php');
}
public function testWriteWithSiteDirUsingAbsoluteConfigPrefixIsNormalized(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir='.$this->tmpDir.'/config/owner_xyz',
]);
$this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php');
}
public function testWriteWithNestedSiteDirWritesToNestedSiteConfig(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir=owner_xyz/sub_a',
]);
$this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/sub_a/Extension.conf.php');
}
public function testWriteWithSiteDirWritesToSiteConfig(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($result);
$this->assertFileExists($this->tmpDir.'/config/owner_xyz/Extension.conf.php');
}
public function testWriteToExistingSiteConfigCreatesBackup(): void
{
$firstWrite = $this->runWrite([
'module:code="first"',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($firstWrite);
$secondWrite = $this->runWrite([
'module:code="second"',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($secondWrite);
$siteFile = $this->tmpDir.'/config/owner_xyz/Extension.conf.php';
$backupFile = $siteFile.'.bak';
$this->assertFileExists($backupFile);
$current = require $siteFile;
$backup = require $backupFile;
$this->assertSame('second', $current['module']['code']);
$this->assertSame('first', $backup['module']['code']);
}
/* }}} */
/* Show Flow {{{ */
public function testShowWithSiteReturnsSiteSpecificValue(): void
{
$this->runWrite([
'module:code="X100"',
'--siteDir=owner_xyz',
]);
$show = $this->runShow([
'module:code',
'--siteDir=owner_xyz',
]);
$this->assertCfgSuccess($show);
$this->assertStringContainsString('X100', $show['output']);
}
/* }}} */
/* Validation {{{ */
public function testInvalidActionReturnsError(): void
{
$result = $this->runCfg([
'-a',
$this->tmpDir,
'delete',
'Extension',
]);
$this->assertCfgFailure($result, "'delete' not found");
}
public function testSiteDirRejectsEmptyValue(): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir=',
]);
$this->assertCfgFailure($result, 'a value is required for --siteDir');
}
/**
* @dataProvider siteDirValidationDataProvider
*/
public function testSiteDirValidation(string $siteDir, string $expectedMessage): void
{
$result = $this->runWrite([
'module:code="X100"',
'--siteDir='.$siteDir,
]);
$this->assertCfgFailure($result, $expectedMessage);
}
/**
* @dataProvider inputFileValidationDataProvider
*/
public function testInputFileValidation(?string $fileContents, string $pathSuffix, string $expectedMessage): void
{
$inFile = $this->tmpDir.'/'.$pathSuffix;
if ($fileContents !== null) {
file_put_contents($inFile, $fileContents);
}
$result = $this->runWrite([
'-i',
$inFile,
]);
$this->assertCfgFailure($result, $expectedMessage);
}
/**
* @dataProvider writePayloadSourceValidationDataProvider
*/
public function testWritePayloadSourceValidation(array $extraArgs, string $expectedMessage): void
{
$inFile = $this->createJsonInputFile(['module' => ['code' => 'X100']]);
$args = array_merge(
[
'-a',
$this->tmpDir,
'write',
'Extension',
],
array_map(
fn (string $arg): string => $arg === '__INPUT_FILE__' ? $inFile : $arg,
$extraArgs
)
);
$result = $this->runCfg($args);
$this->assertCfgFailure($result, $expectedMessage);
}
public function siteDirValidationDataProvider(): array
{
return [
'traversal' => ['../owner_xyz', 'Invalid directory in --siteDir'],
'invalid segment' => ['owner xyz', "Invalid directory name segment 'owner xyz' in --siteDir."],
'hidden dot segment' => ['owner_xyz/./secret', "Invalid directory in --siteDir: 'owner_xyz/./secret'."],
'config root only' => ['config/', 'Option --siteDir is empty.'],
];
}
public function inputFileValidationDataProvider(): array
{
return [
'missing file' => [null, 'missing.json', 'Input file is not readable'],
'invalid json' => ['{"module":', 'invalid.json', 'Input JSON is invalid'],
'scalar json' => ['true', 'scalar.json', 'Input JSON must decode to an object/array'],
];
}
public function writePayloadSourceValidationDataProvider(): array
{
return [
'missing payload source' => [[], 'Nothing to write: provide SETTING or --in.'],
'both payload sources' => [['module:code="x"', '-i', '__INPUT_FILE__'], 'Please use either SETTING or --in, not both.'],
];
}
/* }}} */
/* Helpers {{{ */
private function runWrite(array $args): array
{
return $this->runCfg(array_merge([
'-a',
$this->tmpDir,
'write',
'Extension',
], $args));
}
private function runShow(array $args): array
{
return $this->runCfg(array_merge([
'-a',
$this->tmpDir,
'show',
'Extension',
], $args));
}
private function createJsonInputFile(array $contents, string $name = 'extension-in.json'): string
{
$path = $this->tmpDir.'/'.$name;
file_put_contents($path, json_encode($contents, JSON_PRETTY_PRINT));
return $path;
}
private function loadWrittenConfig(?string $siteDir = null): array
{
$path = $siteDir === null
? $this->tmpDir.'/config/Extension.conf.php'
: $this->tmpDir.'/config/'.$siteDir.'/Extension.conf.php';
return require $path;
}
private function assertCfgSuccess(array $result): void
{
$this->assertSame(0, $result['code'], $result['output']);
}
private function assertCfgFailure(array $result, string $message): void
{
$this->assertSame(1, $result['code'], $result['output']);
$this->assertStringContainsString($message, $result['output']);
}
private function runCfg(array $args): array
{
$script = realpath(__DIR__.'/../bin/cfg');
$this->assertNotFalse($script);
$command = escapeshellarg(PHP_BINARY).' '.escapeshellarg($script);
foreach ($args as $arg) {
$command .= ' '.escapeshellarg($arg);
}
$command .= ' 2>&1';
$outputLines = [];
$exitCode = 0;
exec($command, $outputLines, $exitCode);
return [
'code' => $exitCode,
'output' => implode(PHP_EOL, $outputLines),
];
}
private function removeDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
foreach (scandir($dir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir.'/'.$entry;
if (is_dir($path)) {
$this->removeDir($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
/* }}} */
}

View file

@ -192,6 +192,15 @@ class SettingsTest extends TestCase
$cfg = $this->appPath($cfg, 'localOverride')->load(); $cfg = $this->appPath($cfg, 'localOverride')->load();
$this->assertEquals(42, $cfg->answer); $this->assertEquals(42, $cfg->answer);
} }
public function testLoadWithoutModeFallsBackToFirstKnownMode(): void
{
$cfg = new Settings();
$cfg = $this->appPath($cfg, 'noMode')->load();
$this->assertEquals('prod', $cfg->mode);
$this->assertEquals('default', $cfg->testFiles);
}
/** /**
* @dataProvider fileNameData * @dataProvider fileNameData
@ -211,6 +220,22 @@ class SettingsTest extends TestCase
$this->assertEquals($path.$expected, $cfg->buildFileName($type)); $this->assertEquals($path.$expected, $cfg->buildFileName($type));
} }
public function testBuildFileNameWithPrefixAndSite(): void
{
$cfg = new Settings();
$cfg->appPath('./')->prefix('Extension')->site('owner_xyz');
$this->assertEquals('./config/owner_xyz/Extension.conf.php', $cfg->buildFileName(0x01));
}
public function testBuildFileNameWithPrefixAndNestedSite(): void
{
$cfg = new Settings();
$cfg->appPath('./')->prefix('Extension')->site('owner_xyz/sub_a');
$this->assertEquals('./config/owner_xyz/sub_a/Extension.conf.php', $cfg->buildFileName(0x01));
}
public function fileNameData() public function fileNameData()
{ {

View file

@ -0,0 +1,3 @@
<?php return [
'testFiles' => 'default',
];