Merge pull request #348 from acelaya/feature/external-installer

Feature/external installer
This commit is contained in:
Alejandro Celaya 2019-02-06 23:56:10 +01:00 committed by GitHub
commit 04e03e9b6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 23 additions and 2018 deletions

10
.gitattributes vendored
View File

@ -1,12 +1,14 @@
/config/test export-ignore
/data/infra export-ignore
/docs export-ignore
/module/CLI/test export-ignore
/module/CLI/test-resources export-ignore
/module/Common/test export-ignore
/module/Common/test-func export-ignore
/module/Common/test-db export-ignore
/module/Core/test export-ignore
/module/Core/test-func export-ignore
/module/Core/test-db export-ignore
/module/Rest/test export-ignore
/module/Rest/test-api export-ignore
.env.dist export-ignore
.gitattributes export-ignore
.gitignore export-ignore
@ -17,9 +19,9 @@ build.sh export-ignore
CHANGELOG.md export-ignore
docker-compose.override.yml.dist export-ignore
docker-compose.yml export-ignore
func_tests_bootstrap.php export-ignore
indocker export-ignore
phpcs.xml export-ignore
phpunit.xml.dist export-ignore
phpunit-func.xml export-ignore
phpunit-api.xml export-ignore
phpunit-db.xml export-ignore
phpstan.neon

View File

