diff --git a/CHANGELOG.md b/CHANGELOG.md
index c06cec1..fca165d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,12 +4,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
-## [Unreleased]
+## [2.2.0] - 2021-02-06
### Added
* [#21](https://github.com/shlinkio/shlink-importer/issues/21) Added support to import URL `title` prop.
+* [#5](https://github.com/shlinkio/shlink-importer/issues/5) Added support to import from a standard CSV file.
### Changed
* Migrated build to Github Actions.
+* [#23](https://github.com/shlinkio/shlink-importer/issues/23) Increased required MSI to 75%.
### Deprecated
* *Nothing*
diff --git a/README.md b/README.md
index be9f328..0569e75 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,15 @@ This module can be installed using composer:
## Supported import sources
-* Bit.ly
+#### Bit.ly
+
+It imports using the API v4. The only required param is an [access token](https://bitly.is/accesstoken).
+
+#### Standard CSV
+
+It parses a CSV file with the `Long URL` and `Short code` columns. It can optionally contain `Domain`, `Title` and `Tags`, being the latter a pipe-separated list of items (`foo|bar|baz`).
+
+Column names can have spaces and have any combination of upper and lowercase.
## Usage
diff --git a/composer.json b/composer.json
index 8e034d9..f194e8e 100644
--- a/composer.json
+++ b/composer.json
@@ -14,10 +14,11 @@
"require": {
"php": "^7.4 || ^8.0",
"ext-json": "*",
- "laminas/laminas-servicemanager": "^3.4",
- "lstrojny/functional-php": "^1.14",
+ "laminas/laminas-servicemanager": "^3.6.4",
+ "league/csv": "^9.6",
+ "lstrojny/functional-php": "^1.15",
"shlinkio/shlink-config": "^1.0",
- "symfony/console": "^5.1"
+ "symfony/console": "^5.2"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.2",
@@ -57,7 +58,7 @@
"test": "phpdbg -qrr vendor/bin/phpunit --order-by=random --testdox --colors=always",
"test:ci": "@test --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml",
"test:pretty": "@test --coverage-html build/coverage-html",
- "infect": "infection --threads=4 --min-msi=70 --log-verbosity=default --only-covered",
+ "infect": "infection --threads=4 --min-msi=75 --log-verbosity=default --only-covered",
"infect:ci": "@infect --coverage=build --skip-initial-tests",
"infect:show": "@infect --show-mutations",
"infect:show:ci": "@infect --show-mutations --coverage=build",
diff --git a/config/dependencies.config.php b/config/dependencies.config.php
index 27a5be9..56f1f87 100644
--- a/config/dependencies.config.php
+++ b/config/dependencies.config.php
@@ -33,27 +33,31 @@
'cli' => [
'importer_strategies' => [
'factories' => [
- Strategy\BitlyApiImporter::class => ConfigAbstractFactory::class,
+ Sources\Bitly\BitlyApiImporter::class => ConfigAbstractFactory::class,
+ Sources\Csv\CsvImporter::class => InvokableFactory::class,
],
'aliases' => [
- Strategy\ImportSources::BITLY => Strategy\BitlyApiImporter::class,
+ Sources\ImportSources::BITLY => Sources\Bitly\BitlyApiImporter::class,
+ Sources\ImportSources::CSV => Sources\Csv\CsvImporter::class,
],
],
'params_console_helpers' => [
'factories' => [
- Params\ConsoleHelper\BitlyApiParamsConsoleHelper::class => InvokableFactory::class,
+ Sources\Bitly\BitlyApiParamsConsoleHelper::class => InvokableFactory::class,
+ Sources\Csv\CsvParamsConsoleHelper::class => InvokableFactory::class,
],
'aliases' => [
- Strategy\ImportSources::BITLY => Params\ConsoleHelper\BitlyApiParamsConsoleHelper::class,
+ Sources\ImportSources::BITLY => Sources\Bitly\BitlyApiParamsConsoleHelper::class,
+ Sources\ImportSources::CSV => Sources\Csv\CsvParamsConsoleHelper::class,
],
],
],
ConfigAbstractFactory::class => [
- Strategy\BitlyApiImporter::class => [ClientInterface::class, RequestFactoryInterface::class],
+ Sources\Bitly\BitlyApiImporter::class => [ClientInterface::class, RequestFactoryInterface::class],
Command\ImportCommand::class => [
Strategy\ImporterStrategyManager::class,
Params\ConsoleHelper\ConsoleHelperManager::class,
diff --git a/src/Command/ImportCommand.php b/src/Command/ImportCommand.php
index d845c99..f61afe5 100644
--- a/src/Command/ImportCommand.php
+++ b/src/Command/ImportCommand.php
@@ -9,9 +9,9 @@
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
use Shlinkio\Shlink\Importer\Params\ConsoleHelper\ConsoleHelperManagerInterface;
use Shlinkio\Shlink\Importer\Params\ConsoleHelper\ParamsConsoleHelperInterface;
+use Shlinkio\Shlink\Importer\Sources\ImportSources;
use Shlinkio\Shlink\Importer\Strategy\ImporterStrategyInterface;
use Shlinkio\Shlink\Importer\Strategy\ImporterStrategyManagerInterface;
-use Shlinkio\Shlink\Importer\Strategy\ImportSources;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
@@ -47,8 +47,8 @@ protected function configure(): void
->setName(self::NAME)
->setDescription('Allows to import short URLs from third party sources')
->addArgument('source', InputArgument::REQUIRED, sprintf(
- 'The source from which you want to import. Supported sources: ["%s"]',
- implode('", "', ImportSources::getAll()),
+ 'The source from which you want to import. Supported sources: [%s]',
+ implode(', ', ImportSources::getAll()),
));
}
diff --git a/src/Exception/InvalidSourceException.php b/src/Exception/InvalidSourceException.php
index 724830e..d551dae 100644
--- a/src/Exception/InvalidSourceException.php
+++ b/src/Exception/InvalidSourceException.php
@@ -5,7 +5,7 @@
namespace Shlinkio\Shlink\Importer\Exception;
use InvalidArgumentException;
-use Shlinkio\Shlink\Importer\Strategy\ImportSources;
+use Shlinkio\Shlink\Importer\Sources\ImportSources;
use function implode;
use function sprintf;
diff --git a/src/Exception/BitlyApiException.php b/src/Sources/Bitly/BitlyApiException.php
similarity index 82%
rename from src/Exception/BitlyApiException.php
rename to src/Sources/Bitly/BitlyApiException.php
index 9e70df2..40d7f1b 100644
--- a/src/Exception/BitlyApiException.php
+++ b/src/Sources/Bitly/BitlyApiException.php
@@ -2,7 +2,9 @@
declare(strict_types=1);
-namespace Shlinkio\Shlink\Importer\Exception;
+namespace Shlinkio\Shlink\Importer\Sources\Bitly;
+
+use Shlinkio\Shlink\Importer\Exception\ImportException;
use function sprintf;
diff --git a/src/Strategy/BitlyApiImporter.php b/src/Sources/Bitly/BitlyApiImporter.php
similarity index 93%
rename from src/Strategy/BitlyApiImporter.php
rename to src/Sources/Bitly/BitlyApiImporter.php
index 388397c..0381c91 100644
--- a/src/Strategy/BitlyApiImporter.php
+++ b/src/Sources/Bitly/BitlyApiImporter.php
@@ -2,17 +2,19 @@
declare(strict_types=1);
-namespace Shlinkio\Shlink\Importer\Strategy;
+namespace Shlinkio\Shlink\Importer\Sources\Bitly;
use JsonException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
-use Shlinkio\Shlink\Importer\Exception\BitlyApiException;
use Shlinkio\Shlink\Importer\Exception\ImportException;
-use Shlinkio\Shlink\Importer\Model\BitlyApiProgressTracker;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
-use Shlinkio\Shlink\Importer\Params\BitlyApiParams;
+use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiException;
+use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiParams;
+use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiProgressTracker;
+use Shlinkio\Shlink\Importer\Sources\ImportSources;
+use Shlinkio\Shlink\Importer\Strategy\ImporterStrategyInterface;
use Shlinkio\Shlink\Importer\Util\DateHelpersTrait;
use Throwable;
diff --git a/src/Params/BitlyApiParams.php b/src/Sources/Bitly/BitlyApiParams.php
similarity index 95%
rename from src/Params/BitlyApiParams.php
rename to src/Sources/Bitly/BitlyApiParams.php
index 09f9098..e9fe892 100644
--- a/src/Params/BitlyApiParams.php
+++ b/src/Sources/Bitly/BitlyApiParams.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-namespace Shlinkio\Shlink\Importer\Params;
+namespace Shlinkio\Shlink\Importer\Sources\Bitly;
-class BitlyApiParams
+final class BitlyApiParams
{
private string $accessToken;
private bool $importTags;
diff --git a/src/Params/ConsoleHelper/BitlyApiParamsConsoleHelper.php b/src/Sources/Bitly/BitlyApiParamsConsoleHelper.php
similarity index 92%
rename from src/Params/ConsoleHelper/BitlyApiParamsConsoleHelper.php
rename to src/Sources/Bitly/BitlyApiParamsConsoleHelper.php
index e4047a7..e3a4f75 100644
--- a/src/Params/ConsoleHelper/BitlyApiParamsConsoleHelper.php
+++ b/src/Sources/Bitly/BitlyApiParamsConsoleHelper.php
@@ -2,8 +2,9 @@
declare(strict_types=1);
-namespace Shlinkio\Shlink\Importer\Params\ConsoleHelper;
+namespace Shlinkio\Shlink\Importer\Sources\Bitly;
+use Shlinkio\Shlink\Importer\Params\ConsoleHelper\ParamsConsoleHelperInterface;
use Symfony\Component\Console\Style\StyleInterface;
class BitlyApiParamsConsoleHelper implements ParamsConsoleHelperInterface
diff --git a/src/Model/BitlyApiProgressTracker.php b/src/Sources/Bitly/BitlyApiProgressTracker.php
similarity index 95%
rename from src/Model/BitlyApiProgressTracker.php
rename to src/Sources/Bitly/BitlyApiProgressTracker.php
index 9c84ef3..f370d2c 100644
--- a/src/Model/BitlyApiProgressTracker.php
+++ b/src/Sources/Bitly/BitlyApiProgressTracker.php
@@ -2,11 +2,11 @@
declare(strict_types=1);
-namespace Shlinkio\Shlink\Importer\Model;
+namespace Shlinkio\Shlink\Importer\Sources\Bitly;
use DateInterval;
use DateTimeImmutable;
-use Shlinkio\Shlink\Importer\Params\BitlyApiParams;
+use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiParams;
use Shlinkio\Shlink\Importer\Util\DateHelpersTrait;
use function base64_decode;
diff --git a/src/Sources/Csv/CsvImporter.php b/src/Sources/Csv/CsvImporter.php
new file mode 100644
index 0000000..a0e0c79
--- /dev/null
+++ b/src/Sources/Csv/CsvImporter.php
@@ -0,0 +1,89 @@
+date = $date;
+ }
+
+ /**
+ * @return ImportedShlinkUrl[]
+ * @throws ImportException
+ */
+ public function import(array $rawParams): iterable
+ {
+ $params = CsvParams::fromRawParams($rawParams);
+ $now = $this->date ?? new DateTimeImmutable();
+
+ $csvReader = Reader::createFromStream($params->stream())->setDelimiter($params->delimiter())
+ ->setHeaderOffset(0);
+
+ foreach ($csvReader as $record) {
+ $record = $this->remapRecordHeaders($record);
+
+ yield new ImportedShlinkUrl(
+ ImportSources::CSV,
+ $record['longurl'],
+ $this->parseTags($record),
+ $now,
+ $this->nonEmptyValueOrNull($record, 'domain'),
+ $record['shortcode'],
+ $this->nonEmptyValueOrNull($record, 'title'),
+ );
+ }
+ }
+
+ private function remapRecordHeaders(array $record): array
+ {
+ return reduce_left($record, static function ($value, string $index, array $c, array $acc) {
+ $normalizedKey = strtolower(str_replace(' ', '', $index));
+ $acc[$normalizedKey] = $value;
+
+ return $acc;
+ }, []);
+ }
+
+ private function nonEmptyValueOrNull(array $record, string $key): ?string
+ {
+ $value = $record[$key] ?? null;
+ if (empty($value)) {
+ return null;
+ }
+
+ $trimmedValue = trim($value);
+ if (empty($trimmedValue)) {
+ return null;
+ }
+
+ return $trimmedValue;
+ }
+
+ private function parseTags(array $record): array
+ {
+ return array_filter(explode(self::TAG_SEPARATOR, $this->nonEmptyValueOrNull($record, 'tags') ?? ''));
+ }
+}
diff --git a/src/Sources/Csv/CsvParams.php b/src/Sources/Csv/CsvParams.php
new file mode 100644
index 0000000..4a2a4ac
--- /dev/null
+++ b/src/Sources/Csv/CsvParams.php
@@ -0,0 +1,38 @@
+delimiter = $params['delimiter'] ?? '';
+ $instance->stream = $params['stream'] ?? '';
+
+ return $instance;
+ }
+
+ /**
+ * @return resource
+ */
+ public function stream()
+ {
+ return $this->stream;
+ }
+
+ public function delimiter(): string
+ {
+ return $this->delimiter;
+ }
+}
diff --git a/src/Sources/Csv/CsvParamsConsoleHelper.php b/src/Sources/Csv/CsvParamsConsoleHelper.php
new file mode 100644
index 0000000..0205092
--- /dev/null
+++ b/src/Sources/Csv/CsvParamsConsoleHelper.php
@@ -0,0 +1,42 @@
+ true,
+ 'stream' => $io->ask('What\'s the path for the CSV file you want to import', null, [$this, 'pathToStream']),
+ 'delimiter' => $io->choice('What\'s the delimiter used to separate values?', [
+ ',' => 'Comma',
+ ';' => 'Semicolon',
+ ], ','),
+ ];
+ }
+
+ /**
+ * @return resource
+ */
+ public function pathToStream(?string $value)
+ {
+ if (empty($value)) {
+ throw InvalidPathException::pathNotProvided();
+ }
+
+ $file = @fopen($value, 'rb');
+ if (! $file) {
+ throw InvalidPathException::pathIsNotFile($value);
+ }
+
+ return $file;
+ }
+}
diff --git a/src/Sources/Csv/InvalidPathException.php b/src/Sources/Csv/InvalidPathException.php
new file mode 100644
index 0000000..2d7e576
--- /dev/null
+++ b/src/Sources/Csv/InvalidPathException.php
@@ -0,0 +1,23 @@
+ [
diff --git a/test/Exception/InvalidSourceExceptionTest.php b/test/Exception/InvalidSourceExceptionTest.php
index 19e641e..c02fd46 100644
--- a/test/Exception/InvalidSourceExceptionTest.php
+++ b/test/Exception/InvalidSourceExceptionTest.php
@@ -21,8 +21,8 @@ public function expectedMessageIsGenerated(string $source, string $expectedMessa
public function provideInvalidSources(): iterable
{
- yield 'foo' => ['foo', 'Provided source "foo" is not valid. Expected one of ["bitly"]'];
- yield 'bar' => ['bar', 'Provided source "bar" is not valid. Expected one of ["bitly"]'];
- yield 'baz' => ['baz', 'Provided source "baz" is not valid. Expected one of ["bitly"]'];
+ yield 'foo' => ['foo', 'Provided source "foo" is not valid. Expected one of ["bitly", "csv"]'];
+ yield 'bar' => ['bar', 'Provided source "bar" is not valid. Expected one of ["bitly", "csv"]'];
+ yield 'baz' => ['baz', 'Provided source "baz" is not valid. Expected one of ["bitly", "csv"]'];
}
}
diff --git a/test/Exception/BitlyApiExceptionTest.php b/test/Sources/Bitly/BitlyApiExceptionTest.php
similarity index 86%
rename from test/Exception/BitlyApiExceptionTest.php
rename to test/Sources/Bitly/BitlyApiExceptionTest.php
index 6fe4d42..92c0481 100644
--- a/test/Exception/BitlyApiExceptionTest.php
+++ b/test/Sources/Bitly/BitlyApiExceptionTest.php
@@ -2,10 +2,10 @@
declare(strict_types=1);
-namespace ShlinkioTest\Shlink\Importer\Exception;
+namespace ShlinkioTest\Shlink\Importer\Sources\Bitly;
use PHPUnit\Framework\TestCase;
-use Shlinkio\Shlink\Importer\Exception\BitlyApiException;
+use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiException;
class BitlyApiExceptionTest extends TestCase
{
diff --git a/test/Strategy/BitlyApiImporterTest.php b/test/Sources/Bitly/BitlyApiImporterTest.php
similarity index 98%
rename from test/Strategy/BitlyApiImporterTest.php
rename to test/Sources/Bitly/BitlyApiImporterTest.php
index 51b5cfd..10d82e7 100644
--- a/test/Strategy/BitlyApiImporterTest.php
+++ b/test/Sources/Bitly/BitlyApiImporterTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace ShlinkioTest\Shlink\Importer\Strategy;
+namespace ShlinkioTest\Shlink\Importer\Sources\Bitly;
use DateTimeImmutable;
use DateTimeInterface;
@@ -15,10 +15,10 @@
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
-use Shlinkio\Shlink\Importer\Exception\BitlyApiException;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl as ShlinkUrl;
-use Shlinkio\Shlink\Importer\Strategy\BitlyApiImporter;
-use Shlinkio\Shlink\Importer\Strategy\ImportSources;
+use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiException;
+use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiImporter;
+use Shlinkio\Shlink\Importer\Sources\ImportSources;
use function explode;
use function json_encode;
diff --git a/test/Params/ConsoleHelper/BitlyApiParamsConsoleHelperTest.php b/test/Sources/Bitly/BitlyApiParamsConsoleHelperTest.php
similarity index 95%
rename from test/Params/ConsoleHelper/BitlyApiParamsConsoleHelperTest.php
rename to test/Sources/Bitly/BitlyApiParamsConsoleHelperTest.php
index 997e0c0..4c617cd 100644
--- a/test/Params/ConsoleHelper/BitlyApiParamsConsoleHelperTest.php
+++ b/test/Sources/Bitly/BitlyApiParamsConsoleHelperTest.php
@@ -2,13 +2,13 @@
declare(strict_types=1);
-namespace ShlinkioTest\Shlink\Importer\Params\ConsoleHelper;
+namespace ShlinkioTest\Shlink\Importer\Sources\Bitly;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
-use Shlinkio\Shlink\Importer\Params\ConsoleHelper\BitlyApiParamsConsoleHelper;
+use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiParamsConsoleHelper;
use Symfony\Component\Console\Style\StyleInterface;
use function count;
diff --git a/test/Params/BitlyApiParamsTest.php b/test/Sources/Bitly/BitlyApiParamsTest.php
similarity index 96%
rename from test/Params/BitlyApiParamsTest.php
rename to test/Sources/Bitly/BitlyApiParamsTest.php
index 4db89e2..b61c91f 100644
--- a/test/Params/BitlyApiParamsTest.php
+++ b/test/Sources/Bitly/BitlyApiParamsTest.php
@@ -2,10 +2,10 @@
declare(strict_types=1);
-namespace ShlinkioTest\Shlink\Importer\Model;
+namespace ShlinkioTest\Shlink\Importer\Sources\Bitly;
use PHPUnit\Framework\TestCase;
-use Shlinkio\Shlink\Importer\Params\BitlyApiParams;
+use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiParams;
class BitlyApiParamsTest extends TestCase
{
diff --git a/test/Model/BitlyApiProgressTrackerTest.php b/test/Sources/Bitly/BitlyApiProgressTrackerTest.php
similarity index 88%
rename from test/Model/BitlyApiProgressTrackerTest.php
rename to test/Sources/Bitly/BitlyApiProgressTrackerTest.php
index 1f31c8c..b248ce5 100644
--- a/test/Model/BitlyApiProgressTrackerTest.php
+++ b/test/Sources/Bitly/BitlyApiProgressTrackerTest.php
@@ -2,12 +2,12 @@
declare(strict_types=1);
-namespace ShlinkioTest\Shlink\Importer\Model;
+namespace ShlinkioTest\Shlink\Importer\Sources\Bitly;
use DateInterval;
use PHPUnit\Framework\TestCase;
-use Shlinkio\Shlink\Importer\Model\BitlyApiProgressTracker;
-use Shlinkio\Shlink\Importer\Params\BitlyApiParams;
+use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiParams;
+use Shlinkio\Shlink\Importer\Sources\Bitly\BitlyApiProgressTracker;
use Shlinkio\Shlink\Importer\Util\DateHelpersTrait;
use function base64_encode;
diff --git a/test/Sources/Csv/CsvImporterTest.php b/test/Sources/Csv/CsvImporterTest.php
new file mode 100644
index 0000000..1114b6e
--- /dev/null
+++ b/test/Sources/Csv/CsvImporterTest.php
@@ -0,0 +1,132 @@
+importer = new CsvImporter($this->getDate());
+ }
+
+ /**
+ * @test
+ * @dataProvider provideCSVs
+ */
+ public function csvIsProperlyImported(string $csv, string $delimiter, array $expectedList): void
+ {
+ $rawOptions = ['delimiter' => $delimiter, 'stream' => $this->createCsvStream($csv)];
+
+ $result = $this->importer->import($rawOptions);
+
+ $urls = [];
+ foreach ($result as $item) {
+ $urls[] = $item;
+ }
+
+ self::assertEquals($expectedList, $urls);
+ }
+
+ public function provideCSVs(): iterable
+ {
+ yield 'comma separator' => [
+ <<getDate(),
+ null,
+ '123',
+ null,
+ ),
+ new ImportedShlinkUrl(
+ ImportSources::CSV,
+ 'https://facebook.com',
+ [],
+ $this->getDate(),
+ 'example.com',
+ '456',
+ 'my title',
+ ),
+ ],
+ ];
+ yield 'semicolon separator' => [
+ <<getDate(),
+ null,
+ 'abc',
+ null,
+ ),
+ new ImportedShlinkUrl(
+ ImportSources::CSV,
+ 'https://facebook.com',
+ ['foo', 'baz'],
+ $this->getDate(),
+ 'example.com',
+ 'def',
+ null,
+ ),
+ new ImportedShlinkUrl(
+ ImportSources::CSV,
+ 'https://shlink.io/documentation',
+ [],
+ $this->getDate(),
+ 'example.com',
+ 'ghi',
+ 'the title',
+ ),
+ ],
+ ];
+ }
+
+ /**
+ * @return resource
+ */
+ private function createCsvStream(string $csv)
+ {
+ $stream = fopen('php://memory', 'rb+');
+ fwrite($stream, $csv);
+ rewind($stream);
+
+ return $stream;
+ }
+
+ private function getDate(): DateTimeInterface
+ {
+ static $date;
+ return $date ?? ($date = new DateTimeImmutable());
+ }
+}
diff --git a/test/Sources/Csv/CsvParamsConsoleHelperTest.php b/test/Sources/Csv/CsvParamsConsoleHelperTest.php
new file mode 100644
index 0000000..7ee6910
--- /dev/null
+++ b/test/Sources/Csv/CsvParamsConsoleHelperTest.php
@@ -0,0 +1,74 @@
+helper = new CsvParamsConsoleHelper();
+ $this->io = $this->prophesize(StyleInterface::class);
+ }
+
+ /** @test */
+ public function requestsParams(): void
+ {
+ $ask = $this->io->ask(Argument::cetera())->willReturn('stream');
+ $choice = $this->io->choice(Argument::cetera())->willReturn(';');
+
+ $result = $this->helper->requestParams($this->io->reveal());
+
+ self::assertEquals([
+ 'import_short_codes' => true,
+ 'stream' => 'stream',
+ 'delimiter' => ';',
+ ], $result);
+ $ask->shouldHaveBeenCalledOnce();
+ $choice->shouldHaveBeenCalledOnce();
+ }
+
+ /**
+ * @test
+ * @dataProvider provideEmptyStreamValues
+ */
+ public function pathToStreamThrowsExceptionWithInvalidValue(?string $value, string $expectedMessage): void
+ {
+ $this->expectException(InvalidPathException::class);
+ $this->expectExceptionMessage($expectedMessage);
+
+ $this->helper->pathToStream($value);
+ }
+
+ public function provideEmptyStreamValues(): iterable
+ {
+ yield 'null' => [null, 'The path of the file is required.'];
+ yield 'empty string' => ['', 'The path of the file is required.'];
+ yield 'invalid file' => [
+ 'this is not a file',
+ 'The file "this is not a file" does not seem to exist. Try another one.',
+ ];
+ }
+
+ /** @test */
+ public function pathIsProperlyParsedToStream(): void
+ {
+ $result = $this->helper->pathToStream(__FILE__);
+
+ self::assertIsResource($result);
+ }
+}
diff --git a/test/Sources/Csv/InvalidPathExceptionTest.php b/test/Sources/Csv/InvalidPathExceptionTest.php
new file mode 100644
index 0000000..90a1824
--- /dev/null
+++ b/test/Sources/Csv/InvalidPathExceptionTest.php
@@ -0,0 +1,39 @@
+getMessage());
+ }
+
+ /**
+ * @test
+ * @dataProvider providePaths
+ */
+ public function pathIsNotFileCreatesExceptionAsExpected(string $path): void
+ {
+ $e = InvalidPathException::pathIsNotFile($path);
+
+ self::assertEquals(sprintf('The file "%s" does not seem to exist. Try another one.', $path), $e->getMessage());
+ }
+
+ public function providePaths(): iterable
+ {
+ yield ['/foo'];
+ yield ['/bar'];
+ yield ['/bar/baz'];
+ }
+}