diff --git a/.vortex/installer/src/Command/InstallCommand.php b/.vortex/installer/src/Command/InstallCommand.php index 43bf8ca4e..a67f1fd2b 100644 --- a/.vortex/installer/src/Command/InstallCommand.php +++ b/.vortex/installer/src/Command/InstallCommand.php @@ -5,6 +5,7 @@ namespace DrevOps\VortexInstaller\Command; use DrevOps\VortexInstaller\Downloader\Downloader; +use DrevOps\VortexInstaller\Downloader\RepositoryDownloader; use DrevOps\VortexInstaller\Prompts\Handlers\Starter; use DrevOps\VortexInstaller\Prompts\PromptManager; use DrevOps\VortexInstaller\Runner\CommandRunnerAwareInterface; @@ -75,9 +76,14 @@ class InstallCommand extends Command implements CommandRunnerAwareInterface, Exe protected PromptManager $promptManager; /** - * The downloader. + * The repository downloader. */ - protected ?Downloader $downloader = NULL; + protected ?RepositoryDownloader $repositoryDownloader = NULL; + + /** + * The file downloader. + */ + protected ?Downloader $fileDownloader = NULL; /** * {@inheritdoc} @@ -147,11 +153,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int Task::action( label: 'Downloading Vortex', action: function (): string { - $version = $this->getDownloader()->download($this->config->get(Config::REPO), $this->config->get(Config::REF), $this->config->get(Config::TMP)); + $version = $this->getRepositoryDownloader()->download($this->config->get(Config::REPO), $this->config->get(Config::REF), $this->config->get(Config::TMP)); $this->config->set(Config::VERSION, $version); return $version; }, - hint: fn(): string => sprintf('Downloading from "%s" repository at commit "%s"', ...Downloader::parseUri($this->config->get(Config::REPO))), + hint: fn(): string => sprintf('Downloading from "%s" repository at commit "%s"', ...RepositoryDownloader::parseUri($this->config->get(Config::REPO))), success: fn(string $return): string => sprintf('Vortex downloaded (%s)', $return) ); @@ -234,7 +240,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int protected function checkRequirements(): void { $required_commands = [ 'git', - 'curl', 'tar', 'composer', ]; @@ -296,7 +301,7 @@ protected function resolveOptions(array $arguments, array $options): void { Env::putFromDotenv($dest_env_file); } - [$repo, $ref] = Downloader::parseUri($options[static::OPTION_URI] ?: 'https://github.com/drevops/vortex.git@stable'); + [$repo, $ref] = RepositoryDownloader::parseUri($options[static::OPTION_URI] ?: 'https://github.com/drevops/vortex.git@stable'); $this->config->set(Config::REPO, $repo); $this->config->set(Config::REF, $ref); @@ -425,10 +430,8 @@ protected function prepareDemo(): array|string { $messages[] = sprintf('Created data directory "%s".', $data_dir); } - $command = sprintf('curl -s -L "%s" -o "%s/%s"', $url, $data_dir, $db_file); - if (passthru($command) === FALSE) { - throw new \RuntimeException(sprintf('Unable to download demo database from %s.', $url)); - } + $destination = $data_dir . DIRECTORY_SEPARATOR . $db_file; + $this->getFileDownloader()->download($url, $destination); $messages[] = sprintf('No database dump file was found in "%s" directory.', $data_dir); $messages[] = sprintf('Downloaded demo database from %s.', $url); @@ -506,10 +509,10 @@ protected function header(): void { $content = ''; $ref = $this->config->get(Config::REF); - if ($ref == Downloader::REF_STABLE) { + if ($ref == RepositoryDownloader::REF_STABLE) { $content .= 'This tool will guide you through installing the latest ' . Tui::underscore('stable') . ' version of Vortex into your project.' . PHP_EOL; } - elseif ($ref == Downloader::REF_HEAD) { + elseif ($ref == RepositoryDownloader::REF_HEAD) { $content .= 'This tool will guide you through installing the latest ' . Tui::underscore('development') . ' version of Vortex into your project.' . PHP_EOL; } else { @@ -713,26 +716,49 @@ public function cleanup(): void { } /** - * Get the downloader. + * Get the repository downloader. + * + * Provides a default RepositoryDownloader instance or returns the injected + * one. This allows tests to inject mocks via setRepositoryDownloader(). + * + * @return \DrevOps\VortexInstaller\Downloader\RepositoryDownloader + * The repository downloader. + */ + protected function getRepositoryDownloader(): RepositoryDownloader { + return $this->repositoryDownloader ??= new RepositoryDownloader(); + } + + /** + * Set the repository downloader. + * + * @param \DrevOps\VortexInstaller\Downloader\RepositoryDownloader $repositoryDownloader + * The repository downloader. + */ + public function setRepositoryDownloader(RepositoryDownloader $repositoryDownloader): void { + $this->repositoryDownloader = $repositoryDownloader; + } + + /** + * Get the file downloader. * * Provides a default Downloader instance or returns the injected one. - * This allows tests to inject mocks via setDownloader(). + * This allows tests to inject mocks via setFileDownloader(). * * @return \DrevOps\VortexInstaller\Downloader\Downloader - * The downloader. + * The file downloader. */ - protected function getDownloader(): Downloader { - return $this->downloader ??= new Downloader(); + protected function getFileDownloader(): Downloader { + return $this->fileDownloader ??= new Downloader(); } /** - * Set the downloader. + * Set the file downloader. * - * @param \DrevOps\VortexInstaller\Downloader\Downloader $downloader - * The downloader. + * @param \DrevOps\VortexInstaller\Downloader\Downloader $fileDownloader + * The file downloader. */ - public function setDownloader(Downloader $downloader): void { - $this->downloader = $downloader; + public function setFileDownloader(Downloader $fileDownloader): void { + $this->fileDownloader = $fileDownloader; } } diff --git a/.vortex/installer/src/Downloader/Downloader.php b/.vortex/installer/src/Downloader/Downloader.php index 13b25d903..bdbb23402 100644 --- a/.vortex/installer/src/Downloader/Downloader.php +++ b/.vortex/installer/src/Downloader/Downloader.php @@ -4,21 +4,14 @@ namespace DrevOps\VortexInstaller\Downloader; -use DrevOps\VortexInstaller\Utils\Env; -use DrevOps\VortexInstaller\Utils\Git; -use DrevOps\VortexInstaller\Utils\Validator; use GuzzleHttp\Client; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\RequestException; /** - * Download files from a local or remote Git repository using archive. + * Download files from URLs using HTTP. */ -class Downloader implements DownloaderInterface { - - const REF_HEAD = 'HEAD'; - - const REF_STABLE = 'stable'; +class Downloader { /** * Constructs a new Downloader instance. @@ -26,246 +19,41 @@ class Downloader implements DownloaderInterface { * @param \GuzzleHttp\ClientInterface|null $httpClient * Optional HTTP client for testing. If not provided, a default Guzzle * client will be created. - * @param \DrevOps\VortexInstaller\Downloader\ArchiverInterface|null $archiver - * Optional Archiver instance for testing. If not provided, a default - * Archiver will be created. - * @param \DrevOps\VortexInstaller\Utils\Git|null $git - * Optional Git instance for testing. If not provided, will be created - * when needed for local repository operations. */ public function __construct( protected ?ClientInterface $httpClient = new Client(['timeout' => 30, 'connect_timeout' => 10]), - protected ?ArchiverInterface $archiver = new Archiver(), - protected ?Git $git = NULL, ) { } - public function download(string $repo, string $ref, ?string $dst = NULL): string { - if (str_starts_with($repo, 'https://') || str_starts_with($repo, 'git@')) { - $version = $this->downloadFromRemote($repo, $ref, $dst); - } - else { - $version = $this->downloadFromLocal($repo, $ref, $dst); - } - - if (!is_readable($dst . DIRECTORY_SEPARATOR . 'composer.json')) { - throw new \RuntimeException('The downloaded repository does not contain a composer.json file.'); - } - - return $version; - } - - public static function parseUri(string $src): array { - if (str_starts_with($src, 'https://')) { - if (!preg_match('#^(https://[^/]+/[^/]+/[^@]+)(?:@(.+))?$#', $src, $matches)) { - throw new \RuntimeException(sprintf('Invalid remote repository format: "%s".', $src)); - } - $repo = $matches[1]; - $ref = $matches[2] ?? static::REF_HEAD; - } - elseif (str_starts_with($src, 'git@')) { - if (!preg_match('#^(git@[^:]+:[^/]+/[^@]+)(?:@(.+))?$#', $src, $matches)) { - throw new \RuntimeException(sprintf('Invalid remote repository format: "%s".', $src)); - } - $repo = $matches[1]; - $ref = $matches[2] ?? static::REF_HEAD; - } - elseif (str_starts_with($src, 'file://')) { - if (!preg_match('#^file://(.+?)(?:@(.+))?$#', $src, $matches)) { - throw new \RuntimeException(sprintf('Invalid local repository format: "%s".', $src)); - } - $repo = $matches[1]; - $ref = $matches[2] ?? static::REF_HEAD; - } - else { - if (!preg_match('#^(.+?)(?:@(.+))?$#', $src, $matches)) { - throw new \RuntimeException(sprintf('Invalid local repository format: "%s".', $src)); - } - $repo = rtrim($matches[1], '/'); - $ref = $matches[2] ?? static::REF_HEAD; - } - - if ($ref != static::REF_STABLE && $ref != static::REF_HEAD && !Validator::gitCommitSha($ref) && !Validator::gitCommitShaShort($ref)) { - throw new \RuntimeException(sprintf('Invalid reference format: "%s". Supported formats are: %s, %s, %s, %s.', $ref, static::REF_STABLE, static::REF_HEAD, '40-character commit hash', '7-character commit hash')); - } - - return [$repo, $ref]; - } - - protected function downloadFromRemote(string $repo, string $ref, ?string $destination): string { - if ($destination === NULL) { - throw new \InvalidArgumentException('Destination cannot be null for remote downloads.'); - } - $repo_url = str_ends_with($repo, '.git') ? substr($repo, 0, -4) : $repo; - - $version = $ref; - if ($ref === Downloader::REF_STABLE) { - $ref = $this->discoverLatestReleaseRemote($repo_url); - - if ($ref === NULL) { - throw new \RuntimeException(sprintf('Unable to discover the latest release for "%s".', $repo_url)); - } - - $version = $ref; - } - elseif ($ref === Downloader::REF_HEAD) { - $version = 'develop'; - } - - $url = sprintf('%s/archive/%s.tar.gz', $repo_url, $ref); - - $archive_path = $this->downloadArchive($url); - $this->archiver->validate($archive_path); - $this->archiver->extract($archive_path, $destination, TRUE); - unlink($archive_path); - - return $version; - } - - protected function downloadFromLocal(string $repo, string $ref, ?string $destination): string { - if ($destination === NULL) { - throw new \InvalidArgumentException('Destination cannot be null for local downloads.'); - } - - $ref = $ref === Downloader::REF_STABLE ? Downloader::REF_HEAD : $ref; - $version = $ref; - - if ($ref === Downloader::REF_HEAD) { - $git = new Git($repo); - $ref = $git->getLastShortCommitId(); - $version = 'develop'; - } - - $archive_path = $this->archiveFromLocal($repo, $ref); - $this->archiver->validate($archive_path); - $this->archiver->extract($archive_path, $destination, FALSE); - unlink($archive_path); - - return $version; - } - - protected function discoverLatestReleaseRemote(string $repo_url, ?string $release_prefix = NULL): ?string { - $path = parse_url($repo_url, PHP_URL_PATH); - if ($path === FALSE) { - throw new \RuntimeException(sprintf('Invalid repository URL: "%s".', $repo_url)); - } - - $path = ltrim((string) $path, '/'); - - $release_url = sprintf('https://api.github.com/repos/%s/releases', $path); - - $headers = ['User-Agent' => 'Vortex-Installer', 'Accept' => 'application/vnd.github.v3+json']; - - $github_token = Env::get('GITHUB_TOKEN'); - if ($github_token) { - $headers['Authorization'] = sprintf('Bearer %s', $github_token); - } - - try { - $response = $this->httpClient->request('GET', $release_url, ['headers' => $headers]); - $release_contents = $response->getBody()->getContents(); - } - catch (RequestException $e) { - throw new \RuntimeException(sprintf('Unable to download release information from "%s": %s', $release_url, $e->getMessage()), $e->getCode(), $e); - } - - if ($release_contents === '' || $release_contents === '0') { - $message = sprintf('Unable to download release information from "%s"%s.', $release_url, $github_token ? ' (GitHub token was used)' : ''); - throw new \RuntimeException($message); - } - - $records = json_decode($release_contents, TRUE); - - foreach ($records as $record) { - $tag_name = is_scalar($record['tag_name']) ? strval($record['tag_name']) : ''; - $is_draft = $record['draft'] ?? FALSE; - - if (!$is_draft && (!$release_prefix || str_starts_with($tag_name, $release_prefix))) { - return $tag_name; - } - } - - return NULL; - } - /** - * Download archive from URL to a temporary file. + * Download a file from a URL to a specified destination path. * * @param string $url * The URL to download from. - * - * @return string - * Path to the downloaded temporary file. + * @param string $destination + * The destination file path. + * @param array $headers + * Optional HTTP headers to include in the request. * * @throws \RuntimeException * If download fails. */ - protected function downloadArchive(string $url): string { - $temp_file = tempnam(sys_get_temp_dir(), 'vortex_archive_'); - if ($temp_file === FALSE) { - throw new \RuntimeException('Unable to create temporary file for archive download.'); - } + public function download(string $url, string $destination, array $headers = []): void { + $options = [ + 'sink' => $destination, + 'allow_redirects' => TRUE, + ]; - $headers = ['User-Agent' => 'Vortex-Installer']; - - $github_token = Env::get('GITHUB_TOKEN'); - if ($github_token) { - $headers['Authorization'] = sprintf('Bearer %s', $github_token); + if (!empty($headers)) { + $options['headers'] = $headers; } try { - $response = $this->httpClient->request('GET', $url, ['headers' => $headers, 'sink' => $temp_file]); - - if ($response->getStatusCode() !== 200) { - throw new \RuntimeException(sprintf('Failed to download archive: HTTP %d', $response->getStatusCode())); - } + $this->httpClient->request('GET', $url, $options); } catch (RequestException $e) { - if (file_exists($temp_file)) { - unlink($temp_file); - } - throw new \RuntimeException(sprintf('Failed to download archive from: %s - %s', $url, $e->getMessage()), $e->getCode(), $e); + throw new \RuntimeException(sprintf('Failed to download file from %s: %s', $url, $e->getMessage()), $e->getCode(), $e); } - - return $temp_file; - } - - /** - * Create archive from local Git repository. - * - * @param string $repo - * Path to the local repository. - * @param string $ref - * Git reference to archive. - * - * @return string - * Path to the created temporary archive file. - * - * @throws \RuntimeException - * If archive creation fails. - */ - protected function archiveFromLocal(string $repo, string $ref): string { - if (!$this->git instanceof Git) { - $this->git = new Git($repo); - } - - $temp_file = sys_get_temp_dir() . '/vortex_local_archive_' . uniqid() . '.tar'; - - try { - $this->git->run('archive', '--format=tar', $ref, '-o', $temp_file); - - if (!file_exists($temp_file) || filesize($temp_file) === 0) { - throw new \RuntimeException('Archive creation produced empty file.'); - } - } - catch (\Exception $e) { - if (file_exists($temp_file)) { - unlink($temp_file); - } - throw new \RuntimeException(sprintf('Failed to create archive from local repository: %s - %s', $repo, $e->getMessage()), $e->getCode(), $e); - } - - return $temp_file; } } diff --git a/.vortex/installer/src/Downloader/RepositoryDownloader.php b/.vortex/installer/src/Downloader/RepositoryDownloader.php new file mode 100644 index 000000000..645b3cf8e --- /dev/null +++ b/.vortex/installer/src/Downloader/RepositoryDownloader.php @@ -0,0 +1,273 @@ + 30, 'connect_timeout' => 10]), + protected ?ArchiverInterface $archiver = new Archiver(), + protected ?Git $git = NULL, + protected ?Downloader $fileDownloader = new Downloader(), + ) { + } + + public function download(string $repo, string $ref, ?string $dst = NULL): string { + if (str_starts_with($repo, 'https://') || str_starts_with($repo, 'git@')) { + $version = $this->downloadFromRemote($repo, $ref, $dst); + } + else { + $version = $this->downloadFromLocal($repo, $ref, $dst); + } + + if (!is_readable($dst . DIRECTORY_SEPARATOR . 'composer.json')) { + throw new \RuntimeException('The downloaded repository does not contain a composer.json file.'); + } + + return $version; + } + + public static function parseUri(string $src): array { + if (str_starts_with($src, 'https://')) { + if (!preg_match('#^(https://[^/]+/[^/]+/[^@]+)(?:@(.+))?$#', $src, $matches)) { + throw new \RuntimeException(sprintf('Invalid remote repository format: "%s".', $src)); + } + $repo = $matches[1]; + $ref = $matches[2] ?? static::REF_HEAD; + } + elseif (str_starts_with($src, 'git@')) { + if (!preg_match('#^(git@[^:]+:[^/]+/[^@]+)(?:@(.+))?$#', $src, $matches)) { + throw new \RuntimeException(sprintf('Invalid remote repository format: "%s".', $src)); + } + $repo = $matches[1]; + $ref = $matches[2] ?? static::REF_HEAD; + } + elseif (str_starts_with($src, 'file://')) { + if (!preg_match('#^file://(.+?)(?:@(.+))?$#', $src, $matches)) { + throw new \RuntimeException(sprintf('Invalid local repository format: "%s".', $src)); + } + $repo = $matches[1]; + $ref = $matches[2] ?? static::REF_HEAD; + } + else { + if (!preg_match('#^(.+?)(?:@(.+))?$#', $src, $matches)) { + throw new \RuntimeException(sprintf('Invalid local repository format: "%s".', $src)); + } + $repo = rtrim($matches[1], '/'); + $ref = $matches[2] ?? static::REF_HEAD; + } + + if ($ref != static::REF_STABLE && $ref != static::REF_HEAD && !Validator::gitCommitSha($ref) && !Validator::gitCommitShaShort($ref)) { + throw new \RuntimeException(sprintf('Invalid reference format: "%s". Supported formats are: %s, %s, %s, %s.', $ref, static::REF_STABLE, static::REF_HEAD, '40-character commit hash', '7-character commit hash')); + } + + return [$repo, $ref]; + } + + protected function downloadFromRemote(string $repo, string $ref, ?string $destination): string { + if ($destination === NULL) { + throw new \InvalidArgumentException('Destination cannot be null for remote downloads.'); + } + $repo_url = str_ends_with($repo, '.git') ? substr($repo, 0, -4) : $repo; + + $version = $ref; + if ($ref === RepositoryDownloader::REF_STABLE) { + $ref = $this->discoverLatestReleaseRemote($repo_url); + + if ($ref === NULL) { + throw new \RuntimeException(sprintf('Unable to discover the latest release for "%s".', $repo_url)); + } + + $version = $ref; + } + elseif ($ref === RepositoryDownloader::REF_HEAD) { + $version = 'develop'; + } + + $url = sprintf('%s/archive/%s.tar.gz', $repo_url, $ref); + + $archive_path = $this->downloadArchive($url); + $this->archiver->validate($archive_path); + $this->archiver->extract($archive_path, $destination, TRUE); + unlink($archive_path); + + return $version; + } + + protected function downloadFromLocal(string $repo, string $ref, ?string $destination): string { + if ($destination === NULL) { + throw new \InvalidArgumentException('Destination cannot be null for local downloads.'); + } + + $ref = $ref === RepositoryDownloader::REF_STABLE ? RepositoryDownloader::REF_HEAD : $ref; + $version = $ref; + + if ($ref === RepositoryDownloader::REF_HEAD) { + if (!$this->git instanceof Git) { + $this->git = new Git($repo); + } + $ref = $this->git->getLastShortCommitId(); + $version = 'develop'; + } + + $archive_path = $this->archiveFromLocal($repo, $ref); + $this->archiver->validate($archive_path); + $this->archiver->extract($archive_path, $destination, FALSE); + unlink($archive_path); + + return $version; + } + + protected function discoverLatestReleaseRemote(string $repo_url, ?string $release_prefix = NULL): ?string { + $path = parse_url($repo_url, PHP_URL_PATH); + if ($path === FALSE) { + throw new \RuntimeException(sprintf('Invalid repository URL: "%s".', $repo_url)); + } + + $path = ltrim((string) $path, '/'); + + $release_url = sprintf('https://api.github.com/repos/%s/releases', $path); + + $headers = ['User-Agent' => 'Vortex-Installer', 'Accept' => 'application/vnd.github.v3+json']; + + $github_token = Env::get('GITHUB_TOKEN'); + if ($github_token) { + $headers['Authorization'] = sprintf('Bearer %s', $github_token); + } + + try { + $response = $this->httpClient->request('GET', $release_url, ['headers' => $headers]); + $release_contents = $response->getBody()->getContents(); + } + catch (RequestException $e) { + throw new \RuntimeException(sprintf('Unable to download release information from "%s": %s', $release_url, $e->getMessage()), $e->getCode(), $e); + } + + if ($release_contents === '' || $release_contents === '0') { + $message = sprintf('Unable to download release information from "%s"%s.', $release_url, $github_token ? ' (GitHub token was used)' : ''); + throw new \RuntimeException($message); + } + + $records = json_decode($release_contents, TRUE); + + foreach ($records as $record) { + $tag_name = is_scalar($record['tag_name']) ? strval($record['tag_name']) : ''; + $is_draft = $record['draft'] ?? FALSE; + + if (!$is_draft && (!$release_prefix || str_starts_with($tag_name, $release_prefix))) { + return $tag_name; + } + } + + return NULL; + } + + /** + * Download archive from URL to a temporary file. + * + * @param string $url + * The URL to download from. + * + * @return string + * Path to the downloaded temporary file. + * + * @throws \RuntimeException + * If download fails. + */ + protected function downloadArchive(string $url): string { + $temp_file = tempnam(sys_get_temp_dir(), 'vortex_archive_'); + if ($temp_file === FALSE) { + throw new \RuntimeException('Unable to create temporary file for archive download.'); + } + + $headers = ['User-Agent' => 'Vortex-Installer']; + + $github_token = Env::get('GITHUB_TOKEN'); + if ($github_token) { + $headers['Authorization'] = sprintf('Bearer %s', $github_token); + } + + try { + $this->fileDownloader->download($url, $temp_file, $headers); + } + catch (\RuntimeException $e) { + if (file_exists($temp_file)) { + unlink($temp_file); + } + throw new \RuntimeException(sprintf('Failed to download archive from: %s - %s', $url, $e->getMessage()), $e->getCode(), $e); + } + + return $temp_file; + } + + /** + * Create archive from local Git repository. + * + * @param string $repo + * Path to the local repository. + * @param string $ref + * Git reference to archive. + * + * @return string + * Path to the created temporary archive file. + * + * @throws \RuntimeException + * If archive creation fails. + */ + protected function archiveFromLocal(string $repo, string $ref): string { + if (!$this->git instanceof Git) { + $this->git = new Git($repo); + } + + $temp_file = sys_get_temp_dir() . '/vortex_local_archive_' . uniqid() . '.tar'; + + try { + $this->git->run('archive', '--format=tar', $ref, '-o', $temp_file); + + if (!file_exists($temp_file) || filesize($temp_file) === 0) { + throw new \RuntimeException('Archive creation produced empty file.'); + } + } + catch (\Exception $e) { + if (file_exists($temp_file)) { + unlink($temp_file); + } + throw new \RuntimeException(sprintf('Failed to create archive from local repository: %s - %s', $repo, $e->getMessage()), $e->getCode(), $e); + } + + return $temp_file; + } + +} diff --git a/.vortex/installer/src/Downloader/DownloaderInterface.php b/.vortex/installer/src/Downloader/RepositoryDownloaderInterface.php similarity index 96% rename from .vortex/installer/src/Downloader/DownloaderInterface.php rename to .vortex/installer/src/Downloader/RepositoryDownloaderInterface.php index 69f30091b..d6331ad3b 100644 --- a/.vortex/installer/src/Downloader/DownloaderInterface.php +++ b/.vortex/installer/src/Downloader/RepositoryDownloaderInterface.php @@ -7,7 +7,7 @@ /** * Interface for downloading files from local or remote Git repositories. */ -interface DownloaderInterface { +interface RepositoryDownloaderInterface { /** * Downloads a repository archive from a local or remote source. diff --git a/.vortex/installer/tests/Functional/Command/InstallCommandTest.php b/.vortex/installer/tests/Functional/Command/InstallCommandTest.php index 8cfe0d7e4..52da0ade1 100644 --- a/.vortex/installer/tests/Functional/Command/InstallCommandTest.php +++ b/.vortex/installer/tests/Functional/Command/InstallCommandTest.php @@ -8,7 +8,7 @@ use DrevOps\VortexInstaller\Command\BuildCommand; use DrevOps\VortexInstaller\Command\CheckRequirementsCommand; use DrevOps\VortexInstaller\Command\InstallCommand; -use DrevOps\VortexInstaller\Downloader\Downloader; +use DrevOps\VortexInstaller\Downloader\RepositoryDownloader; use DrevOps\VortexInstaller\Runner\ProcessRunner; use DrevOps\VortexInstaller\Runner\RunnerInterface; use DrevOps\VortexInstaller\Tests\Functional\FunctionalTestCase; @@ -86,9 +86,9 @@ public function testInstallCommand( $install_command->setExecutableFinder($executable_finder); if ($download_should_fail) { - $mock_downloader = $this->createMock(Downloader::class); + $mock_downloader = $this->createMock(RepositoryDownloader::class); $mock_downloader->method('download')->willThrowException(new \RuntimeException('Failed to download Vortex.')); - $install_command->setDownloader($mock_downloader); + $install_command->setRepositoryDownloader($mock_downloader); } else { // Download from root as a real repository. This is long, but there is @@ -227,31 +227,6 @@ public static function dataProviderInstallCommand(): array { ], ], - 'Requirements of install command check fails, missing curl' => [ - 'command_inputs' => self::tuiOptions([ - InstallCommand::OPTION_NO_INTERACTION => TRUE, - ]), - 'install_executable_finder_find_callback' => function (string $command): ?string { - // Curl command fails. - if (str_contains($command, 'curl')) { - return NULL; - } - return '/usr/bin/' . $command; - }, - - 'build_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS, - 'check_requirements_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS, - 'expect_failure' => TRUE, - 'output_assertions' => [ - ...TuiOutput::present([ - TuiOutput::INSTALL_ERROR_MISSING_CURL, - ]), - ...TuiOutput::absent([ - TuiOutput::INSTALL_STARTING, - ]), - ], - ], - 'Requirements of install command check fails, missing tar' => [ 'command_inputs' => self::tuiOptions([ InstallCommand::OPTION_NO_INTERACTION => TRUE, diff --git a/.vortex/installer/tests/Functional/Handlers/BaselineHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/BaselineHandlerProcessTest.php index 3dbcfd9a6..c6f8dec31 100644 --- a/.vortex/installer/tests/Functional/Handlers/BaselineHandlerProcessTest.php +++ b/.vortex/installer/tests/Functional/Handlers/BaselineHandlerProcessTest.php @@ -29,7 +29,7 @@ use DrevOps\VortexInstaller\Prompts\Handlers\Webroot; use DrevOps\VortexInstaller\Prompts\PromptManager; use DrevOps\VortexInstaller\Utils\Config; -use DrevOps\VortexInstaller\Downloader\Downloader; +use DrevOps\VortexInstaller\Downloader\RepositoryDownloader; use DrevOps\VortexInstaller\Utils\File; use DrevOps\VortexInstaller\Utils\Git; use DrevOps\VortexInstaller\Utils\Tui; @@ -59,7 +59,7 @@ #[CoversClass(Timezone::class)] #[CoversClass(Webroot::class)] #[CoversClass(PromptManager::class)] -#[CoversClass(Downloader::class)] +#[CoversClass(RepositoryDownloader::class)] #[CoversClass(Config::class)] #[CoversClass(Git::class)] #[CoversClass(Tui::class)] diff --git a/.vortex/installer/tests/Helpers/TuiOutput.php b/.vortex/installer/tests/Helpers/TuiOutput.php index 39721473d..85f3b6b57 100644 --- a/.vortex/installer/tests/Helpers/TuiOutput.php +++ b/.vortex/installer/tests/Helpers/TuiOutput.php @@ -128,8 +128,6 @@ class TuiOutput { const INSTALL_ERROR_MISSING_GIT = 'Installation failed with an error: Missing required command: git.'; - const INSTALL_ERROR_MISSING_CURL = 'Installation failed with an error: Missing required command: curl.'; - const INSTALL_ERROR_MISSING_TAR = 'Installation failed with an error: Missing required command: tar.'; const INSTALL_ERROR_MISSING_COMPOSER = 'Installation failed with an error: Missing required command: Composer.'; diff --git a/.vortex/installer/tests/Unit/Downloader/DownloaderTest.php b/.vortex/installer/tests/Unit/Downloader/DownloaderTest.php index 53d080744..72d36309c 100644 --- a/.vortex/installer/tests/Unit/Downloader/DownloaderTest.php +++ b/.vortex/installer/tests/Unit/Downloader/DownloaderTest.php @@ -4,908 +4,80 @@ namespace DrevOps\VortexInstaller\Tests\Unit\Downloader; -use AlexSkrypnyk\File\File; -use DrevOps\VortexInstaller\Downloader\ArchiverInterface; use DrevOps\VortexInstaller\Downloader\Downloader; use DrevOps\VortexInstaller\Tests\Unit\UnitTestCase; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\RequestException; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\MockObject\MockObject; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\StreamInterface; #[CoversClass(Downloader::class)] class DownloaderTest extends UnitTestCase { - #[DataProvider('dataProviderParseUri')] - public function testParseUri(string $src, ?string $expected_repo = NULL, ?string $expected_ref = NULL, ?string $expected_exception_message = NULL): void { - if (!is_null($expected_exception_message)) { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage($expected_exception_message); - } - - $result = Downloader::parseUri($src); - - if (is_null($expected_exception_message)) { - $this->assertCount(2, $result); - $this->assertEquals($expected_repo, $result[0], 'Repository matches input: ' . $src); - $this->assertEquals($expected_ref, $result[1], 'Reference matches input: ' . $src); - } - } - - public function testDownloadWithMockedArchiver(): void { - $mock_http_client = $this->createMockHttpClient(); - /** @var \PHPUnit\Framework\MockObject\MockObject&\DrevOps\VortexInstaller\Downloader\ArchiverInterface $mock_archiver */ - $mock_archiver = $this->createMockArchiver(); - $mock_archiver->expects($this->once())->method('validate'); - $mock_archiver->expects($this->once())->method('extract'); - $destination = self::$tmp . '/destination'; - File::mkdir($destination); - File::dump($destination . '/composer.json', '{}'); - $downloader = new Downloader($mock_http_client, $mock_archiver); - $version = $downloader->download('https://github.com/user/repo', 'HEAD', $destination); - $this->assertEquals('develop', $version); - } - - public function testDownloadThrowsExceptionWhenComposerJsonMissing(): void { + public function testDownloadSuccess(): void { $mock_http_client = $this->createMock(ClientInterface::class); - $mock_archiver = $this->createMock(ArchiverInterface::class); $mock_response = $this->createMock(ResponseInterface::class); - $mock_body = $this->createMock(StreamInterface::class); - - $mock_response->method('getBody')->willReturn($mock_body); - $mock_body->method('getContents')->willReturn('mock content'); $mock_response->method('getStatusCode')->willReturn(200); - $mock_http_client->method('request')->willReturn($mock_response); + $mock_http_client->expects($this->once()) + ->method('request') + ->with( + 'GET', + 'https://example.com/file.sql', + $this->callback(fn(array $options): bool => isset($options['sink']) && isset($options['allow_redirects']) && $options['allow_redirects'] === TRUE) + ) + ->willReturn($mock_response); - $destination = self::$tmp . '/destination'; - File::mkdir($destination); + $destination = self::$tmp . '/downloaded_file.sql'; - $downloader = new Downloader($mock_http_client, $mock_archiver); + $downloader = new Downloader($mock_http_client); + $downloader->download('https://example.com/file.sql', $destination); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('The downloaded repository does not contain a composer.json file.'); - - $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + // If we got here without exception, the download was successful. + $this->addToAssertionCount(1); } - public function testDownloadFromRemoteCallsArchiverCorrectly(): void { + public function testDownloadFailure(): void { $mock_http_client = $this->createMock(ClientInterface::class); - $mock_archiver = $this->createMock(ArchiverInterface::class); - $mock_response = $this->createMock(ResponseInterface::class); - $mock_body = $this->createMock(StreamInterface::class); + $mock_http_client->method('request') + ->willThrowException(new RequestException('Network error', $this->createMock(RequestInterface::class))); - $mock_response->method('getBody')->willReturn($mock_body); - $mock_body->method('getContents')->willReturn('mock content'); - $mock_response->method('getStatusCode')->willReturn(200); - $mock_http_client->method('request')->willReturn($mock_response); - - $mock_archiver->expects($this->once())->method('validate')->with($this->stringContains('vortex_archive_')); - $mock_archiver->expects($this->once())->method('extract')->with($this->stringContains('vortex_archive_'), $this->anything(), TRUE); - - $destination = self::$tmp . '/destination'; - File::mkdir($destination); - File::dump($destination . '/composer.json', '{}'); - - $downloader = new Downloader($mock_http_client, $mock_archiver); - $downloader->download('https://github.com/user/repo', 'HEAD', $destination); - } + $destination = self::$tmp . '/downloaded_file.sql'; - public function testDownloadArchiveCreatesTemporaryFile(): void { - $mock_http_client = $this->createMock(ClientInterface::class); - $mock_response = $this->createMock(ResponseInterface::class); - $mock_response->method('getStatusCode')->willReturn(200); - $mock_http_client->method('request')->willReturn($mock_response); - $mock_archiver = $this->createMock(ArchiverInterface::class); - $destination = self::$tmp . '/destination'; - File::mkdir($destination); - File::dump($destination . '/composer.json', '{}'); - $downloader = new Downloader($mock_http_client, $mock_archiver); - $version = $downloader->download('https://github.com/user/repo', 'HEAD', $destination); - $this->assertEquals('develop', $version); - } + $downloader = new Downloader($mock_http_client); - public function testDownloadArchiveHandlesHttpError(): void { - $mock_http_client = $this->createMock(ClientInterface::class); - $mock_response = $this->createMock(ResponseInterface::class); - $mock_response->method('getStatusCode')->willReturn(404); - $mock_http_client->method('request')->willReturn($mock_response); - $mock_archiver = $this->createMock(ArchiverInterface::class); - $destination = self::$tmp . '/destination'; - File::mkdir($destination); - $downloader = new Downloader($mock_http_client, $mock_archiver); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Failed to download archive: HTTP 404'); - $downloader->download('https://github.com/user/repo', 'HEAD', $destination); - } + $this->expectExceptionMessage('Failed to download file from https://example.com/file.sql: Network error'); - public function testDownloadArchiveHandlesRequestException(): void { - $mock_http_client = $this->createMock(ClientInterface::class); - $mock_http_client->method('request')->willThrowException(new RequestException('Network error', $this->createMock(RequestInterface::class))); - $mock_archiver = $this->createMock(ArchiverInterface::class); - $destination = self::$tmp . '/destination'; - File::mkdir($destination); - $downloader = new Downloader($mock_http_client, $mock_archiver); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Failed to download archive from'); - $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + $downloader->download('https://example.com/file.sql', $destination); } - #[DataProvider('providerDiscoverLatestReleaseRemote')] - public function testDiscoverLatestReleaseRemote(string $repo, mixed $releaseData, bool $throwException, bool $skipMockSetup, ?string $expectedVersion, ?string $expectedException, ?string $expectedMessage): void { - $mock_http_client = $this->createMock(ClientInterface::class); - - if (!$skipMockSetup) { - if ($throwException) { - $mock_http_client->method('request')->willThrowException(new RequestException('API error', $this->createMock(RequestInterface::class))); - } - else { - $mock_response = $this->createMock(ResponseInterface::class); - $mock_body = $this->createMock(StreamInterface::class); - $mock_response->method('getBody')->willReturn($mock_body); - - $release_json = is_array($releaseData) ? json_encode($releaseData) : $releaseData; - - $mock_body->method('getContents')->willReturn($release_json); - $mock_response->method('getStatusCode')->willReturn(200); - - if ($expectedVersion !== NULL) { - $mock_http_client->expects($this->exactly(2))->method('request')->willReturnOnConsecutiveCalls($mock_response, $mock_response); - } - else { - $mock_http_client->method('request')->willReturn($mock_response); - } - } - } - - $mock_archiver = $this->createMock(ArchiverInterface::class); - $destination = self::$tmp . '/destination_' . uniqid(); - File::mkdir($destination); - - if ($expectedVersion !== NULL) { - File::dump($destination . '/composer.json', '{}'); - } - - $downloader = new Downloader($mock_http_client, $mock_archiver); - - if ($expectedException !== NULL) { - /** @var class-string<\Throwable> $expectedException */ - $this->expectException($expectedException); - $this->expectExceptionMessage($expectedMessage); - } - - $version = $downloader->download($repo, 'stable', $destination); - - if ($expectedVersion !== NULL) { - $this->assertEquals($expectedVersion, $version); - } - } - - public function testDownloadFromRemoteWithGitSuffix(): void { + public function testDownloadFollowsRedirects(): void { $mock_http_client = $this->createMock(ClientInterface::class); $mock_response = $this->createMock(ResponseInterface::class); $mock_response->method('getStatusCode')->willReturn(200); - $mock_http_client->method('request')->willReturn($mock_response); - $mock_archiver = $this->createMock(ArchiverInterface::class); - $destination = self::$tmp . '/destination'; - File::mkdir($destination); - File::dump($destination . '/composer.json', '{}'); - $downloader = new Downloader($mock_http_client, $mock_archiver); - $version = $downloader->download('https://github.com/user/repo.git', 'HEAD', $destination); - $this->assertEquals('develop', $version); - } - #[DataProvider('providerDownloadWithNullDestination')] - public function testDownloadWithNullDestination(string $repo, string $expectedMessage): void { - $downloader = new Downloader(); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage($expectedMessage); - $downloader->download($repo, 'HEAD'); - } + $mock_http_client->expects($this->once()) + ->method('request') + ->with( + 'GET', + $this->anything(), + $this->callback(fn(array $options): bool => $options['allow_redirects'] === TRUE) + ) + ->willReturn($mock_response); - #[DataProvider('providerDownloadFromLocal')] - public function testDownloadFromLocal(string $ref, string $expectedVersion): void { - $temp_repo_dir = $this->createGitRepo(); - $destination = self::$tmp . '/dest_' . uniqid(); - File::mkdir($destination); + $destination = self::$tmp . '/downloaded_file.sql'; - // Handle the special case where we need to get the actual commit hash. - if ($ref === 'COMMIT_HASH') { - $output = shell_exec('cd ' . escapeshellarg($temp_repo_dir) . ' && git rev-parse HEAD'); - $this->assertIsString($output, 'Failed to get commit hash from git repository'); - $commit_hash = trim($output); - $this->assertNotEmpty($commit_hash, 'Git rev-parse returned empty output'); - $ref = substr($commit_hash, 0, 7); - $expectedVersion = $ref; - } + $downloader = new Downloader($mock_http_client); + $downloader->download('https://example.com/redirect', $destination); - /** @var \PHPUnit\Framework\MockObject\MockObject&\DrevOps\VortexInstaller\Downloader\ArchiverInterface $mock_archiver */ - $mock_archiver = $this->createMockArchiverWithExtract(); - $downloader = new Downloader(NULL, $mock_archiver); - $version = $downloader->download($temp_repo_dir, $ref, $destination); - $this->assertEquals($expectedVersion, $version); - $this->removeGitRepo($temp_repo_dir); + $this->addToAssertionCount(1); } - public function testArchiveFromLocalHandlesGitFailure(): void { - $temp_repo_dir = self::$tmp . '/test_git_repo_' . uniqid(); - $temp_dest_dir = self::$tmp . '/test_dest_' . uniqid(); - File::mkdir($temp_repo_dir); - File::mkdir($temp_dest_dir); - chdir($temp_repo_dir); - exec('git init 2>&1'); - exec('git config user.email "test@example.com" 2>&1'); - exec('git config user.name "Test User" 2>&1'); - File::dump($temp_repo_dir . '/test.txt', 'test content'); - exec('git add . 2>&1'); - exec('git commit -m "Initial commit" 2>&1'); + public function testDownloadWithDefaultClient(): void { + // Test that the class can be instantiated without providing an HTTP client. $downloader = new Downloader(); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Failed to create archive from local repository'); - $downloader->download($temp_repo_dir, 'nonexistent-ref', $temp_dest_dir); - } - - public function testDiscoverLatestReleaseRemoteWithGithubToken(): void { - static::envSet('GITHUB_TOKEN', 'test_token_12345'); - $release_json = json_encode([ - ['tag_name' => 'v1.5.0', 'draft' => FALSE], - ]); - $mock_http_client = $this->createMock(ClientInterface::class); - $mock_response = $this->createMock(ResponseInterface::class); - $mock_body = $this->createMock(StreamInterface::class); - $mock_response->method('getBody')->willReturn($mock_body); - $mock_body->method('getContents')->willReturn($release_json); - $mock_response->method('getStatusCode')->willReturn(200); - $mock_http_client->expects($this->exactly(2))->method('request')->willReturnCallback(function ($method, $url, array|\ArrayAccess $options) use ($mock_response): ResponseInterface { - $this->assertArrayHasKey('headers', $options); - $this->assertArrayHasKey('Authorization', $options['headers']); - $this->assertEquals('Bearer test_token_12345', $options['headers']['Authorization']); - return $mock_response; - }); - $mock_archiver = $this->createMock(ArchiverInterface::class); - $destination = self::$tmp . '/destination'; - File::mkdir($destination); - File::dump($destination . '/composer.json', '{}'); - $downloader = new Downloader($mock_http_client, $mock_archiver); - $version = $downloader->download('https://github.com/user/repo', 'stable', $destination); - $this->assertEquals('v1.5.0', $version); - } - - public function testDownloadArchiveWithGithubToken(): void { - static::envSet('GITHUB_TOKEN', 'test_token_67890'); - $mock_http_client = $this->createMock(ClientInterface::class); - $mock_response = $this->createMock(ResponseInterface::class); - $mock_response->method('getStatusCode')->willReturn(200); - $mock_http_client->expects($this->once())->method('request')->willReturnCallback(function ($method, $url, array|\ArrayAccess $options) use ($mock_response): ResponseInterface { - $this->assertArrayHasKey('headers', $options); - $this->assertArrayHasKey('Authorization', $options['headers']); - $this->assertEquals('Bearer test_token_67890', $options['headers']['Authorization']); - return $mock_response; - }); - $mock_archiver = $this->createMock(ArchiverInterface::class); - $destination = self::$tmp . '/destination'; - File::mkdir($destination); - File::dump($destination . '/composer.json', '{}'); - $downloader = new Downloader($mock_http_client, $mock_archiver); - $version = $downloader->download('https://github.com/user/repo', 'HEAD', $destination); - $this->assertEquals('develop', $version); - } - - public static function dataProviderParseUri(): array { - return [ - // Valid test cases. - 'HTTPS URLs - with default HEAD reference - GitHub' => [ - 'https://github.com/user/repo', - 'https://github.com/user/repo', - Downloader::REF_HEAD, - - ], - 'HTTPS URLs - with default HEAD reference - GitLab' => [ - 'https://gitlab.com/user/repo', - 'https://gitlab.com/user/repo', - Downloader::REF_HEAD, - - ], - 'HTTPS URLs - with default HEAD reference - Bitbucket' => [ - 'https://bitbucket.org/user/repo', - 'https://bitbucket.org/user/repo', - Downloader::REF_HEAD, - - ], - 'HTTPS URLs - with specific valid references - stable' => [ - 'https://github.com/user/repo@stable', - 'https://github.com/user/repo', - Downloader::REF_STABLE, - - ], - 'HTTPS URLs - with specific valid references - HEAD' => [ - 'https://github.com/user/repo@HEAD', - 'https://github.com/user/repo', - Downloader::REF_HEAD, - - ], - 'HTTPS URLs - with 40-character commit hash' => [ - 'https://github.com/user/repo@1234567890abcdef1234567890abcdef12345678', - 'https://github.com/user/repo', - '1234567890abcdef1234567890abcdef12345678', - - ], - 'HTTPS URLs - with 7-character short commit hash' => [ - 'https://github.com/user/repo@1234567', - 'https://github.com/user/repo', - '1234567', - - ], - 'Git SSH URLs - with default HEAD reference - GitHub' => [ - 'git@github.com:user/repo', - 'git@github.com:user/repo', - Downloader::REF_HEAD, - ], - 'Git SSH URLs - with default HEAD reference - GitLab' => [ - 'git@gitlab.com:user/repo', - 'git@gitlab.com:user/repo', - Downloader::REF_HEAD, - ], - 'Git SSH URLs - with default HEAD reference - Bitbucket' => [ - 'git@bitbucket.org:user/repo', - 'git@bitbucket.org:user/repo', - Downloader::REF_HEAD, - ], - 'Git SSH URLs - with specific valid references - stable' => [ - 'git@github.com:user/repo@stable', - 'git@github.com:user/repo', - Downloader::REF_STABLE, - ], - 'Git SSH URLs - with specific valid references - HEAD' => [ - 'git@github.com:user/repo@HEAD', - 'git@github.com:user/repo', - Downloader::REF_HEAD, - ], - 'Git SSH URLs - with commit hashes - 40 char' => [ - 'git@github.com:user/repo@1234567890abcdef1234567890abcdef12345678', - 'git@github.com:user/repo', - '1234567890abcdef1234567890abcdef12345678', - ], - 'Git SSH URLs - with commit hashes - 7 char' => [ - 'git@github.com:user/repo@1234567', - 'git@github.com:user/repo', - '1234567', - ], - 'File URLs - with default HEAD reference' => [ - 'file:///path/to/repo', - '/path/to/repo', - Downloader::REF_HEAD, - ], - 'File URLs - with default HEAD reference - user path' => [ - 'file:///home/user/repos/myrepo', - '/home/user/repos/myrepo', - Downloader::REF_HEAD, - ], - 'File URLs - with specific valid references - stable' => [ - 'file:///path/to/repo@stable', - '/path/to/repo', - Downloader::REF_STABLE, - ], - 'File URLs - with specific valid references - HEAD' => [ - 'file:///path/to/repo@HEAD', - '/path/to/repo', - Downloader::REF_HEAD, - ], - 'File URLs - with 40-character commit hash' => [ - 'file:///path/to/repo@1234567890abcdef1234567890abcdef12345678', - '/path/to/repo', - '1234567890abcdef1234567890abcdef12345678', - ], - 'File URLs - with 7-character commit hash' => [ - 'file:///path/to/repo@1234567', - '/path/to/repo', - '1234567', - ], - 'Local paths - with default HEAD reference - absolute' => [ - '/path/to/repo', - '/path/to/repo', - Downloader::REF_HEAD, - ], - 'Local paths - with default HEAD reference - user home' => [ - '/home/user/repos/myrepo', - '/home/user/repos/myrepo', - Downloader::REF_HEAD, - ], - 'Local paths - with default HEAD reference - relative' => [ - 'relative/path/to/repo', - 'relative/path/to/repo', - Downloader::REF_HEAD, - ], - 'Local paths - with default HEAD reference - current dir' => [ - './repo', - './repo', - Downloader::REF_HEAD, - ], - 'Local paths - with default HEAD reference - parent dir' => [ - '../repo', - '../repo', - Downloader::REF_HEAD, - ], - 'Local paths - with specific valid references - stable' => [ - '/path/to/repo@stable', - '/path/to/repo', - Downloader::REF_STABLE, - ], - 'Local paths - with specific valid references - HEAD' => [ - '/path/to/repo@HEAD', - '/path/to/repo', - Downloader::REF_HEAD, - ], - 'Local paths - with 40-character commit hash' => [ - '/path/to/repo@1234567890abcdef1234567890abcdef12345678', - '/path/to/repo', - '1234567890abcdef1234567890abcdef12345678', - ], - 'Local paths - with 7-character commit hash' => [ - '/path/to/repo@1234567', - '/path/to/repo', - '1234567', - ], - 'Local paths with trailing slashes - should be trimmed - single slash' => [ - '/path/to/repo/', - '/path/to/repo', - Downloader::REF_HEAD, - ], - 'Local paths with trailing slashes - should be trimmed - double slash' => [ - '/path/to/repo//', - '/path/to/repo', - Downloader::REF_HEAD, - ], - 'Local paths with trailing slashes - should be trimmed - with reference' => [ - '/path/to/repo/@stable', - '/path/to/repo', - Downloader::REF_STABLE, - ], - 'Relative paths - simple' => [ - 'repo', - 'repo', - Downloader::REF_HEAD, - ], - 'Relative paths - with reference' => [ - 'repo@stable', - 'repo', - Downloader::REF_STABLE, - ], - 'Edge cases with valid commit hashes - uppercase 40 char' => [ - 'https://github.com/user/repo@ABCDEF1234567890ABCDEF1234567890ABCDEF12', - 'https://github.com/user/repo', - 'ABCDEF1234567890ABCDEF1234567890ABCDEF12', - ], - 'Edge cases with valid commit hashes - uppercase 7 char' => [ - 'git@github.com:user/repo@ABCDEF1', - 'git@github.com:user/repo', - 'ABCDEF1', - ], - 'Edge cases that are actually valid - HTTPS with extra path' => [ - 'https://github.com/user/repo/extra/path', - 'https://github.com/user/repo/extra/path', - Downloader::REF_HEAD, - ], - 'Edge cases that are actually valid - Git SSH with extra path' => [ - 'git@github.com:user/repo/extra', - 'git@github.com:user/repo/extra', - Downloader::REF_HEAD, - ], - 'Edge cases that are actually valid - protocol-less GitHub' => [ - 'github.com/user/repo', - 'github.com/user/repo', - Downloader::REF_HEAD, - ], - 'Edge cases that are actually valid - file root' => [ - 'file:///', - '/', - Downloader::REF_HEAD, - ], - 'Empty reference defaults to HEAD - @ is captured in repo part' => [ - '/path/to/repo@', - '/path/to/repo@', - Downloader::REF_HEAD, - ], - - // Invalid test cases. - 'Invalid HTTPS URL formats - missing repo' => [ - 'https://github.com', - NULL, - NULL, - 'Invalid remote repository format: "https://github.com".', - ], - 'Invalid HTTPS URL formats - missing repo with slash' => [ - 'https://github.com/', - NULL, - NULL, - 'Invalid remote repository format: "https://github.com/".', - ], - 'Invalid HTTPS URL formats - missing repo name' => [ - 'https://github.com/user', - NULL, - NULL, - 'Invalid remote repository format: "https://github.com/user".', - ], - 'Invalid HTTPS URL formats - missing repo name with slash' => [ - 'https://github.com/user/', - NULL, - NULL, - 'Invalid remote repository format: "https://github.com/user/".', - ], - 'Invalid HTTPS URL formats - malformed with reference' => [ - 'https://github.com/user@main', - NULL, - NULL, - 'Invalid remote repository format: "https://github.com/user@main".', - ], - 'Invalid HTTPS URL formats - protocol only' => [ - 'https://', - NULL, - NULL, - 'Invalid remote repository format: "https://".', - ], - 'Invalid HTTPS URL formats - protocol with slash' => [ - 'https:///', - NULL, - NULL, - 'Invalid remote repository format: "https:///".', - ], - 'Invalid Git SSH URL formats - missing colon' => [ - 'git@github.com', - NULL, - NULL, - 'Invalid remote repository format: "git@github.com".', - ], - 'Invalid Git SSH URL formats - empty after colon' => [ - 'git@github.com:', - NULL, - NULL, - 'Invalid remote repository format: "git@github.com:".', - ], - 'Invalid Git SSH URL formats - missing repo name' => [ - 'git@github.com:user', - NULL, - NULL, - 'Invalid remote repository format: "git@github.com:user".', - ], - 'Invalid Git SSH URL formats - malformed with reference' => [ - 'git@github.com:user@main', - NULL, - NULL, - 'Invalid remote repository format: "git@github.com:user@main".', - ], - 'Invalid Git SSH URL formats - empty user' => [ - 'git@', - NULL, - NULL, - 'Invalid remote repository format: "git@".', - ], - 'Invalid Git SSH URL formats - empty host' => [ - 'git@:user/repo', - NULL, - NULL, - 'Invalid remote repository format: "git@:user/repo".', - ], - 'Invalid file URL formats - empty path' => [ - 'file://', - NULL, - NULL, - 'Invalid local repository format: "file://".', - ], - 'Invalid reference formats - branch names not allowed - main' => [ - 'https://github.com/user/repo@main', - NULL, - NULL, - 'Invalid reference format: "main". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid reference formats - branch names not allowed - develop' => [ - 'git@github.com:user/repo@develop', - NULL, - NULL, - 'Invalid reference format: "develop". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid reference formats - branch names not allowed - version tag' => [ - '/path/to/repo@v1.0.0', - NULL, - NULL, - 'Invalid reference format: "v1.0.0". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid reference formats - branch names not allowed - feature branch' => [ - 'file:///path/to/repo@feature-branch', - NULL, - NULL, - 'Invalid reference format: "feature-branch". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid reference formats - special characters - exclamation' => [ - 'https://github.com/user/repo@invalid-ref-with-special-chars!', - NULL, - NULL, - 'Invalid reference format: "invalid-ref-with-special-chars!". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid reference formats - special characters - double @' => [ - 'git@github.com:user/repo@invalid@ref', - NULL, - NULL, - 'Invalid reference format: "invalid@ref". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - wrong length - 6 chars' => [ - 'file:///path/to/repo@123456', - NULL, - NULL, - 'Invalid reference format: "123456". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - wrong length - 8 chars' => [ - 'https://github.com/user/repo@12345678', - NULL, - NULL, - 'Invalid reference format: "12345678". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - wrong length - 39 chars' => [ - 'git@github.com:user/repo@123456789012345678901234567890123456789', - NULL, - NULL, - 'Invalid reference format: "123456789012345678901234567890123456789". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - invalid characters - 40 char with g' => [ - 'https://github.com/user/repo@1234567890abcdef1234567890abcdef1234567g', - NULL, - NULL, - 'Invalid reference format: "1234567890abcdef1234567890abcdef1234567g". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - invalid characters - 7 char with g' => [ - '/path/to/repo@123456g', - NULL, - NULL, - 'Invalid reference format: "123456g". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - wrong length - 3 chars' => [ - 'https://github.com/user/repo@123', - NULL, - NULL, - 'Invalid reference format: "123". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - wrong length - 41 chars' => [ - 'git@github.com:user/repo@12345678901234567890123456789012345678901', - NULL, - NULL, - 'Invalid reference format: "12345678901234567890123456789012345678901". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Invalid commit hash formats - wrong length - 39 chars mixed' => [ - '/path/to/repo@1234567890abcdef1234567890abcdef123456789', - NULL, - NULL, - 'Invalid reference format: "1234567890abcdef1234567890abcdef123456789". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Edge cases - double @ character results in reference validation error - HTTPS' => [ - 'https://github.com/user/repo@@main', - NULL, - NULL, - 'Invalid reference format: "@main". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Edge cases - double @ character results in reference validation error - Git SSH' => [ - 'git@github.com:user/repo@@main', - NULL, - NULL, - 'Invalid reference format: "@main". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - 'Protocol-less URLs - treated as local paths - reference validation error' => [ - 'user/repo@github.com', - NULL, - NULL, - 'Invalid reference format: "github.com". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', - ], - ]; - } - - /** - * Data provider for testDiscoverLatestReleaseRemote(). - * - * @return array> - * Test data. - */ - public static function providerDiscoverLatestReleaseRemote(): array { - return [ - 'valid releases' => [ - 'repo' => 'https://github.com/user/repo', - 'releaseData' => [ - ['tag_name' => 'v2.0.0', 'draft' => FALSE], - ['tag_name' => 'v1.0.0', 'draft' => FALSE], - ], - 'throwException' => FALSE, - 'skipMockSetup' => FALSE, - 'expectedVersion' => 'v2.0.0', - 'expectedException' => NULL, - 'expectedMessage' => NULL, - ], - 'skips drafts' => [ - 'repo' => 'https://github.com/user/repo', - 'releaseData' => [ - ['tag_name' => 'v3.0.0', 'draft' => TRUE], - ['tag_name' => 'v2.0.0', 'draft' => FALSE], - ], - 'throwException' => FALSE, - 'skipMockSetup' => FALSE, - 'expectedVersion' => 'v2.0.0', - 'expectedException' => NULL, - 'expectedMessage' => NULL, - ], - 'no releases' => [ - 'repo' => 'https://github.com/user/repo', - 'releaseData' => [], - 'throwException' => FALSE, - 'skipMockSetup' => FALSE, - 'expectedVersion' => NULL, - 'expectedException' => \RuntimeException::class, - 'expectedMessage' => 'Unable to discover the latest release', - ], - 'request exception' => [ - 'repo' => 'https://github.com/user/repo', - 'releaseData' => NULL, - 'throwException' => TRUE, - 'skipMockSetup' => FALSE, - 'expectedVersion' => NULL, - 'expectedException' => \RuntimeException::class, - 'expectedMessage' => 'Unable to download release information from', - ], - 'empty response' => [ - 'repo' => 'https://github.com/user/repo', - 'releaseData' => '', - 'throwException' => FALSE, - 'skipMockSetup' => FALSE, - 'expectedVersion' => NULL, - 'expectedException' => \RuntimeException::class, - 'expectedMessage' => 'Unable to download release information from', - ], - 'invalid url' => [ - 'repo' => 'https://', - 'releaseData' => NULL, - 'throwException' => FALSE, - 'skipMockSetup' => TRUE, - 'expectedVersion' => NULL, - 'expectedException' => \RuntimeException::class, - 'expectedMessage' => 'Invalid repository URL', - ], - 'SemVer+CalVer format - single release' => [ - 'repo' => 'https://github.com/drevops/vortex', - 'releaseData' => [ - ['tag_name' => '1.0.0-2025.11.0', 'draft' => FALSE], - ], - 'throwException' => FALSE, - 'skipMockSetup' => FALSE, - 'expectedVersion' => '1.0.0-2025.11.0', - 'expectedException' => NULL, - 'expectedMessage' => NULL, - ], - 'SemVer+CalVer format - multiple releases' => [ - 'repo' => 'https://github.com/drevops/vortex', - 'releaseData' => [ - ['tag_name' => '1.2.0-2025.12.0', 'draft' => FALSE], - ['tag_name' => '1.1.0-2025.11.0', 'draft' => FALSE], - ['tag_name' => '1.0.0-2025.10.0', 'draft' => FALSE], - ], - 'throwException' => FALSE, - 'skipMockSetup' => FALSE, - 'expectedVersion' => '1.2.0-2025.12.0', - 'expectedException' => NULL, - 'expectedMessage' => NULL, - ], - 'SemVer+CalVer format - skip draft' => [ - 'repo' => 'https://github.com/drevops/vortex', - 'releaseData' => [ - ['tag_name' => '2.0.0-2026.01.0', 'draft' => TRUE], - ['tag_name' => '1.0.0-2025.11.0', 'draft' => FALSE], - ], - 'throwException' => FALSE, - 'skipMockSetup' => FALSE, - 'expectedVersion' => '1.0.0-2025.11.0', - 'expectedException' => NULL, - 'expectedMessage' => NULL, - ], - 'Mixed format - SemVer+CalVer and CalVer' => [ - 'repo' => 'https://github.com/drevops/vortex', - 'releaseData' => [ - ['tag_name' => '1.0.0-2025.11.0', 'draft' => FALSE], - ['tag_name' => '25.10.0', 'draft' => FALSE], - ['tag_name' => '25.9.0', 'draft' => FALSE], - ], - 'throwException' => FALSE, - 'skipMockSetup' => FALSE, - 'expectedVersion' => '1.0.0-2025.11.0', - 'expectedException' => NULL, - 'expectedMessage' => NULL, - ], - ]; - } - - /** - * Data provider for testDownloadWithNullDestination(). - * - * @return array> - * Test data. - */ - public static function providerDownloadWithNullDestination(): array { - return [ - 'remote repository' => [ - 'repo' => 'https://github.com/user/repo', - 'expectedMessage' => 'Destination cannot be null for remote downloads', - ], - 'local repository' => [ - 'repo' => '/path/to/repo', - 'expectedMessage' => 'Destination cannot be null for local downloads', - ], - ]; - } - - /** - * Data provider for testDownloadFromLocal(). - * - * @return array> - * Test data. - */ - public static function providerDownloadFromLocal(): array { - return [ - 'HEAD ref' => [ - 'ref' => 'HEAD', - 'expectedVersion' => 'develop', - ], - 'stable ref' => [ - 'ref' => 'stable', - 'expectedVersion' => 'develop', - ], - 'commit hash' => [ - 'ref' => 'COMMIT_HASH', - 'expectedVersion' => 'COMMIT_HASH', - ], - ]; - } - - protected function createMockHttpClient(int $statusCode = 200, string $bodyContent = 'mock content'): ClientInterface { - $mock_client = $this->createMock(ClientInterface::class); - $mock_response = $this->createMock(ResponseInterface::class); - $mock_body = $this->createMock(StreamInterface::class); - $mock_response->method('getBody')->willReturn($mock_body); - $mock_body->method('getContents')->willReturn($bodyContent); - $mock_response->method('getStatusCode')->willReturn($statusCode); - $mock_client->method('request')->willReturn($mock_response); - return $mock_client; - } - - protected function createMockArchiver(): MockObject { - return $this->createMock(ArchiverInterface::class); - } - - protected function createGitRepo(): string { - $temp_repo_dir = self::$tmp . '/test_git_repo_' . uniqid(); - File::mkdir($temp_repo_dir); - $original_dir = (string) getcwd(); - chdir($temp_repo_dir); - exec('git init 2>&1'); - exec('git config user.email "test@example.com" 2>&1'); - exec('git config user.name "Test User" 2>&1'); - File::dump($temp_repo_dir . '/test.txt', 'test content'); - exec('git add . 2>&1'); - exec('git commit -m "Initial commit" 2>&1'); - File::dump($temp_repo_dir . '/composer.json', '{}'); - exec('git add composer.json 2>&1'); - exec('git commit -m "Add composer.json" 2>&1'); - chdir($original_dir); - return $temp_repo_dir; - } - - protected function removeGitRepo(string $repo_dir): void { - exec('rm -rf ' . escapeshellarg($repo_dir) . ' 2>&1'); - } - - protected function createMockArchiverWithExtract(): MockObject { - $mock_archiver = $this->createMockArchiver(); - $mock_archiver->expects($this->once())->method('validate'); - $mock_archiver->expects($this->once())->method('extract')->willReturnCallback(function ($archive, string $dest): void { - File::dump($dest . '/composer.json', '{}'); - }); - return $mock_archiver; + $this->assertInstanceOf(Downloader::class, $downloader); } } diff --git a/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php b/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php new file mode 100644 index 000000000..38e78481d --- /dev/null +++ b/.vortex/installer/tests/Unit/Downloader/RepositoryDownloaderTest.php @@ -0,0 +1,910 @@ +expectException(\RuntimeException::class); + $this->expectExceptionMessage($expected_exception_message); + } + + $result = RepositoryDownloader::parseUri($src); + + if (is_null($expected_exception_message)) { + $this->assertCount(2, $result); + $this->assertEquals($expected_repo, $result[0], 'Repository matches input: ' . $src); + $this->assertEquals($expected_ref, $result[1], 'Reference matches input: ' . $src); + } + } + + public function testDownloadWithMockedArchiver(): void { + $mock_http_client = $this->createMockHttpClient(); + /** @var \PHPUnit\Framework\MockObject\MockObject&\DrevOps\VortexInstaller\Downloader\ArchiverInterface $mock_archiver */ + $mock_archiver = $this->createMockArchiver(); + $mock_archiver->expects($this->once())->method('validate'); + $mock_archiver->expects($this->once())->method('extract'); + $mock_file_downloader = $this->createMockFileDownloader(); + $destination = self::$tmp . '/destination'; + File::mkdir($destination); + File::dump($destination . '/composer.json', '{}'); + $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); + $version = $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + $this->assertEquals('develop', $version); + } + + public function testDownloadThrowsExceptionWhenComposerJsonMissing(): void { + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_archiver = $this->createMock(ArchiverInterface::class); + $mock_file_downloader = $this->createMockFileDownloader(); + + $destination = self::$tmp . '/destination'; + File::mkdir($destination); + + $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The downloaded repository does not contain a composer.json file.'); + + $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + } + + public function testDownloadFromRemoteCallsArchiverCorrectly(): void { + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_archiver = $this->createMock(ArchiverInterface::class); + $mock_file_downloader = $this->createMockFileDownloader(); + + $mock_archiver->expects($this->once())->method('validate')->with($this->stringContains('vortex_archive_')); + $mock_archiver->expects($this->once())->method('extract')->with($this->stringContains('vortex_archive_'), $this->anything(), TRUE); + + $destination = self::$tmp . '/destination'; + File::mkdir($destination); + File::dump($destination . '/composer.json', '{}'); + + $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); + $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + } + + public function testDownloadArchiveCreatesTemporaryFile(): void { + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_archiver = $this->createMock(ArchiverInterface::class); + $mock_file_downloader = $this->createMockFileDownloader(); + $destination = self::$tmp . '/destination'; + File::mkdir($destination); + File::dump($destination . '/composer.json', '{}'); + $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); + $version = $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + $this->assertEquals('develop', $version); + } + + public function testDownloadArchiveHandlesHttpError(): void { + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_archiver = $this->createMock(ArchiverInterface::class); + $mock_file_downloader = $this->createMock(Downloader::class); + $mock_file_downloader->method('download')->willThrowException(new \RuntimeException('HTTP 404 error')); + $destination = self::$tmp . '/destination'; + File::mkdir($destination); + $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to download archive from'); + $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + } + + public function testDownloadArchiveHandlesRequestException(): void { + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_archiver = $this->createMock(ArchiverInterface::class); + $mock_file_downloader = $this->createMock(Downloader::class); + $mock_file_downloader->method('download')->willThrowException(new \RuntimeException('Network error')); + $destination = self::$tmp . '/destination'; + File::mkdir($destination); + $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to download archive from'); + $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + } + + #[DataProvider('providerDiscoverLatestReleaseRemote')] + public function testDiscoverLatestReleaseRemote(string $repo, mixed $releaseData, bool $throwException, bool $skipMockSetup, ?string $expectedVersion, ?string $expectedException, ?string $expectedMessage): void { + $mock_http_client = $this->createMock(ClientInterface::class); + + if (!$skipMockSetup) { + if ($throwException) { + $mock_http_client->method('request')->willThrowException(new RequestException('API error', $this->createMock(RequestInterface::class))); + } + else { + $mock_response = $this->createMock(ResponseInterface::class); + $mock_body = $this->createMock(StreamInterface::class); + $mock_response->method('getBody')->willReturn($mock_body); + + $release_json = is_array($releaseData) ? json_encode($releaseData) : $releaseData; + + $mock_body->method('getContents')->willReturn($release_json); + $mock_response->method('getStatusCode')->willReturn(200); + + // Only the API call uses httpClient now. + $mock_http_client->method('request')->willReturn($mock_response); + } + } + + $mock_archiver = $this->createMock(ArchiverInterface::class); + $mock_file_downloader = $this->createMockFileDownloader(); + $destination = self::$tmp . '/destination_' . uniqid(); + File::mkdir($destination); + + if ($expectedVersion !== NULL) { + File::dump($destination . '/composer.json', '{}'); + } + + $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); + + if ($expectedException !== NULL) { + /** @var class-string<\Throwable> $expectedException */ + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedMessage); + } + + $version = $downloader->download($repo, 'stable', $destination); + + if ($expectedVersion !== NULL) { + $this->assertEquals($expectedVersion, $version); + } + } + + public function testDownloadFromRemoteWithGitSuffix(): void { + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_response = $this->createMock(ResponseInterface::class); + $mock_response->method('getStatusCode')->willReturn(200); + $mock_http_client->method('request')->willReturn($mock_response); + $mock_archiver = $this->createMock(ArchiverInterface::class); + $mock_file_downloader = $this->createMockFileDownloader(); + $destination = self::$tmp . '/destination'; + File::mkdir($destination); + File::dump($destination . '/composer.json', '{}'); + $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); + $version = $downloader->download('https://github.com/user/repo.git', 'HEAD', $destination); + $this->assertEquals('develop', $version); + } + + #[DataProvider('providerDownloadWithNullDestination')] + public function testDownloadWithNullDestination(string $repo, string $expectedMessage): void { + $downloader = new RepositoryDownloader(); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + $downloader->download($repo, 'HEAD'); + } + + #[DataProvider('providerDownloadFromLocal')] + public function testDownloadFromLocal(string $ref, string $expectedVersion): void { + $temp_repo_dir = $this->createGitRepo(); + $destination = self::$tmp . '/dest_' . uniqid(); + File::mkdir($destination); + + // Handle the special case where we need to get the actual commit hash. + if ($ref === 'COMMIT_HASH') { + $output = shell_exec('cd ' . escapeshellarg($temp_repo_dir) . ' && git rev-parse HEAD'); + $this->assertIsString($output, 'Failed to get commit hash from git repository'); + $commit_hash = trim($output); + $this->assertNotEmpty($commit_hash, 'Git rev-parse returned empty output'); + $ref = substr($commit_hash, 0, 7); + $expectedVersion = $ref; + } + + /** @var \PHPUnit\Framework\MockObject\MockObject&\DrevOps\VortexInstaller\Downloader\ArchiverInterface $mock_archiver */ + $mock_archiver = $this->createMockArchiverWithExtract(); + $downloader = new RepositoryDownloader(NULL, $mock_archiver); + $version = $downloader->download($temp_repo_dir, $ref, $destination); + $this->assertEquals($expectedVersion, $version); + $this->removeGitRepo($temp_repo_dir); + } + + public function testArchiveFromLocalHandlesGitFailure(): void { + $temp_repo_dir = self::$tmp . '/test_git_repo_' . uniqid(); + $temp_dest_dir = self::$tmp . '/test_dest_' . uniqid(); + File::mkdir($temp_repo_dir); + File::mkdir($temp_dest_dir); + chdir($temp_repo_dir); + exec('git init 2>&1'); + exec('git config user.email "test@example.com" 2>&1'); + exec('git config user.name "Test User" 2>&1'); + File::dump($temp_repo_dir . '/test.txt', 'test content'); + exec('git add . 2>&1'); + exec('git commit -m "Initial commit" 2>&1'); + $downloader = new RepositoryDownloader(); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Failed to create archive from local repository'); + $downloader->download($temp_repo_dir, 'nonexistent-ref', $temp_dest_dir); + } + + public function testDiscoverLatestReleaseRemoteWithGithubToken(): void { + static::envSet('GITHUB_TOKEN', 'test_token_12345'); + $release_json = json_encode([ + ['tag_name' => 'v1.5.0', 'draft' => FALSE], + ]); + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_response = $this->createMock(ResponseInterface::class); + $mock_body = $this->createMock(StreamInterface::class); + $mock_response->method('getBody')->willReturn($mock_body); + $mock_body->method('getContents')->willReturn($release_json); + $mock_response->method('getStatusCode')->willReturn(200); + // Only the API call uses httpClient now. + $mock_http_client->expects($this->once())->method('request')->willReturnCallback(function ($method, $url, array|\ArrayAccess $options) use ($mock_response): ResponseInterface { + $this->assertArrayHasKey('headers', $options); + $this->assertArrayHasKey('Authorization', $options['headers']); + $this->assertEquals('Bearer test_token_12345', $options['headers']['Authorization']); + return $mock_response; + }); + $mock_archiver = $this->createMock(ArchiverInterface::class); + // File downloader should receive the token in headers. + $mock_file_downloader = $this->createMock(Downloader::class); + $mock_file_downloader->expects($this->once())->method('download')->willReturnCallback(function ($url, $dest, array $headers): void { + $this->assertArrayHasKey('Authorization', $headers); + $this->assertEquals('Bearer test_token_12345', $headers['Authorization']); + }); + $destination = self::$tmp . '/destination'; + File::mkdir($destination); + File::dump($destination . '/composer.json', '{}'); + $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); + $version = $downloader->download('https://github.com/user/repo', 'stable', $destination); + $this->assertEquals('v1.5.0', $version); + } + + public function testDownloadArchiveWithGithubToken(): void { + static::envSet('GITHUB_TOKEN', 'test_token_67890'); + $mock_http_client = $this->createMock(ClientInterface::class); + $mock_archiver = $this->createMock(ArchiverInterface::class); + // File downloader should receive the token in headers. + $mock_file_downloader = $this->createMock(Downloader::class); + $mock_file_downloader->expects($this->once())->method('download')->willReturnCallback(function ($url, $dest, array $headers): void { + $this->assertArrayHasKey('Authorization', $headers); + $this->assertEquals('Bearer test_token_67890', $headers['Authorization']); + }); + $destination = self::$tmp . '/destination'; + File::mkdir($destination); + File::dump($destination . '/composer.json', '{}'); + $downloader = new RepositoryDownloader($mock_http_client, $mock_archiver, NULL, $mock_file_downloader); + $version = $downloader->download('https://github.com/user/repo', 'HEAD', $destination); + $this->assertEquals('develop', $version); + } + + public static function dataProviderParseUri(): array { + return [ + // Valid test cases. + 'HTTPS URLs - with default HEAD reference - GitHub' => [ + 'https://github.com/user/repo', + 'https://github.com/user/repo', + RepositoryDownloader::REF_HEAD, + + ], + 'HTTPS URLs - with default HEAD reference - GitLab' => [ + 'https://gitlab.com/user/repo', + 'https://gitlab.com/user/repo', + RepositoryDownloader::REF_HEAD, + + ], + 'HTTPS URLs - with default HEAD reference - Bitbucket' => [ + 'https://bitbucket.org/user/repo', + 'https://bitbucket.org/user/repo', + RepositoryDownloader::REF_HEAD, + + ], + 'HTTPS URLs - with specific valid references - stable' => [ + 'https://github.com/user/repo@stable', + 'https://github.com/user/repo', + RepositoryDownloader::REF_STABLE, + + ], + 'HTTPS URLs - with specific valid references - HEAD' => [ + 'https://github.com/user/repo@HEAD', + 'https://github.com/user/repo', + RepositoryDownloader::REF_HEAD, + + ], + 'HTTPS URLs - with 40-character commit hash' => [ + 'https://github.com/user/repo@1234567890abcdef1234567890abcdef12345678', + 'https://github.com/user/repo', + '1234567890abcdef1234567890abcdef12345678', + + ], + 'HTTPS URLs - with 7-character short commit hash' => [ + 'https://github.com/user/repo@1234567', + 'https://github.com/user/repo', + '1234567', + + ], + 'Git SSH URLs - with default HEAD reference - GitHub' => [ + 'git@github.com:user/repo', + 'git@github.com:user/repo', + RepositoryDownloader::REF_HEAD, + ], + 'Git SSH URLs - with default HEAD reference - GitLab' => [ + 'git@gitlab.com:user/repo', + 'git@gitlab.com:user/repo', + RepositoryDownloader::REF_HEAD, + ], + 'Git SSH URLs - with default HEAD reference - Bitbucket' => [ + 'git@bitbucket.org:user/repo', + 'git@bitbucket.org:user/repo', + RepositoryDownloader::REF_HEAD, + ], + 'Git SSH URLs - with specific valid references - stable' => [ + 'git@github.com:user/repo@stable', + 'git@github.com:user/repo', + RepositoryDownloader::REF_STABLE, + ], + 'Git SSH URLs - with specific valid references - HEAD' => [ + 'git@github.com:user/repo@HEAD', + 'git@github.com:user/repo', + RepositoryDownloader::REF_HEAD, + ], + 'Git SSH URLs - with commit hashes - 40 char' => [ + 'git@github.com:user/repo@1234567890abcdef1234567890abcdef12345678', + 'git@github.com:user/repo', + '1234567890abcdef1234567890abcdef12345678', + ], + 'Git SSH URLs - with commit hashes - 7 char' => [ + 'git@github.com:user/repo@1234567', + 'git@github.com:user/repo', + '1234567', + ], + 'File URLs - with default HEAD reference' => [ + 'file:///path/to/repo', + '/path/to/repo', + RepositoryDownloader::REF_HEAD, + ], + 'File URLs - with default HEAD reference - user path' => [ + 'file:///home/user/repos/myrepo', + '/home/user/repos/myrepo', + RepositoryDownloader::REF_HEAD, + ], + 'File URLs - with specific valid references - stable' => [ + 'file:///path/to/repo@stable', + '/path/to/repo', + RepositoryDownloader::REF_STABLE, + ], + 'File URLs - with specific valid references - HEAD' => [ + 'file:///path/to/repo@HEAD', + '/path/to/repo', + RepositoryDownloader::REF_HEAD, + ], + 'File URLs - with 40-character commit hash' => [ + 'file:///path/to/repo@1234567890abcdef1234567890abcdef12345678', + '/path/to/repo', + '1234567890abcdef1234567890abcdef12345678', + ], + 'File URLs - with 7-character commit hash' => [ + 'file:///path/to/repo@1234567', + '/path/to/repo', + '1234567', + ], + 'Local paths - with default HEAD reference - absolute' => [ + '/path/to/repo', + '/path/to/repo', + RepositoryDownloader::REF_HEAD, + ], + 'Local paths - with default HEAD reference - user home' => [ + '/home/user/repos/myrepo', + '/home/user/repos/myrepo', + RepositoryDownloader::REF_HEAD, + ], + 'Local paths - with default HEAD reference - relative' => [ + 'relative/path/to/repo', + 'relative/path/to/repo', + RepositoryDownloader::REF_HEAD, + ], + 'Local paths - with default HEAD reference - current dir' => [ + './repo', + './repo', + RepositoryDownloader::REF_HEAD, + ], + 'Local paths - with default HEAD reference - parent dir' => [ + '../repo', + '../repo', + RepositoryDownloader::REF_HEAD, + ], + 'Local paths - with specific valid references - stable' => [ + '/path/to/repo@stable', + '/path/to/repo', + RepositoryDownloader::REF_STABLE, + ], + 'Local paths - with specific valid references - HEAD' => [ + '/path/to/repo@HEAD', + '/path/to/repo', + RepositoryDownloader::REF_HEAD, + ], + 'Local paths - with 40-character commit hash' => [ + '/path/to/repo@1234567890abcdef1234567890abcdef12345678', + '/path/to/repo', + '1234567890abcdef1234567890abcdef12345678', + ], + 'Local paths - with 7-character commit hash' => [ + '/path/to/repo@1234567', + '/path/to/repo', + '1234567', + ], + 'Local paths with trailing slashes - should be trimmed - single slash' => [ + '/path/to/repo/', + '/path/to/repo', + RepositoryDownloader::REF_HEAD, + ], + 'Local paths with trailing slashes - should be trimmed - double slash' => [ + '/path/to/repo//', + '/path/to/repo', + RepositoryDownloader::REF_HEAD, + ], + 'Local paths with trailing slashes - should be trimmed - with reference' => [ + '/path/to/repo/@stable', + '/path/to/repo', + RepositoryDownloader::REF_STABLE, + ], + 'Relative paths - simple' => [ + 'repo', + 'repo', + RepositoryDownloader::REF_HEAD, + ], + 'Relative paths - with reference' => [ + 'repo@stable', + 'repo', + RepositoryDownloader::REF_STABLE, + ], + 'Edge cases with valid commit hashes - uppercase 40 char' => [ + 'https://github.com/user/repo@ABCDEF1234567890ABCDEF1234567890ABCDEF12', + 'https://github.com/user/repo', + 'ABCDEF1234567890ABCDEF1234567890ABCDEF12', + ], + 'Edge cases with valid commit hashes - uppercase 7 char' => [ + 'git@github.com:user/repo@ABCDEF1', + 'git@github.com:user/repo', + 'ABCDEF1', + ], + 'Edge cases that are actually valid - HTTPS with extra path' => [ + 'https://github.com/user/repo/extra/path', + 'https://github.com/user/repo/extra/path', + RepositoryDownloader::REF_HEAD, + ], + 'Edge cases that are actually valid - Git SSH with extra path' => [ + 'git@github.com:user/repo/extra', + 'git@github.com:user/repo/extra', + RepositoryDownloader::REF_HEAD, + ], + 'Edge cases that are actually valid - protocol-less GitHub' => [ + 'github.com/user/repo', + 'github.com/user/repo', + RepositoryDownloader::REF_HEAD, + ], + 'Edge cases that are actually valid - file root' => [ + 'file:///', + '/', + RepositoryDownloader::REF_HEAD, + ], + 'Empty reference defaults to HEAD - @ is captured in repo part' => [ + '/path/to/repo@', + '/path/to/repo@', + RepositoryDownloader::REF_HEAD, + ], + + // Invalid test cases. + 'Invalid HTTPS URL formats - missing repo' => [ + 'https://github.com', + NULL, + NULL, + 'Invalid remote repository format: "https://github.com".', + ], + 'Invalid HTTPS URL formats - missing repo with slash' => [ + 'https://github.com/', + NULL, + NULL, + 'Invalid remote repository format: "https://github.com/".', + ], + 'Invalid HTTPS URL formats - missing repo name' => [ + 'https://github.com/user', + NULL, + NULL, + 'Invalid remote repository format: "https://github.com/user".', + ], + 'Invalid HTTPS URL formats - missing repo name with slash' => [ + 'https://github.com/user/', + NULL, + NULL, + 'Invalid remote repository format: "https://github.com/user/".', + ], + 'Invalid HTTPS URL formats - malformed with reference' => [ + 'https://github.com/user@main', + NULL, + NULL, + 'Invalid remote repository format: "https://github.com/user@main".', + ], + 'Invalid HTTPS URL formats - protocol only' => [ + 'https://', + NULL, + NULL, + 'Invalid remote repository format: "https://".', + ], + 'Invalid HTTPS URL formats - protocol with slash' => [ + 'https:///', + NULL, + NULL, + 'Invalid remote repository format: "https:///".', + ], + 'Invalid Git SSH URL formats - missing colon' => [ + 'git@github.com', + NULL, + NULL, + 'Invalid remote repository format: "git@github.com".', + ], + 'Invalid Git SSH URL formats - empty after colon' => [ + 'git@github.com:', + NULL, + NULL, + 'Invalid remote repository format: "git@github.com:".', + ], + 'Invalid Git SSH URL formats - missing repo name' => [ + 'git@github.com:user', + NULL, + NULL, + 'Invalid remote repository format: "git@github.com:user".', + ], + 'Invalid Git SSH URL formats - malformed with reference' => [ + 'git@github.com:user@main', + NULL, + NULL, + 'Invalid remote repository format: "git@github.com:user@main".', + ], + 'Invalid Git SSH URL formats - empty user' => [ + 'git@', + NULL, + NULL, + 'Invalid remote repository format: "git@".', + ], + 'Invalid Git SSH URL formats - empty host' => [ + 'git@:user/repo', + NULL, + NULL, + 'Invalid remote repository format: "git@:user/repo".', + ], + 'Invalid file URL formats - empty path' => [ + 'file://', + NULL, + NULL, + 'Invalid local repository format: "file://".', + ], + 'Invalid reference formats - branch names not allowed - main' => [ + 'https://github.com/user/repo@main', + NULL, + NULL, + 'Invalid reference format: "main". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Invalid reference formats - branch names not allowed - develop' => [ + 'git@github.com:user/repo@develop', + NULL, + NULL, + 'Invalid reference format: "develop". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Invalid reference formats - branch names not allowed - version tag' => [ + '/path/to/repo@v1.0.0', + NULL, + NULL, + 'Invalid reference format: "v1.0.0". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Invalid reference formats - branch names not allowed - feature branch' => [ + 'file:///path/to/repo@feature-branch', + NULL, + NULL, + 'Invalid reference format: "feature-branch". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Invalid reference formats - special characters - exclamation' => [ + 'https://github.com/user/repo@invalid-ref-with-special-chars!', + NULL, + NULL, + 'Invalid reference format: "invalid-ref-with-special-chars!". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Invalid reference formats - special characters - double @' => [ + 'git@github.com:user/repo@invalid@ref', + NULL, + NULL, + 'Invalid reference format: "invalid@ref". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Invalid commit hash formats - wrong length - 6 chars' => [ + 'file:///path/to/repo@123456', + NULL, + NULL, + 'Invalid reference format: "123456". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Invalid commit hash formats - wrong length - 8 chars' => [ + 'https://github.com/user/repo@12345678', + NULL, + NULL, + 'Invalid reference format: "12345678". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Invalid commit hash formats - wrong length - 39 chars' => [ + 'git@github.com:user/repo@123456789012345678901234567890123456789', + NULL, + NULL, + 'Invalid reference format: "123456789012345678901234567890123456789". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Invalid commit hash formats - invalid characters - 40 char with g' => [ + 'https://github.com/user/repo@1234567890abcdef1234567890abcdef1234567g', + NULL, + NULL, + 'Invalid reference format: "1234567890abcdef1234567890abcdef1234567g". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Invalid commit hash formats - invalid characters - 7 char with g' => [ + '/path/to/repo@123456g', + NULL, + NULL, + 'Invalid reference format: "123456g". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Invalid commit hash formats - wrong length - 3 chars' => [ + 'https://github.com/user/repo@123', + NULL, + NULL, + 'Invalid reference format: "123". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Invalid commit hash formats - wrong length - 41 chars' => [ + 'git@github.com:user/repo@12345678901234567890123456789012345678901', + NULL, + NULL, + 'Invalid reference format: "12345678901234567890123456789012345678901". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Invalid commit hash formats - wrong length - 39 chars mixed' => [ + '/path/to/repo@1234567890abcdef1234567890abcdef123456789', + NULL, + NULL, + 'Invalid reference format: "1234567890abcdef1234567890abcdef123456789". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Edge cases - double @ character results in reference validation error - HTTPS' => [ + 'https://github.com/user/repo@@main', + NULL, + NULL, + 'Invalid reference format: "@main". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Edge cases - double @ character results in reference validation error - Git SSH' => [ + 'git@github.com:user/repo@@main', + NULL, + NULL, + 'Invalid reference format: "@main". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + 'Protocol-less URLs - treated as local paths - reference validation error' => [ + 'user/repo@github.com', + NULL, + NULL, + 'Invalid reference format: "github.com". Supported formats are: stable, HEAD, 40-character commit hash, 7-character commit hash.', + ], + ]; + } + + /** + * Data provider for testDiscoverLatestReleaseRemote(). + * + * @return array> + * Test data. + */ + public static function providerDiscoverLatestReleaseRemote(): array { + return [ + 'valid releases' => [ + 'repo' => 'https://github.com/user/repo', + 'releaseData' => [ + ['tag_name' => 'v2.0.0', 'draft' => FALSE], + ['tag_name' => 'v1.0.0', 'draft' => FALSE], + ], + 'throwException' => FALSE, + 'skipMockSetup' => FALSE, + 'expectedVersion' => 'v2.0.0', + 'expectedException' => NULL, + 'expectedMessage' => NULL, + ], + 'skips drafts' => [ + 'repo' => 'https://github.com/user/repo', + 'releaseData' => [ + ['tag_name' => 'v3.0.0', 'draft' => TRUE], + ['tag_name' => 'v2.0.0', 'draft' => FALSE], + ], + 'throwException' => FALSE, + 'skipMockSetup' => FALSE, + 'expectedVersion' => 'v2.0.0', + 'expectedException' => NULL, + 'expectedMessage' => NULL, + ], + 'no releases' => [ + 'repo' => 'https://github.com/user/repo', + 'releaseData' => [], + 'throwException' => FALSE, + 'skipMockSetup' => FALSE, + 'expectedVersion' => NULL, + 'expectedException' => \RuntimeException::class, + 'expectedMessage' => 'Unable to discover the latest release', + ], + 'request exception' => [ + 'repo' => 'https://github.com/user/repo', + 'releaseData' => NULL, + 'throwException' => TRUE, + 'skipMockSetup' => FALSE, + 'expectedVersion' => NULL, + 'expectedException' => \RuntimeException::class, + 'expectedMessage' => 'Unable to download release information from', + ], + 'empty response' => [ + 'repo' => 'https://github.com/user/repo', + 'releaseData' => '', + 'throwException' => FALSE, + 'skipMockSetup' => FALSE, + 'expectedVersion' => NULL, + 'expectedException' => \RuntimeException::class, + 'expectedMessage' => 'Unable to download release information from', + ], + 'invalid url' => [ + 'repo' => 'https://', + 'releaseData' => NULL, + 'throwException' => FALSE, + 'skipMockSetup' => TRUE, + 'expectedVersion' => NULL, + 'expectedException' => \RuntimeException::class, + 'expectedMessage' => 'Invalid repository URL', + ], + 'SemVer+CalVer format - single release' => [ + 'repo' => 'https://github.com/drevops/vortex', + 'releaseData' => [ + ['tag_name' => '1.0.0-2025.11.0', 'draft' => FALSE], + ], + 'throwException' => FALSE, + 'skipMockSetup' => FALSE, + 'expectedVersion' => '1.0.0-2025.11.0', + 'expectedException' => NULL, + 'expectedMessage' => NULL, + ], + 'SemVer+CalVer format - multiple releases' => [ + 'repo' => 'https://github.com/drevops/vortex', + 'releaseData' => [ + ['tag_name' => '1.2.0-2025.12.0', 'draft' => FALSE], + ['tag_name' => '1.1.0-2025.11.0', 'draft' => FALSE], + ['tag_name' => '1.0.0-2025.10.0', 'draft' => FALSE], + ], + 'throwException' => FALSE, + 'skipMockSetup' => FALSE, + 'expectedVersion' => '1.2.0-2025.12.0', + 'expectedException' => NULL, + 'expectedMessage' => NULL, + ], + 'SemVer+CalVer format - skip draft' => [ + 'repo' => 'https://github.com/drevops/vortex', + 'releaseData' => [ + ['tag_name' => '2.0.0-2026.01.0', 'draft' => TRUE], + ['tag_name' => '1.0.0-2025.11.0', 'draft' => FALSE], + ], + 'throwException' => FALSE, + 'skipMockSetup' => FALSE, + 'expectedVersion' => '1.0.0-2025.11.0', + 'expectedException' => NULL, + 'expectedMessage' => NULL, + ], + 'Mixed format - SemVer+CalVer and CalVer' => [ + 'repo' => 'https://github.com/drevops/vortex', + 'releaseData' => [ + ['tag_name' => '1.0.0-2025.11.0', 'draft' => FALSE], + ['tag_name' => '25.10.0', 'draft' => FALSE], + ['tag_name' => '25.9.0', 'draft' => FALSE], + ], + 'throwException' => FALSE, + 'skipMockSetup' => FALSE, + 'expectedVersion' => '1.0.0-2025.11.0', + 'expectedException' => NULL, + 'expectedMessage' => NULL, + ], + ]; + } + + /** + * Data provider for testDownloadWithNullDestination(). + * + * @return array> + * Test data. + */ + public static function providerDownloadWithNullDestination(): array { + return [ + 'remote repository' => [ + 'repo' => 'https://github.com/user/repo', + 'expectedMessage' => 'Destination cannot be null for remote downloads', + ], + 'local repository' => [ + 'repo' => '/path/to/repo', + 'expectedMessage' => 'Destination cannot be null for local downloads', + ], + ]; + } + + /** + * Data provider for testDownloadFromLocal(). + * + * @return array> + * Test data. + */ + public static function providerDownloadFromLocal(): array { + return [ + 'HEAD ref' => [ + 'ref' => 'HEAD', + 'expectedVersion' => 'develop', + ], + 'stable ref' => [ + 'ref' => 'stable', + 'expectedVersion' => 'develop', + ], + 'commit hash' => [ + 'ref' => 'COMMIT_HASH', + 'expectedVersion' => 'COMMIT_HASH', + ], + ]; + } + + protected function createMockHttpClient(int $statusCode = 200, string $bodyContent = 'mock content'): ClientInterface { + $mock_client = $this->createMock(ClientInterface::class); + $mock_response = $this->createMock(ResponseInterface::class); + $mock_body = $this->createMock(StreamInterface::class); + $mock_response->method('getBody')->willReturn($mock_body); + $mock_body->method('getContents')->willReturn($bodyContent); + $mock_response->method('getStatusCode')->willReturn($statusCode); + $mock_client->method('request')->willReturn($mock_response); + return $mock_client; + } + + protected function createMockArchiver(): MockObject { + return $this->createMock(ArchiverInterface::class); + } + + protected function createGitRepo(): string { + $temp_repo_dir = self::$tmp . '/test_git_repo_' . uniqid(); + File::mkdir($temp_repo_dir); + $original_dir = (string) getcwd(); + chdir($temp_repo_dir); + exec('git init 2>&1'); + exec('git config user.email "test@example.com" 2>&1'); + exec('git config user.name "Test User" 2>&1'); + File::dump($temp_repo_dir . '/test.txt', 'test content'); + exec('git add . 2>&1'); + exec('git commit -m "Initial commit" 2>&1'); + File::dump($temp_repo_dir . '/composer.json', '{}'); + exec('git add composer.json 2>&1'); + exec('git commit -m "Add composer.json" 2>&1'); + chdir($original_dir); + return $temp_repo_dir; + } + + protected function removeGitRepo(string $repo_dir): void { + File::rmdir($repo_dir); + } + + protected function createMockArchiverWithExtract(): MockObject { + $mock_archiver = $this->createMockArchiver(); + $mock_archiver->expects($this->once())->method('validate'); + $mock_archiver->expects($this->once())->method('extract')->willReturnCallback(function ($archive, string $dest): void { + File::dump($dest . '/composer.json', '{}'); + }); + return $mock_archiver; + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject&\DrevOps\VortexInstaller\Downloader\Downloader + * Mock file downloader. + */ + protected function createMockFileDownloader(): MockObject { + return $this->createMock(Downloader::class); + } + +}