@ -47,6 +47,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* [#320](https://github.com/shlinkio/shlink/issues/320) Replaced query builder by plain DQL for all queries which do not need to be dynamically generated.
* [#330](https://github.com/shlinkio/shlink/issues/330) No longer allow failures on PHP 7.3 envs during project CI build.
* [#335](https://github.com/shlinkio/shlink/issues/335) Renamed functional test suite to database test suite, since that better describes what it actually does.
* [#346](https://github.com/shlinkio/shlink/issues/346) Extracted installer as an independent tool.
#### Deprecated

View File

@ -2,11 +2,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer;
namespace Shlinkio\Shlink;
use Symfony\Component\Console\Application;
use Zend\ServiceManager\ServiceLocatorInterface;
/** @var ServiceLocatorInterface $container */
$container = include __DIR__ . '/../config/install-container.php';
$container->build(Application::class)->run();
$run = require __DIR__ . '/../vendor/shlinkio/shlink-installer/bin/run.php';
$run(false);

View File

@ -2,11 +2,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer;
namespace Shlinkio\Shlink;
use Symfony\Component\Console\Application;
use Zend\ServiceManager\ServiceLocatorInterface;
/** @var ServiceLocatorInterface $container */
$container = include __DIR__ . '/../config/install-container.php';
$container->build(Application::class, ['isUpdate' => true])->run();
$run = require __DIR__ . '/../vendor/shlinkio/shlink-installer/bin/run.php';
$run(true);

View File

@ -29,7 +29,7 @@
"lstrojny/functional-php": "^1.8",
"mikehaertl/phpwkhtmltopdf": "^2.2",
"monolog/monolog": "^1.21",
"roave/security-advisories": "dev-master",
"shlinkio/shlink-installer": "^1.0",
"symfony/console": "^4.2",
"symfony/filesystem": "^4.2",
"symfony/lock": "^4.2",
@ -37,7 +37,7 @@
"theorchard/monolog-cascade": "^0.4",
"zendframework/zend-config": "^3.0",
"zendframework/zend-config-aggregator": "^1.0",
"zendframework/zend-diactoros": "^2.0 <2.0.2",
"zendframework/zend-diactoros": "^2.1.1",
"zendframework/zend-expressive": "^3.0",
"zendframework/zend-expressive-fastroute": "^3.0",
"zendframework/zend-expressive-helpers": "^5.0",
@ -57,6 +57,7 @@
"phpstan/phpstan": "^0.10.0",
"phpunit/phpcov": "^5.0",
"phpunit/phpunit": "^7.3",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~1.0.0",
"symfony/dotenv": "^4.2",
"symfony/var-dumper": "^4.2",
@ -68,8 +69,7 @@
"Shlinkio\\Shlink\\CLI\\": "module/CLI/src",
"Shlinkio\\Shlink\\Rest\\": "module/Rest/src",
"Shlinkio\\Shlink\\Core\\": "module/Core/src",
"Shlinkio\\Shlink\\Common\\": "module/Common/src",
"Shlinkio\\Shlink\\Installer\\": "module/Installer/src"
"Shlinkio\\Shlink\\Common\\": "module/Common/src"
},
"files": [
"module/Common/functions/functions.php"
@ -87,8 +87,7 @@
"ShlinkioTest\\Shlink\\Common\\": [
"module/Common/test",
"module/Common/test-db"
],
"ShlinkioTest\\Shlink\\Installer\\": "module/Installer/test"
]
}
},
"scripts": {

View File

@ -18,7 +18,6 @@ return (new ConfigAggregator\ConfigAggregator([
Common\ConfigProvider::class,
Core\ConfigProvider::class,
CLI\ConfigProvider::class,
Installer\ConfigProvider::class,
Rest\ConfigProvider::class,
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
new ConfigAggregator\ZendConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'),

View File

@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Factory\InstallApplicationFactory;
use Symfony\Component\Console\Application;
use Symfony\Component\Filesystem\Filesystem;
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Zend\ServiceManager\Factory\InvokableFactory;
use Zend\ServiceManager\ServiceManager;
chdir(dirname(__DIR__));
require __DIR__ . '/../vendor/autoload.php';
$config = [
'dependencies' => [
'factories' => [
Application::class => InstallApplicationFactory::class,
Filesystem::class => InvokableFactory::class,
],
'services' => [
'random-chars-generator' => function () {
return str_shuffle(UrlShortenerOptions::DEFAULT_CHARS);
},
],
],
'config_customizer_plugins' => [
'factories' => [
Plugin\DatabaseConfigCustomizer::class => ConfigAbstractFactory::class,
Plugin\UrlShortenerConfigCustomizer::class => ConfigAbstractFactory::class,
Plugin\LanguageConfigCustomizer::class => InvokableFactory::class,
Plugin\ApplicationConfigCustomizer::class => InvokableFactory::class,
],
],
ConfigAbstractFactory::class => [
Plugin\DatabaseConfigCustomizer::class => [Filesystem::class],
Plugin\UrlShortenerConfigCustomizer::class => ['random-chars-generator'],
],
];
$container = new ServiceManager($config['dependencies']);
$container->setService('config', $config);
return $container;

View File

@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Collection;
use function array_key_exists;
use function array_shift;
use function is_array;
final class PathCollection
{
/** @var array */
private $array;
public function __construct(array $array)
{
$this->array = $array;
}
public function pathExists(array $path): bool
{
return $this->checkPathExists($path, $this->array);
}
private function checkPathExists(array $path, array $array): bool
{
// As soon as a step is not found, the path does not exist
$step = array_shift($path);
if (! array_key_exists($step, $array)) {
return false;
}
// Once the path is empty, we have found all the parts in the path
if (empty($path)) {
return true;
}
// If current value is not an array, then we have not found the path
$newArray = $array[$step];
if (! is_array($newArray)) {
return false;
}
return $this->checkPathExists($path, $newArray);
}
/**
* @return mixed
*/
public function getValueInPath(array $path)
{
$array = $this->array;
do {
$step = array_shift($path);
if (! is_array($array) || ! array_key_exists($step, $array)) {
return null;
}
$array = $array[$step];
} while (! empty($path));
return $array;
}
}

View File

@ -1,80 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Collection;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Collection\PathCollection;
class PathCollectionTest extends TestCase
{
/** @var PathCollection */
private $collection;
public function setUp()
{
$this->collection = new PathCollection([
'foo' => [
'bar' => [
'baz' => 'Hello world!',
],
],
'something' => [],
'another' => [
'one' => 'Shlink',
],
]);
}
/**
* @test
* @dataProvider providePaths
*/
public function pathExistsReturnsExpectedValue(array $path, bool $expected)
{
$this->assertEquals($expected, $this->collection->pathExists($path));
}
public function providePaths(): array
{
return [
[[], false],
[['boo'], false],
[['foo', 'nop'], false],
[['another', 'one', 'nop'], false],
[['foo'], true],
[['foo', 'bar'], true],
[['foo', 'bar', 'baz'], true],
[['something'], true],
];
}
/**
* @test
* @dataProvider providePathsWithValue
*/
public function getValueInPathReturnsExpectedValue(array $path, $expected)
{
$this->assertEquals($expected, $this->collection->getValueInPath($path));
}
public function providePathsWithValue(): array
{
return [
[[], null],
[['boo'], null],
[['foo', 'nop'], null],
[['another', 'one', 'nop'], null],
[['foo'], [
'bar' => [
'baz' => 'Hello world!',
],
]],
[['foo', 'bar'], [
'baz' => 'Hello world!',
]],
[['foo', 'bar', 'baz'], 'Hello world!'],
[['something'], []],
];
}
}

View File

@ -1,4 +0,0 @@
<?php
declare(strict_types=1);
return [];

View File

@ -1,247 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Command;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
use Shlinkio\Shlink\Installer\Config\ConfigCustomizerManagerInterface;
use Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Installer\Util\AskUtilsTrait;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\PhpExecutableFinder;
use Zend\Config\Writer\WriterInterface;
use function array_unshift;
use function implode;
class InstallCommand extends Command
{
use AskUtilsTrait;
public const GENERATED_CONFIG_PATH = 'config/params/generated_config.php';
/** @var SymfonyStyle */
private $io;
/** @var ProcessHelper */
private $processHelper;
/** @var WriterInterface */
private $configWriter;
/** @var Filesystem */
private $filesystem;
/** @var ConfigCustomizerManagerInterface */
private $configCustomizers;
/** @var bool */
private $isUpdate;
/** @var PhpExecutableFinder */
private $phpFinder;
/** @var string|bool */
private $phpBinary;
/**
* InstallCommand constructor.
* @param WriterInterface $configWriter
* @param Filesystem $filesystem
* @param ConfigCustomizerManagerInterface $configCustomizers
* @param bool $isUpdate
* @param PhpExecutableFinder|null $phpFinder
* @throws LogicException
*/
public function __construct(
WriterInterface $configWriter,
Filesystem $filesystem,
ConfigCustomizerManagerInterface $configCustomizers,
bool $isUpdate = false,
PhpExecutableFinder $phpFinder = null
) {
parent::__construct();
$this->configWriter = $configWriter;
$this->isUpdate = $isUpdate;
$this->filesystem = $filesystem;
$this->configCustomizers = $configCustomizers;
$this->phpFinder = $phpFinder ?: new PhpExecutableFinder();
}
protected function configure(): void
{
$this
->setName('shlink:install')
->setDescription('Installs or updates Shlink');
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return void
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
protected function execute(InputInterface $input, OutputInterface $output): void
{
$this->io = new SymfonyStyle($input, $output);
$this->io->writeln([
'<info>Welcome to Shlink!!</info>',
'This tool will guide you through the installation process.',
]);
// Check if a cached config file exists and drop it if so
if ($this->filesystem->exists('data/cache/app_config.php')) {
$this->io->write('Deleting old cached config...');
try {
$this->filesystem->remove('data/cache/app_config.php');
$this->io->writeln(' <info>Success</info>');
} catch (IOException $e) {
$this->io->error(
'Failed! You will have to manually delete the data/cache/app_config.php file to'
. ' get new config applied.'
);
if ($this->io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
return;
}
}
// If running update command, ask the user to import previous config
$config = $this->isUpdate ? $this->importConfig() : new CustomizableAppConfig();
// Ask for custom config params
foreach ([
Plugin\DatabaseConfigCustomizer::class,
Plugin\UrlShortenerConfigCustomizer::class,
Plugin\LanguageConfigCustomizer::class,
Plugin\ApplicationConfigCustomizer::class,
] as $pluginName) {
/** @var Plugin\ConfigCustomizerInterface $configCustomizer */
$configCustomizer = $this->configCustomizers->get($pluginName);
$configCustomizer->process($this->io, $config);
}
// Generate config params files
$this->configWriter->toFile(self::GENERATED_CONFIG_PATH, $config->getArrayCopy(), false);
$this->io->writeln(['<info>Custom configuration properly generated!</info>', '']);
// If current command is not update, generate database
if (! $this->isUpdate) {
$this->io->write('Initializing database...');
if (! $this->execPhp(
['vendor/doctrine/orm/bin/doctrine.php', 'orm:schema-tool:create'],
'Error generating database.',
$output
)) {
return;
}
}
// Run database migrations
$this->io->write('Updating database...');
if (! $this->execPhp(
['vendor/doctrine/migrations/bin/doctrine-migrations.php', 'migrations:migrate'],
'Error updating database.',
$output
)) {
return;
}
// Generate proxies
$this->io->write('Generating proxies...');
if (! $this->execPhp(
['vendor/doctrine/orm/bin/doctrine.php', 'orm:generate-proxies'],
'Error generating proxies.',
$output
)) {
return;
}
// Download GeoLite2 db file
$this->io->write('Downloading GeoLite2 db...');
if (! $this->execPhp(['bin/cli', 'visit:update-db'], 'Error downloading GeoLite2 db.', $output)) {
return;
}
$this->io->success('Installation complete!');
}
/**
* @return CustomizableAppConfig
* @throws RuntimeException
*/
private function importConfig(): CustomizableAppConfig
{
$config = new CustomizableAppConfig();
// Ask the user if he/she wants to import an older configuration
$importConfig = $this->io->confirm(
'Do you want to import configuration from previous installation? (You will still be asked for any new '
. 'config option that did not exist in previous shlink versions)'
);
if (! $importConfig) {
return $config;
}
// Ask the user for the older shlink path
$keepAsking = true;
do {
$config->setImportedInstallationPath($this->askRequired(
$this->io,
'previous installation path',
'Previous shlink installation path from which to import config'
));
$configFile = $config->getImportedInstallationPath() . '/' . self::GENERATED_CONFIG_PATH;
$configExists = $this->filesystem->exists($configFile);
if (! $configExists) {
$keepAsking = $this->io->confirm(
'Provided path does not seem to be a valid shlink root path. Do you want to try another path?'
);
}
} while (! $configExists && $keepAsking);
// If after some retries the user has chosen not to test another path, return
if (! $configExists) {
return $config;
}
// Read the config file
$config->exchangeArray(include $configFile);
return $config;
}
private function execPhp(array $command, string $errorMessage, OutputInterface $output): bool
{
if ($this->processHelper === null) {
$this->processHelper = $this->getHelper('process');
}
if ($this->phpBinary === null) {
$this->phpBinary = $this->phpFinder->find(false) ?: 'php';
}
array_unshift($command, $this->phpBinary);
$this->io->write(
' <options=bold>[Running "' . implode(' ', $command) . '"]</> ',
false,
OutputInterface::VERBOSITY_VERBOSE
);
$process = $this->processHelper->run($output, $command);
if ($process->isSuccessful()) {
$this->io->writeln(' <info>Success!</info>');
return true;
}
if (! $this->io->isVerbose()) {
$this->io->error($errorMessage . ' Run this command with -vvv to see specific error info.');
}
return false;
}
}

View File

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config;
use Shlinkio\Shlink\Installer\Config\Plugin\ConfigCustomizerInterface;
use Zend\ServiceManager\AbstractPluginManager;
class ConfigCustomizerManager extends AbstractPluginManager implements ConfigCustomizerManagerInterface
{
protected $instanceOf = ConfigCustomizerInterface::class;
}

View File

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config;
use Psr\Container\ContainerInterface;
interface ConfigCustomizerManagerInterface extends ContainerInterface
{
}

View File

@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Shlinkio\Shlink\Installer\Exception\InvalidConfigOptionException;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_diff;
use function array_keys;
use function is_numeric;
use function sprintf;
class ApplicationConfigCustomizer implements ConfigCustomizerInterface
{
use StringUtilsTrait;
public const SECRET = 'SECRET';
public const DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM';
public const CHECK_VISITS_THRESHOLD = 'CHECK_VISITS_THRESHOLD';
public const VISITS_THRESHOLD = 'VISITS_THRESHOLD';
private const EXPECTED_KEYS = [
self::SECRET,
self::DISABLE_TRACK_PARAM,
self::CHECK_VISITS_THRESHOLD,
self::VISITS_THRESHOLD,
];
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig): void
{
$app = $appConfig->getApp();
$keysToAskFor = $appConfig->hasApp() ? array_diff(self::EXPECTED_KEYS, array_keys($app)) : self::EXPECTED_KEYS;
if (empty($keysToAskFor)) {
return;
}
$io->title('APPLICATION');
foreach ($keysToAskFor as $key) {
// Skip visits threshold when the user decided not to check visits on deletions
if ($key === self::VISITS_THRESHOLD && ! $app[self::CHECK_VISITS_THRESHOLD]) {
continue;
}
$app[$key] = $this->ask($io, $key);
}
$appConfig->setApp($app);
}
private function ask(SymfonyStyle $io, string $key)
{
switch ($key) {
case self::SECRET:
return $io->ask(
'Define a secret string that will be used to sign API tokens (leave empty to autogenerate one) '
. '<fg=red>[DEPRECATED. TO BE REMOVED]</>'
) ?: $this->generateRandomString(32);
case self::DISABLE_TRACK_PARAM:
return $io->ask(
'Provide a parameter name that you will be able to use to disable tracking on specific request to '
. 'short URLs (leave empty and this feature won\'t be enabled)'
);
case self::CHECK_VISITS_THRESHOLD:
return $io->confirm(
'Do you want to enable a safety check which will not allow short URLs to be deleted when they '
. 'have more than a specific amount of visits?'
);
case self::VISITS_THRESHOLD:
return $io->ask(
'What is the amount of visits from which the system will not allow short URLs to be deleted?',
15,
[$this, 'validateVisitsThreshold']
);
}
return '';
}
public function validateVisitsThreshold($value): int
{
if (! is_numeric($value) || $value < 1) {
throw new InvalidConfigOptionException(
sprintf('Provided value "%s" is invalid. Expected a number greater than 1', $value)
);
}
return (int) $value;
}
}

View File

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
interface ConfigCustomizerInterface
{
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig): void;
}

View File

@ -1,133 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Installer\Util\AskUtilsTrait;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use function array_diff;
use function array_keys;
use function Functional\contains;
class DatabaseConfigCustomizer implements ConfigCustomizerInterface
{
use AskUtilsTrait;
public const DRIVER = 'DRIVER';
public const NAME = 'NAME';
public const USER = 'USER';
public const PASSWORD = 'PASSWORD';
public const HOST = 'HOST';
public const PORT = 'PORT';
private const DRIVER_DEPENDANT_OPTIONS = [
self::DRIVER,
self::NAME,
self::USER,
self::PASSWORD,
self::HOST,
self::PORT,
];
private const EXPECTED_KEYS = self::DRIVER_DEPENDANT_OPTIONS; // Same now, but could change in the future
private const DATABASE_DRIVERS = [
'MySQL' => 'pdo_mysql',
'PostgreSQL' => 'pdo_pgsql',
'SQLite' => 'pdo_sqlite',
];
/** @var Filesystem */
private $filesystem;
public function __construct(Filesystem $filesystem)
{
$this->filesystem = $filesystem;
}
/**
* @throws IOException
*/
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig): void
{
$titlePrinted = false;
$db = $appConfig->getDatabase();
$doImport = $appConfig->hasDatabase();
$keysToAskFor = $doImport ? array_diff(self::EXPECTED_KEYS, array_keys($db)) : self::EXPECTED_KEYS;
// If the user selected to keep DB, try to import SQLite database
if ($doImport) {
$this->importSqliteDbFile($io, $appConfig);
}
if (empty($keysToAskFor)) {
return;
}
// If the driver is one of the params to ask for, ask for it first
if (contains($keysToAskFor, self::DRIVER)) {
$io->title('DATABASE');
$titlePrinted = true;
$db[self::DRIVER] = $this->ask($io, self::DRIVER);
$keysToAskFor = array_diff($keysToAskFor, [self::DRIVER]);
}
// If driver is SQLite, do not ask any driver-dependant option
if ($db[self::DRIVER] === self::DATABASE_DRIVERS['SQLite']) {
$keysToAskFor = array_diff($keysToAskFor, self::DRIVER_DEPENDANT_OPTIONS);
}
if (! $titlePrinted && ! empty($keysToAskFor)) {
$io->title('DATABASE');
}
foreach ($keysToAskFor as $key) {
$db[$key] = $this->ask($io, $key, $db);
}
$appConfig->setDatabase($db);
}
private function importSqliteDbFile(SymfonyStyle $io, CustomizableAppConfig $appConfig): void
{
if ($appConfig->getDatabase()[self::DRIVER] !== self::DATABASE_DRIVERS['SQLite']) {
return;
}
try {
$this->filesystem->copy(
$appConfig->getImportedInstallationPath() . '/' . CustomizableAppConfig::SQLITE_DB_PATH,
CustomizableAppConfig::SQLITE_DB_PATH
);
} catch (IOException $e) {
$io->error('It wasn\'t possible to import the SQLite database');
throw $e;
}
}
private function ask(SymfonyStyle $io, string $key, array $params = [])
{
switch ($key) {
case self::DRIVER:
$databases = array_keys(self::DATABASE_DRIVERS);
$dbType = $io->choice('Select database type', $databases, $databases[0]);
return self::DATABASE_DRIVERS[$dbType];
case self::NAME:
return $io->ask('Database name', 'shlink');
case self::USER:
return $this->askRequired($io, 'username', 'Database username');
case self::PASSWORD:
return $this->askRequired($io, 'password', 'Database password');
case self::HOST:
return $io->ask('Database host', 'localhost');
case self::PORT:
return $io->ask('Database port', $this->getDefaultDbPort($params[self::DRIVER]));
}
return '';
}
private function getDefaultDbPort(string $driver): string
{
return $driver === 'pdo_mysql' ? '3306' : '5432';
}
}

View File

@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_diff;
use function array_keys;
class LanguageConfigCustomizer implements ConfigCustomizerInterface
{
public const DEFAULT_LANG = 'DEFAULT';
private const EXPECTED_KEYS = [
self::DEFAULT_LANG,
];
private const SUPPORTED_LANGUAGES = ['en', 'es'];
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig): void
{
$lang = $appConfig->getLanguage();
$keysToAskFor = $appConfig->hasLanguage()
? array_diff(self::EXPECTED_KEYS, array_keys($lang))
: self::EXPECTED_KEYS;
if (empty($keysToAskFor)) {
return;
}
$io->title('LANGUAGE');
foreach ($keysToAskFor as $key) {
$lang[$key] = $this->ask($io, $key);
}
$appConfig->setLanguage($lang);
}
private function ask(SymfonyStyle $io, string $key)
{
switch ($key) {
case self::DEFAULT_LANG:
return $this->chooseLanguage($io, 'Select default language for the application error pages');
}
return '';
}
private function chooseLanguage(SymfonyStyle $io, string $message): string
{
return $io->choice($message, self::SUPPORTED_LANGUAGES, self::SUPPORTED_LANGUAGES[0]);
}
}

View File

@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Config\Plugin;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Shlinkio\Shlink\Installer\Util\AskUtilsTrait;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_diff;
use function array_keys;
use function count;
use function Functional\contains;
class UrlShortenerConfigCustomizer implements ConfigCustomizerInterface
{
use AskUtilsTrait;
public const SCHEMA = 'SCHEMA';
public const HOSTNAME = 'HOSTNAME';
public const CHARS = 'CHARS';
public const VALIDATE_URL = 'VALIDATE_URL';
public const ENABLE_NOT_FOUND_REDIRECTION = 'ENABLE_NOT_FOUND_REDIRECTION';
public const NOT_FOUND_REDIRECT_TO = 'NOT_FOUND_REDIRECT_TO';
private const EXPECTED_KEYS = [
self::SCHEMA,
self::HOSTNAME,
self::CHARS,
self::VALIDATE_URL,
self::ENABLE_NOT_FOUND_REDIRECTION,
self::NOT_FOUND_REDIRECT_TO,
];
/** @var callable */
private $randomCharsGenerator;
public function __construct(callable $randomCharsGenerator)
{
$this->randomCharsGenerator = $randomCharsGenerator;
}
public function process(SymfonyStyle $io, CustomizableAppConfig $appConfig): void
{
$urlShortener = $appConfig->getUrlShortener();
$doImport = $appConfig->hasUrlShortener();
$keysToAskFor = $doImport ? array_diff(self::EXPECTED_KEYS, array_keys($urlShortener)) : self::EXPECTED_KEYS;
if (empty($keysToAskFor)) {
return;
}
// Print title if there are keys other than "chars"
$onlyKeyIsChars = count($keysToAskFor) === 1 && contains($keysToAskFor, self::CHARS);
if (! $onlyKeyIsChars) {
$io->title('URL SHORTENER');
}
foreach ($keysToAskFor as $key) {
// Skip not found redirect URL when the user decided not to redirect
if ($key === self::NOT_FOUND_REDIRECT_TO && ! $urlShortener[self::ENABLE_NOT_FOUND_REDIRECTION]) {
continue;
}
$urlShortener[$key] = $this->ask($io, $key);
}
$appConfig->setUrlShortener($urlShortener);
}
private function ask(SymfonyStyle $io, string $key)
{
switch ($key) {
case self::SCHEMA:
return $io->choice(
'Select schema for generated short URLs',
['http', 'https'],
'http'
);
case self::HOSTNAME:
return $this->askRequired($io, 'hostname', 'Hostname for generated URLs');
case self::CHARS:
// This won't actually ask anything, just generate the chars. Asking for this was confusing for users
return ($this->randomCharsGenerator)();
case self::VALIDATE_URL:
return $io->confirm('Do you want to validate long urls by 200 HTTP status code on response');
case self::ENABLE_NOT_FOUND_REDIRECTION:
return $io->confirm(
'Do you want to enable a redirection to a custom URL when a user hits an invalid short URL? ' .
'(If not enabled, the user will see a default "404 not found" page)',
false
);
case self::NOT_FOUND_REDIRECT_TO:
return $this->askRequired(
$io,
'redirect URL',
'Custom URL to redirect to when a user hits an invalid short URL'
);
}
return '';
}
}

View File

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer;
use Zend\Config\Factory;
use Zend\Stdlib\Glob;
class ConfigProvider
{
public function __invoke()
{
return Factory::fromFiles(Glob::glob(__DIR__ . '/../config/{,*.}config.php', Glob::GLOB_BRACE));
}
}

View File

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Exception;
use Throwable;
interface ExceptionInterface extends Throwable
{
}

View File

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Exception;
use RuntimeException;
class InvalidConfigOptionException extends RuntimeException implements ExceptionInterface
{
}

View File

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Exception;
use RuntimeException;
use function sprintf;
class MissingRequiredOptionException extends RuntimeException implements ExceptionInterface
{
public static function fromOption(string $optionName): self
{
return new self(sprintf('The "%s" is required and can\'t be empty', $optionName));
}
}

View File

@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Factory;
use Interop\Container\ContainerInterface;
use Interop\Container\Exception\ContainerException;
use Shlinkio\Shlink\Installer\Command\InstallCommand;
use Shlinkio\Shlink\Installer\Config\ConfigCustomizerManager;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Filesystem\Filesystem;
use Zend\Config\Writer\PhpArray;
use Zend\ServiceManager\Exception\ServiceNotCreatedException;
use Zend\ServiceManager\Exception\ServiceNotFoundException;
use Zend\ServiceManager\Factory\FactoryInterface;
class InstallApplicationFactory implements FactoryInterface
{
/**
* Create an object
*
* @param ContainerInterface $container
* @param string $requestedName
* @param null|array $options
* @return object
* @throws LogicException
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$isUpdate = $options !== null && isset($options['isUpdate']) ? (bool) $options['isUpdate'] : false;
$app = new Application();
$command = new InstallCommand(
new PhpArray(),
$container->get(Filesystem::class),
new ConfigCustomizerManager($container, $container->get('config')['config_customizer_plugins']),
$isUpdate
);
$app->add($command);
$app->setDefaultCommand($command->getName(), true);
return $app;
}
}

View File

@ -1,218 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Model;
use PDO;
use Shlinkio\Shlink\Common\Collection\PathCollection;
use Shlinkio\Shlink\Installer\Config\Plugin\ApplicationConfigCustomizer;
use Shlinkio\Shlink\Installer\Config\Plugin\DatabaseConfigCustomizer;
use Shlinkio\Shlink\Installer\Config\Plugin\LanguageConfigCustomizer;
use Shlinkio\Shlink\Installer\Config\Plugin\UrlShortenerConfigCustomizer;
use Zend\Stdlib\ArraySerializableInterface;
final class CustomizableAppConfig implements ArraySerializableInterface
{
public const SQLITE_DB_PATH = 'data/database.sqlite';
/** @var array */
private $database = [];
/** @var array */
private $urlShortener = [];
/** @var array */
private $language = [];
/** @var array */
private $app = [];
/** @var string|null */
private $importedInstallationPath;
public function getDatabase(): array
{
return $this->database;
}
public function setDatabase(array $database): self
{
$this->database = $database;
return $this;
}
public function hasDatabase(): bool
{
return ! empty($this->database);
}
public function getUrlShortener(): array
{
return $this->urlShortener;
}
public function setUrlShortener(array $urlShortener): self
{
$this->urlShortener = $urlShortener;
return $this;
}
public function hasUrlShortener(): bool
{
return ! empty($this->urlShortener);
}
public function getLanguage(): array
{
return $this->language;
}
public function setLanguage(array $language): self
{
$this->language = $language;
return $this;
}
public function hasLanguage(): bool
{
return ! empty($this->language);
}
public function getApp(): array
{
return $this->app;
}
public function setApp(array $app): self
{
$this->app = $app;
return $this;
}
public function hasApp(): bool
{
return ! empty($this->app);
}
public function getImportedInstallationPath(): ?string
{
return $this->importedInstallationPath;
}
public function setImportedInstallationPath(string $importedInstallationPath): self
{
$this->importedInstallationPath = $importedInstallationPath;
return $this;
}
public function hasImportedInstallationPath(): bool
{
return $this->importedInstallationPath !== null;
}
public function exchangeArray(array $array): void
{
$pathCollection = new PathCollection($array);
$this->setApp($this->mapExistingPathsToKeys([
ApplicationConfigCustomizer::SECRET => ['app_options', 'secret_key'],
ApplicationConfigCustomizer::DISABLE_TRACK_PARAM => ['app_options', 'disable_track_param'],
ApplicationConfigCustomizer::CHECK_VISITS_THRESHOLD => ['delete_short_urls', 'check_visits_threshold'],
ApplicationConfigCustomizer::VISITS_THRESHOLD => ['delete_short_urls', 'visits_threshold'],
], $pathCollection));
$this->setDatabase($this->mapExistingPathsToKeys([
DatabaseConfigCustomizer::DRIVER => ['entity_manager', 'connection', 'driver'],
DatabaseConfigCustomizer::USER => ['entity_manager', 'connection', 'user'],
DatabaseConfigCustomizer::PASSWORD => ['entity_manager', 'connection', 'password'],
DatabaseConfigCustomizer::NAME => ['entity_manager', 'connection', 'dbname'],
DatabaseConfigCustomizer::HOST => ['entity_manager', 'connection', 'host'],
DatabaseConfigCustomizer::PORT => ['entity_manager', 'connection', 'port'],
], $pathCollection));
$this->setLanguage($this->mapExistingPathsToKeys([
LanguageConfigCustomizer::DEFAULT_LANG => ['translator', 'locale'],
], $pathCollection));
$this->setUrlShortener($this->mapExistingPathsToKeys([
UrlShortenerConfigCustomizer::SCHEMA => ['url_shortener', 'domain', 'schema'],
UrlShortenerConfigCustomizer::HOSTNAME => ['url_shortener', 'domain', 'hostname'],
UrlShortenerConfigCustomizer::CHARS => ['url_shortener', 'shortcode_chars'],
UrlShortenerConfigCustomizer::VALIDATE_URL => ['url_shortener', 'validate_url'],
UrlShortenerConfigCustomizer::ENABLE_NOT_FOUND_REDIRECTION => [
'url_shortener',
'not_found_short_url',
'enable_redirection',
],
UrlShortenerConfigCustomizer::NOT_FOUND_REDIRECT_TO => [
'url_shortener',
'not_found_short_url',
'redirect_to',
],
], $pathCollection));
}
private function mapExistingPathsToKeys(array $map, PathCollection $pathCollection): array
{
$result = [];
foreach ($map as $key => $path) {
if ($pathCollection->pathExists($path)) {
$result[$key] = $pathCollection->getValueInPath($path);
}
}
return $result;
}
public function getArrayCopy(): array
{
$dbDriver = $this->database[DatabaseConfigCustomizer::DRIVER] ?? '';
$config = [
'app_options' => [
'secret_key' => $this->app[ApplicationConfigCustomizer::SECRET] ?? '',
'disable_track_param' => $this->app[ApplicationConfigCustomizer::DISABLE_TRACK_PARAM] ?? null,
],
'delete_short_urls' => [
'check_visits_threshold' => $this->app[ApplicationConfigCustomizer::CHECK_VISITS_THRESHOLD] ?? true,
'visits_threshold' => $this->app[ApplicationConfigCustomizer::VISITS_THRESHOLD] ?? 15,
],
'entity_manager' => [
'connection' => [
'driver' => $dbDriver,
],
],
'translator' => [
'locale' => $this->language[LanguageConfigCustomizer::DEFAULT_LANG] ?? 'en',
],
'url_shortener' => [
'domain' => [
'schema' => $this->urlShortener[UrlShortenerConfigCustomizer::SCHEMA] ?? 'http',
'hostname' => $this->urlShortener[UrlShortenerConfigCustomizer::HOSTNAME] ?? '',
],
'shortcode_chars' => $this->urlShortener[UrlShortenerConfigCustomizer::CHARS] ?? '',
'validate_url' => $this->urlShortener[UrlShortenerConfigCustomizer::VALIDATE_URL] ?? true,
'not_found_short_url' => [
'enable_redirection' =>
$this->urlShortener[UrlShortenerConfigCustomizer::ENABLE_NOT_FOUND_REDIRECTION] ?? false,
'redirect_to' => $this->urlShortener[UrlShortenerConfigCustomizer::NOT_FOUND_REDIRECT_TO] ?? null,
],
],
];
// Build dynamic database config based on selected driver
if ($dbDriver === 'pdo_sqlite') {
$config['entity_manager']['connection']['path'] = self::SQLITE_DB_PATH;
} else {
$config['entity_manager']['connection']['user'] = $this->database[DatabaseConfigCustomizer::USER] ?? '';
$config['entity_manager']['connection']['password'] =
$this->database[DatabaseConfigCustomizer::PASSWORD] ?? '';
$config['entity_manager']['connection']['dbname'] = $this->database[DatabaseConfigCustomizer::NAME] ?? '';
$config['entity_manager']['connection']['host'] = $this->database[DatabaseConfigCustomizer::HOST] ?? '';
$config['entity_manager']['connection']['port'] = $this->database[DatabaseConfigCustomizer::PORT] ?? '';
if ($dbDriver === 'pdo_mysql') {
$config['entity_manager']['connection']['driverOptions'] = [
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
];
}
}
return $config;
}
}

View File

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Installer\Util;
use Shlinkio\Shlink\Installer\Exception\MissingRequiredOptionException;
use Symfony\Component\Console\Style\SymfonyStyle;
trait AskUtilsTrait
{
/**
* @return mixed
*/
private function askRequired(SymfonyStyle $io, string $optionName, string $question)
{
return $io->ask($question, null, function ($value) use ($optionName) {
if (empty($value)) {
throw MissingRequiredOptionException::fromOption($optionName);
};
return $value;
});
}
}

View File

@ -1,2 +0,0 @@
<?php
return [];

View File

@ -1,140 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Installer\Command;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use ReflectionObject;
use Shlinkio\Shlink\Installer\Command\InstallCommand;
use Shlinkio\Shlink\Installer\Config\ConfigCustomizerManagerInterface;
use Shlinkio\Shlink\Installer\Config\Plugin\ConfigCustomizerInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
use Zend\Config\Writer\WriterInterface;
class InstallCommandTest extends TestCase
{
/** @var InstallCommand */
private $command;
/** @var CommandTester */
private $commandTester;
/** @var ObjectProphecy */
private $configWriter;
/** @var ObjectProphecy */
private $filesystem;
public function setUp()
{
$processMock = $this->prophesize(Process::class);
$processMock->isSuccessful()->willReturn(true);
$processHelper = $this->prophesize(ProcessHelper::class);
$processHelper->getName()->willReturn('process');
$processHelper->setHelperSet(Argument::any())->willReturn(null);
$processHelper->run(Argument::cetera())->willReturn($processMock->reveal());
$this->filesystem = $this->prophesize(Filesystem::class);
$this->filesystem->exists(Argument::cetera())->willReturn(false);
$this->configWriter = $this->prophesize(WriterInterface::class);
$configCustomizer = $this->prophesize(ConfigCustomizerInterface::class);
$configCustomizers = $this->prophesize(ConfigCustomizerManagerInterface::class);
$configCustomizers->get(Argument::cetera())->willReturn($configCustomizer->reveal());
$finder = $this->prophesize(PhpExecutableFinder::class);
$finder->find(false)->willReturn('php');
$app = new Application();
$helperSet = $app->getHelperSet();
$helperSet->set($processHelper->reveal());
$app->setHelperSet($helperSet);
$this->command = new InstallCommand(
$this->configWriter->reveal(),
$this->filesystem->reveal(),
$configCustomizers->reveal(),
false,
$finder->reveal()
);
$app->add($this->command);
$this->commandTester = new CommandTester($this->command);
}
/**
* @test
*/
public function generatedConfigIsProperlyPersisted()
{
$this->configWriter->toFile(Argument::any(), Argument::type('array'), false)->shouldBeCalledOnce();
$this->commandTester->execute([]);
}
/**
* @test
*/
public function cachedConfigIsDeletedIfExists()
{
/** @var MethodProphecy $appConfigExists */
$appConfigExists = $this->filesystem->exists('data/cache/app_config.php')->willReturn(true);
/** @var MethodProphecy $appConfigRemove */
$appConfigRemove = $this->filesystem->remove('data/cache/app_config.php')->willReturn(null);
$this->commandTester->execute([]);
$appConfigExists->shouldHaveBeenCalledOnce();
$appConfigRemove->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function exceptionWhileDeletingCachedConfigCancelsProcess()
{
/** @var MethodProphecy $appConfigExists */
$appConfigExists = $this->filesystem->exists('data/cache/app_config.php')->willReturn(true);
/** @var MethodProphecy $appConfigRemove */
$appConfigRemove = $this->filesystem->remove('data/cache/app_config.php')->willThrow(IOException::class);
/** @var MethodProphecy $configToFile */
$configToFile = $this->configWriter->toFile(Argument::cetera())->willReturn(true);
$this->commandTester->execute([]);
$appConfigExists->shouldHaveBeenCalledOnce();
$appConfigRemove->shouldHaveBeenCalledOnce();
$configToFile->shouldNotHaveBeenCalled();
}
/**
* @test
*/
public function whenCommandIsUpdatePreviousConfigCanBeImported()
{
$ref = new ReflectionObject($this->command);
$prop = $ref->getProperty('isUpdate');
$prop->setAccessible(true);
$prop->setValue($this->command, true);
/** @var MethodProphecy $importedConfigExists */
$importedConfigExists = $this->filesystem->exists(
__DIR__ . '/../../test-resources/' . InstallCommand::GENERATED_CONFIG_PATH
)->willReturn(true);
$this->commandTester->setInputs([
'',
'/foo/bar/wrong_previous_shlink',
'',
__DIR__ . '/../../test-resources',
]);
$this->commandTester->execute([]);
$importedConfigExists->shouldHaveBeenCalled();
}
}

View File

@ -1,171 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Installer\Config\Plugin;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Installer\Config\Plugin\ApplicationConfigCustomizer;
use Shlinkio\Shlink\Installer\Exception\InvalidConfigOptionException;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
use function array_shift;
use function strpos;
class ApplicationConfigCustomizerTest extends TestCase
{
/** @var ApplicationConfigCustomizer */
private $plugin;
/** @var ObjectProphecy */
private $io;
public function setUp()
{
$this->io = $this->prophesize(SymfonyStyle::class);
$this->io->title(Argument::any())->willReturn(null);
$this->plugin = new ApplicationConfigCustomizer();
}
/**
* @test
*/
public function configIsRequestedToTheUser()
{
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$config = new CustomizableAppConfig();
$this->plugin->process($this->io->reveal(), $config);
$this->assertTrue($config->hasApp());
$this->assertEquals([
'SECRET' => 'asked',
'DISABLE_TRACK_PARAM' => 'asked',
'CHECK_VISITS_THRESHOLD' => false,
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(2);
$confirm->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function visitsThresholdIsRequestedIfCheckIsEnabled()
{
$ask = $this->io->ask(Argument::cetera())->will(function (array $args) {
$message = array_shift($args);
return strpos($message, 'What is the amount of visits') === 0 ? 20 : 'asked';
});
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
$this->plugin->process($this->io->reveal(), $config);
$this->assertTrue($config->hasApp());
$this->assertEquals([
'SECRET' => 'asked',
'DISABLE_TRACK_PARAM' => 'asked',
'CHECK_VISITS_THRESHOLD' => true,
'VISITS_THRESHOLD' => 20,
], $config->getApp());
$ask->shouldHaveBeenCalledTimes(3);
$confirm->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function onlyMissingOptionsAreAsked()
{
$ask = $this->io->ask(Argument::cetera())->willReturn('disable_param');
$config = new CustomizableAppConfig();
$config->setApp([
'SECRET' => 'foo',
'CHECK_VISITS_THRESHOLD' => true,
'VISITS_THRESHOLD' => 20,
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'SECRET' => 'foo',
'DISABLE_TRACK_PARAM' => 'disable_param',
'CHECK_VISITS_THRESHOLD' => true,
'VISITS_THRESHOLD' => 20,
], $config->getApp());
$ask->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function noQuestionsAskedIfImportedConfigContainsEverything()
{
$ask = $this->io->ask(Argument::cetera())->willReturn('the_new_secret');
$config = new CustomizableAppConfig();
$config->setApp([
'SECRET' => 'foo',
'DISABLE_TRACK_PARAM' => 'the_new_secret',
'CHECK_VISITS_THRESHOLD' => true,
'VISITS_THRESHOLD' => 20,
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'SECRET' => 'foo',
'DISABLE_TRACK_PARAM' => 'the_new_secret',
'CHECK_VISITS_THRESHOLD' => true,
'VISITS_THRESHOLD' => 20,
], $config->getApp());
$ask->shouldNotHaveBeenCalled();
}
/**
* @test
* @dataProvider provideInvalidValues
* @param mixed $value
*/
public function validateVisitsThresholdThrowsExceptionWhenProvidedValueIsInvalid($value)
{
$this->expectException(InvalidConfigOptionException::class);
$this->plugin->validateVisitsThreshold($value);
}
public function provideInvalidValues(): array
{
return [
'string' => ['foo'],
'empty string' => [''],
'negative number' => [-5],
'negative number as string' => ['-5'],
'zero' => [0],
'zero as string' => ['0'],
];
}
/**
* @test
* @dataProvider provideValidValues
* @param mixed $value
*/
public function validateVisitsThresholdCastsToIntWhenProvidedValueIsValid($value, int $expected)
{
$this->assertEquals($expected, $this->plugin->validateVisitsThreshold($value));
}
public function provideValidValues(): array
{
return [
'positive as string' => ['20', 20],
'positive as integer' => [5, 5],
'one as string' => ['1', 1],
'one as integer' => [1, 1],
];
}
}

View File

@ -1,136 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Installer\Config\Plugin;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Installer\Config\Plugin\DatabaseConfigCustomizer;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
class DatabaseConfigCustomizerTest extends TestCase
{
/** @var DatabaseConfigCustomizer */
private $plugin;
/** @var ObjectProphecy */
private $io;
/** @var ObjectProphecy */
private $filesystem;
public function setUp()
{
$this->io = $this->prophesize(SymfonyStyle::class);
$this->io->title(Argument::any())->willReturn(null);
$this->filesystem = $this->prophesize(Filesystem::class);
$this->plugin = new DatabaseConfigCustomizer($this->filesystem->reveal());
}
/**
* @test
*/
public function configIsRequestedToTheUser()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('MySQL');
$ask = $this->io->ask(Argument::cetera())->willReturn('param');
$config = new CustomizableAppConfig();
$this->plugin->process($this->io->reveal(), $config);
$this->assertTrue($config->hasDatabase());
$this->assertEquals([
'DRIVER' => 'pdo_mysql',
'NAME' => 'param',
'USER' => 'param',
'PASSWORD' => 'param',
'HOST' => 'param',
'PORT' => 'param',
], $config->getDatabase());
$choice->shouldHaveBeenCalledOnce();
$ask->shouldHaveBeenCalledTimes(5);
}
/**
* @test
*/
public function onlyMissingOptionsAreAsked()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('MySQL');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$config = new CustomizableAppConfig();
$config->setDatabase([
'DRIVER' => 'pdo_pgsql',
'NAME' => 'foo',
'PASSWORD' => 'foo',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DRIVER' => 'pdo_pgsql',
'NAME' => 'foo',
'USER' => 'asked',
'PASSWORD' => 'foo',
'HOST' => 'asked',
'PORT' => 'asked',
], $config->getDatabase());
$choice->shouldNotHaveBeenCalled();
$ask->shouldHaveBeenCalledTimes(3);
}
/**
* @test
*/
public function noQuestionsAskedIfImportedConfigContainsEverything()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('MySQL');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$config = new CustomizableAppConfig();
$config->setDatabase([
'DRIVER' => 'pdo_pgsql',
'NAME' => 'foo',
'USER' => 'foo',
'PASSWORD' => 'foo',
'HOST' => 'foo',
'PORT' => 'foo',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DRIVER' => 'pdo_pgsql',
'NAME' => 'foo',
'USER' => 'foo',
'PASSWORD' => 'foo',
'HOST' => 'foo',
'PORT' => 'foo',
], $config->getDatabase());
$choice->shouldNotHaveBeenCalled();
$ask->shouldNotHaveBeenCalled();
}
/**
* @test
*/
public function sqliteDatabaseIsImportedWhenRequested()
{
$copy = $this->filesystem->copy(Argument::cetera())->willReturn(null);
$config = new CustomizableAppConfig();
$config->setDatabase([
'DRIVER' => 'pdo_sqlite',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DRIVER' => 'pdo_sqlite',
], $config->getDatabase());
$copy->shouldHaveBeenCalledOnce();
}
}

View File

@ -1,79 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Installer\Config\Plugin;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Installer\Config\Plugin\LanguageConfigCustomizer;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
class LanguageConfigCustomizerTest extends TestCase
{
/** @var LanguageConfigCustomizer */
private $plugin;
/** @var ObjectProphecy */
private $io;
public function setUp()
{
$this->io = $this->prophesize(SymfonyStyle::class);
$this->io->title(Argument::any())->willReturn(null);
$this->plugin = new LanguageConfigCustomizer();
}
/**
* @test
*/
public function configIsRequestedToTheUser()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('en');
$config = new CustomizableAppConfig();
$this->plugin->process($this->io->reveal(), $config);
$this->assertTrue($config->hasLanguage());
$this->assertEquals([
'DEFAULT' => 'en',
], $config->getLanguage());
$choice->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function onlyMissingOptionsAreAsked()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('es');
$config = new CustomizableAppConfig();
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DEFAULT' => 'es',
], $config->getLanguage());
$choice->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function noQuestionsAskedIfImportedConfigContainsEverything()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('en');
$config = new CustomizableAppConfig();
$config->setLanguage([
'DEFAULT' => 'es',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'DEFAULT' => 'es',
], $config->getLanguage());
$choice->shouldNotHaveBeenCalled();
}
}

View File

@ -1,148 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Installer\Config\Plugin;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Installer\Config\Plugin\UrlShortenerConfigCustomizer;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
use Symfony\Component\Console\Style\SymfonyStyle;
class UrlShortenerConfigCustomizerTest extends TestCase
{
/** @var UrlShortenerConfigCustomizer */
private $plugin;
/** @var ObjectProphecy */
private $io;
public function setUp()
{
$this->io = $this->prophesize(SymfonyStyle::class);
$this->io->title(Argument::any())->willReturn(null);
$this->plugin = new UrlShortenerConfigCustomizer(function () {
return 'the_chars';
});
}
/**
* @test
*/
public function configIsRequestedToTheUser()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('chosen');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(true);
$config = new CustomizableAppConfig();
$this->plugin->process($this->io->reveal(), $config);
$this->assertTrue($config->hasUrlShortener());
$this->assertEquals([
'SCHEMA' => 'chosen',
'HOSTNAME' => 'asked',
'CHARS' => 'the_chars',
'VALIDATE_URL' => true,
'ENABLE_NOT_FOUND_REDIRECTION' => true,
'NOT_FOUND_REDIRECT_TO' => 'asked',
], $config->getUrlShortener());
$ask->shouldHaveBeenCalledTimes(2);
$choice->shouldHaveBeenCalledOnce();
$confirm->shouldHaveBeenCalledTimes(2);
}
/**
* @test
*/
public function onlyMissingOptionsAreAsked()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('chosen');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$config = new CustomizableAppConfig();
$config->setUrlShortener([
'SCHEMA' => 'foo',
'ENABLE_NOT_FOUND_REDIRECTION' => true,
'NOT_FOUND_REDIRECT_TO' => 'foo',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'SCHEMA' => 'foo',
'HOSTNAME' => 'asked',
'CHARS' => 'the_chars',
'VALIDATE_URL' => false,
'ENABLE_NOT_FOUND_REDIRECTION' => true,
'NOT_FOUND_REDIRECT_TO' => 'foo',
], $config->getUrlShortener());
$choice->shouldNotHaveBeenCalled();
$ask->shouldHaveBeenCalledOnce();
$confirm->shouldHaveBeenCalledOnce();
}
/**
* @test
*/
public function noQuestionsAskedIfImportedConfigContainsEverything()
{
$choice = $this->io->choice(Argument::cetera())->willReturn('chosen');
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$config = new CustomizableAppConfig();
$config->setUrlShortener([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
'VALIDATE_URL' => true,
'ENABLE_NOT_FOUND_REDIRECTION' => true,
'NOT_FOUND_REDIRECT_TO' => 'foo',
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertEquals([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
'VALIDATE_URL' => true,
'ENABLE_NOT_FOUND_REDIRECTION' => true,
'NOT_FOUND_REDIRECT_TO' => 'foo',
], $config->getUrlShortener());
$choice->shouldNotHaveBeenCalled();
$ask->shouldNotHaveBeenCalled();
$confirm->shouldNotHaveBeenCalled();
}
/**
* @test
*/
public function redirectUrlOptionIsNotAskedIfAnswerToPreviousQuestionIsNo()
{
$ask = $this->io->ask(Argument::cetera())->willReturn('asked');
$confirm = $this->io->confirm(Argument::cetera())->willReturn(false);
$config = new CustomizableAppConfig();
$config->setUrlShortener([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
'VALIDATE_URL' => true,
]);
$this->plugin->process($this->io->reveal(), $config);
$this->assertTrue($config->hasUrlShortener());
$this->assertEquals([
'SCHEMA' => 'foo',
'HOSTNAME' => 'foo',
'CHARS' => 'foo',
'VALIDATE_URL' => true,
'ENABLE_NOT_FOUND_REDIRECTION' => false,
], $config->getUrlShortener());
$ask->shouldNotHaveBeenCalled();
$confirm->shouldHaveBeenCalledOnce();
}
}

View File

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Installer;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Installer\ConfigProvider;
class ConfigProviderTest extends TestCase
{
/** @var ConfigProvider */
private $configProvider;
public function setUp()
{
$this->configProvider = new ConfigProvider();
}
/**
* @test
*/
public function configIsReturned()
{
$config = $this->configProvider->__invoke();
$this->assertEmpty($config);
}
}

View File

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Installer;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Installer\Config\Plugin\ApplicationConfigCustomizer;
use Shlinkio\Shlink\Installer\Config\Plugin\LanguageConfigCustomizer;
use Shlinkio\Shlink\Installer\Model\CustomizableAppConfig;
class CustomizableAppConfigTest extends TestCase
{
/**
* @test
*/
public function exchangeArrayIgnoresAnyNonProvidedKey()
{
$config = new CustomizableAppConfig();
$config->exchangeArray([
'app_options' => [
'disable_track_param' => null,
],
'translator' => [
'locale' => 'es',
],
]);
$this->assertFalse($config->hasDatabase());
$this->assertFalse($config->hasUrlShortener());
$this->assertTrue($config->hasApp());
$this->assertTrue($config->hasLanguage());
$this->assertEquals([
ApplicationConfigCustomizer::DISABLE_TRACK_PARAM => null,
], $config->getApp());
$this->assertEquals([
LanguageConfigCustomizer::DEFAULT_LANG => 'es',
], $config->getLanguage());
}
}

View File

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Installer\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Installer\Exception\MissingRequiredOptionException;
class MissingRequiredOptionExceptionTest extends TestCase
{
/**
* @test
*/
public function fromOptionsGeneratesExpectedMessage()
{
$e = MissingRequiredOptionException::fromOption('foo');
$this->assertEquals('The "foo" is required and can\'t be empty', $e->getMessage());
}
}

View File

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Installer\Factory;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Installer\Factory\InstallApplicationFactory;
use Symfony\Component\Console\Application;
use Symfony\Component\Filesystem\Filesystem;
use Zend\ServiceManager\ServiceManager;
class InstallApplicationFactoryTest extends TestCase
{
/** @var InstallApplicationFactory */
private $factory;
public function setUp(): void
{
$this->factory = new InstallApplicationFactory();
}
/**
* @test
*/
public function serviceIsCreated(): void
{
$instance = ($this->factory)(new ServiceManager(['services' => [
Filesystem::class => $this->prophesize(Filesystem::class)->reveal(),
'config' => ['config_customizer_plugins' => []],
]]), '');
$this->assertInstanceOf(Application::class, $instance);
}
}

View File

@ -1,9 +1,10 @@
<?xml version="1.0"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/6.5/phpunit.xsd"
bootstrap="./vendor/autoload.php"
colors="true">
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/6.5/phpunit.xsd"
bootstrap="./vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Common">
<directory>./module/Common/test</directory>