diff --git a/docs/api/inspectors.md b/docs/api/inspectors.md index e2b5ca5..b84258c 100644 --- a/docs/api/inspectors.md +++ b/docs/api/inspectors.md @@ -18,6 +18,18 @@ ### SecurityInspector ### QualityInspector +`QualityInspector` combines package metadata and repository contents into a score from 0 to 100. Each available quality signal contributes 15 points: + +- README +- license +- tests +- description +- repository URL +- `.gitattributes` +- `.gitignore` + +Ignored example, demo, documentation, test, and specification files reduce the score by 2 points each, up to a maximum 15-point penalty. Scores are capped between 0 and 100 and graded as follows: A from 90, B from 75, C from 60, D from 40, and F below 40. + ## Usage Patterns ### Single Inspector Usage @@ -40,4 +52,4 @@ ### Provider Ordering ### Parallel Execution -### Caching Strategy \ No newline at end of file +### Caching Strategy diff --git a/docs/bridges/packagist.md b/docs/bridges/packagist.md index ecef3aa..7d66d21 100644 --- a/docs/bridges/packagist.md +++ b/docs/bridges/packagist.md @@ -86,7 +86,7 @@ Determines latest release and commit time from available versions. Uses GitHub API as a fallback to fetch YAML advisories for the package path under `FriendsOfPHP/security-advisories`. ### **Content Provider** -Downloads the distribution archive of the latest version using `SecureFileHandler` and analyses its files. +Downloads the distribution archive of the latest version using `SecureFileHandler` and analyses its files. When constructing the provider directly, its optional third argument selects the parent directory for temporary extraction; it defaults to `sys_get_temp_dir()`. ### **Statistics Provider** `ComposerDownloadStatsProvider` wraps `PackagistApiClient` to expose download counts as `DownloadStats` models. diff --git a/src/Auth/EnvAuthenticationManager.php b/src/Auth/EnvAuthenticationManager.php index 9c60fc3..d3c0c19 100644 --- a/src/Auth/EnvAuthenticationManager.php +++ b/src/Auth/EnvAuthenticationManager.php @@ -13,6 +13,9 @@ namespace PackApi\Auth; +/** + * @author Simon André + */ class EnvAuthenticationManager implements AuthenticationManagerInterface { public function __construct(private readonly string $githubTokenEnvVariable = 'GITHUB_TOKEN') diff --git a/src/Bridge/BundlePhobia/BundlePhobiaApiClient.php b/src/Bridge/BundlePhobia/BundlePhobiaApiClient.php index 89e948f..c2fff65 100644 --- a/src/Bridge/BundlePhobia/BundlePhobiaApiClient.php +++ b/src/Bridge/BundlePhobia/BundlePhobiaApiClient.php @@ -17,6 +17,9 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @author Simon André + */ class BundlePhobiaApiClient { public function __construct( diff --git a/src/Bridge/BundlePhobia/BundlePhobiaProviderFactory.php b/src/Bridge/BundlePhobia/BundlePhobiaProviderFactory.php index 0ea8023..2a351d5 100644 --- a/src/Bridge/BundlePhobia/BundlePhobiaProviderFactory.php +++ b/src/Bridge/BundlePhobia/BundlePhobiaProviderFactory.php @@ -17,6 +17,9 @@ use PackApi\Provider\BundleSizeProviderInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @author Simon André + */ final class BundlePhobiaProviderFactory { private readonly HttpClientInterface $scopedClient; diff --git a/src/Bridge/BundlePhobia/BundlePhobiaSizeProvider.php b/src/Bridge/BundlePhobia/BundlePhobiaSizeProvider.php index e7343d3..cc0fab2 100644 --- a/src/Bridge/BundlePhobia/BundlePhobiaSizeProvider.php +++ b/src/Bridge/BundlePhobia/BundlePhobiaSizeProvider.php @@ -18,6 +18,9 @@ use PackApi\Package\Package; use PackApi\Provider\BundleSizeProviderInterface; +/** + * @author Simon André + */ final class BundlePhobiaSizeProvider implements BundleSizeProviderInterface { public function __construct(private readonly BundlePhobiaApiClient $client) @@ -73,7 +76,7 @@ public function getPackageHistory(Package $package): ?array try { return $this->client->getPackageHistory($package->getName()); - } catch (\Exception $e) { + } catch (\Exception) { return null; } } diff --git a/src/Bridge/GitHub/GitHubActivityProvider.php b/src/Bridge/GitHub/GitHubActivityProvider.php index 951a3b5..3fa7186 100644 --- a/src/Bridge/GitHub/GitHubActivityProvider.php +++ b/src/Bridge/GitHub/GitHubActivityProvider.php @@ -17,6 +17,9 @@ use PackApi\Package\Package; use PackApi\Provider\ActivityProviderInterface; +/** + * @author Simon André + */ final class GitHubActivityProvider implements ActivityProviderInterface { public function __construct(private GitHubApiClient $client) diff --git a/src/Bridge/GitHub/GitHubApiClient.php b/src/Bridge/GitHub/GitHubApiClient.php index c002b39..1b35d4e 100644 --- a/src/Bridge/GitHub/GitHubApiClient.php +++ b/src/Bridge/GitHub/GitHubApiClient.php @@ -21,6 +21,9 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @author Simon André + */ final class GitHubApiClient { public function __construct( diff --git a/src/Bridge/GitHub/GitHubContentProvider.php b/src/Bridge/GitHub/GitHubContentProvider.php index 301de57..9621440 100644 --- a/src/Bridge/GitHub/GitHubContentProvider.php +++ b/src/Bridge/GitHub/GitHubContentProvider.php @@ -18,6 +18,9 @@ use PackApi\Package\Package; use PackApi\Provider\ContentProviderInterface; +/** + * @author Simon André + */ final class GitHubContentProvider implements ContentProviderInterface { public function __construct(private GitHubApiClient $client) diff --git a/src/Bridge/GitHub/GitHubMetadataProvider.php b/src/Bridge/GitHub/GitHubMetadataProvider.php index 1094f4d..716bf97 100644 --- a/src/Bridge/GitHub/GitHubMetadataProvider.php +++ b/src/Bridge/GitHub/GitHubMetadataProvider.php @@ -18,6 +18,9 @@ use PackApi\Package\Package; use PackApi\Provider\MetadataProviderInterface; +/** + * @author Simon André + */ final class GitHubMetadataProvider implements MetadataProviderInterface { public function __construct( diff --git a/src/Bridge/GitHub/GitHubProviderFactory.php b/src/Bridge/GitHub/GitHubProviderFactory.php index e5e5d5d..57fbb9e 100644 --- a/src/Bridge/GitHub/GitHubProviderFactory.php +++ b/src/Bridge/GitHub/GitHubProviderFactory.php @@ -15,6 +15,9 @@ use PackApi\Http\HttpClientFactoryInterface; +/** + * @author Simon André + */ final class GitHubProviderFactory { private readonly GitHubApiClient $apiClient; diff --git a/src/Bridge/GitHub/GitHubSearchProvider.php b/src/Bridge/GitHub/GitHubSearchProvider.php index 4c4ef81..e9d5dfc 100644 --- a/src/Bridge/GitHub/GitHubSearchProvider.php +++ b/src/Bridge/GitHub/GitHubSearchProvider.php @@ -15,6 +15,9 @@ use PackApi\Provider\PackageSearchInterface; +/** + * @author Simon André + */ final class GitHubSearchProvider implements PackageSearchInterface { public function __construct(private readonly GitHubApiClient $client) diff --git a/src/Bridge/GitHub/GitHubSecurityProvider.php b/src/Bridge/GitHub/GitHubSecurityProvider.php index 23d5dd1..5aba685 100644 --- a/src/Bridge/GitHub/GitHubSecurityProvider.php +++ b/src/Bridge/GitHub/GitHubSecurityProvider.php @@ -17,6 +17,9 @@ use PackApi\Package\Package; use PackApi\Provider\SecurityProviderInterface; +/** + * @author Simon André + */ final class GitHubSecurityProvider implements SecurityProviderInterface { public function __construct(private GitHubApiClient $client) diff --git a/src/Bridge/GitHub/GitHubStatisticProvider.php b/src/Bridge/GitHub/GitHubStatisticProvider.php index ccf1099..7c10e11 100644 --- a/src/Bridge/GitHub/GitHubStatisticProvider.php +++ b/src/Bridge/GitHub/GitHubStatisticProvider.php @@ -19,6 +19,9 @@ use PackApi\Package\Package; use PackApi\Provider\DownloadStatsProviderInterface; +/** + * @author Simon André + */ final class GitHubStatisticProvider implements DownloadStatsProviderInterface { public function __construct( @@ -72,9 +75,6 @@ public function getStatsForPeriod(Package $package, DownloadPeriod $period): ?Do return null; } - // Map GitHub stats to download-like metrics - $stats = $activityData['activity_stats']; - $count = (int) ($repoData['stargazers_count'] ?? 0); $computedPeriod = new DownloadPeriod( $period->getType(), diff --git a/src/Bridge/Npm/NpmApiClient.php b/src/Bridge/Npm/NpmApiClient.php index 02e1c13..cd517dc 100644 --- a/src/Bridge/Npm/NpmApiClient.php +++ b/src/Bridge/Npm/NpmApiClient.php @@ -17,6 +17,9 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @author Simon André + */ class NpmApiClient { public function __construct( diff --git a/src/Bridge/Npm/NpmProviderFactory.php b/src/Bridge/Npm/NpmProviderFactory.php index fd9fb0c..c0517d1 100644 --- a/src/Bridge/Npm/NpmProviderFactory.php +++ b/src/Bridge/Npm/NpmProviderFactory.php @@ -22,6 +22,9 @@ use PackApi\System\Npm\NpmMetadataProvider; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @author Simon André + */ final class NpmProviderFactory { private readonly HttpClientInterface $registryClient; diff --git a/src/Bridge/OSV/OSVApiClient.php b/src/Bridge/OSV/OSVApiClient.php index fbc06b7..9ac8557 100644 --- a/src/Bridge/OSV/OSVApiClient.php +++ b/src/Bridge/OSV/OSVApiClient.php @@ -17,6 +17,9 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @author Simon André + */ class OSVApiClient { public function __construct( diff --git a/src/Bridge/OSV/OSVProviderFactory.php b/src/Bridge/OSV/OSVProviderFactory.php index 7d8686f..381d049 100644 --- a/src/Bridge/OSV/OSVProviderFactory.php +++ b/src/Bridge/OSV/OSVProviderFactory.php @@ -17,6 +17,9 @@ use PackApi\Provider\SecurityProviderInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @author Simon André + */ final class OSVProviderFactory { private readonly HttpClientInterface $scopedClient; diff --git a/src/Bridge/OSV/OSVSecurityProvider.php b/src/Bridge/OSV/OSVSecurityProvider.php index d6ac6c0..6067bfd 100644 --- a/src/Bridge/OSV/OSVSecurityProvider.php +++ b/src/Bridge/OSV/OSVSecurityProvider.php @@ -19,6 +19,9 @@ use PackApi\Package\Package; use PackApi\Provider\SecurityProviderInterface; +/** + * @author Simon André + */ final class OSVSecurityProvider implements SecurityProviderInterface { public function __construct(private readonly OSVApiClient $client) @@ -156,11 +159,6 @@ private function extractSeverity(array $vulnerability): string return strtoupper($vulnerability['database_specific']['severity']); } - if (isset($vulnerability['database_specific']['github_reviewed']) - && isset($vulnerability['database_specific']['severity'])) { - return strtoupper($vulnerability['database_specific']['severity']); - } - return 'MEDIUM'; } diff --git a/src/Bridge/Packagist/PackagistActivityProvider.php b/src/Bridge/Packagist/PackagistActivityProvider.php index 64a9171..c9b6012 100644 --- a/src/Bridge/Packagist/PackagistActivityProvider.php +++ b/src/Bridge/Packagist/PackagistActivityProvider.php @@ -17,6 +17,9 @@ use PackApi\Package\Package; use PackApi\Provider\ActivityProviderInterface; +/** + * @author Simon André + */ final class PackagistActivityProvider implements ActivityProviderInterface { public function __construct(private PackagistApiClient $client) diff --git a/src/Bridge/Packagist/PackagistApiClient.php b/src/Bridge/Packagist/PackagistApiClient.php index a166f98..2e127e6 100644 --- a/src/Bridge/Packagist/PackagistApiClient.php +++ b/src/Bridge/Packagist/PackagistApiClient.php @@ -15,6 +15,9 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @author Simon André + */ final class PackagistApiClient { public function __construct( diff --git a/src/Bridge/Packagist/PackagistContentProvider.php b/src/Bridge/Packagist/PackagistContentProvider.php index 5ef7309..817f442 100644 --- a/src/Bridge/Packagist/PackagistContentProvider.php +++ b/src/Bridge/Packagist/PackagistContentProvider.php @@ -20,12 +20,19 @@ use PackApi\Provider\ContentProviderInterface; use PackApi\Security\SecureFileHandlerInterface; +/** + * @author Simon André + */ final class PackagistContentProvider implements ContentProviderInterface { + private readonly string $tempDir; + public function __construct( private readonly PackagistApiClient $client, private readonly SecureFileHandlerInterface $fileHandler, + ?string $tempDir = null, ) { + $this->tempDir = $tempDir ?? sys_get_temp_dir(); } public function supports(Package $package): bool @@ -51,8 +58,8 @@ public function getContentOverview(Package $package): ?ContentOverview $tarPath = $this->fileHandler->downloadSafely($distUrl); - $tmp = sys_get_temp_dir().'/packapi_'.uniqid('', true); - if (!mkdir($tmp, 0755, true) && !is_dir($tmp)) { + $tmp = $this->tempDir.'/packapi_'.uniqid('', true); + if (!@mkdir($tmp, 0755, true) && !is_dir($tmp)) { throw new ValidationException('Cannot create extraction directory'); } @@ -116,10 +123,6 @@ public function getContentOverview(Package $package): ?ContentOverview private function cleanupDirectory(string $path): void { - if (!is_dir($path)) { - return; - } - $files = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST diff --git a/src/Bridge/Packagist/PackagistMetadataProvider.php b/src/Bridge/Packagist/PackagistMetadataProvider.php index 4c60a91..81d6d9d 100644 --- a/src/Bridge/Packagist/PackagistMetadataProvider.php +++ b/src/Bridge/Packagist/PackagistMetadataProvider.php @@ -17,6 +17,9 @@ use PackApi\Package\Package; use PackApi\Provider\MetadataProviderInterface; +/** + * @author Simon André + */ final class PackagistMetadataProvider implements MetadataProviderInterface { public function __construct(private PackagistApiClient $client) diff --git a/src/Bridge/Packagist/PackagistProviderFactory.php b/src/Bridge/Packagist/PackagistProviderFactory.php index b47fefd..7d9aa6c 100644 --- a/src/Bridge/Packagist/PackagistProviderFactory.php +++ b/src/Bridge/Packagist/PackagistProviderFactory.php @@ -17,6 +17,9 @@ use PackApi\Security\SecureFileHandler; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @author Simon André + */ final class PackagistProviderFactory { private readonly HttpClientInterface $scopedClient; diff --git a/src/Bridge/Packagist/PackagistSearchProvider.php b/src/Bridge/Packagist/PackagistSearchProvider.php index 347f83f..b8a8aa0 100644 --- a/src/Bridge/Packagist/PackagistSearchProvider.php +++ b/src/Bridge/Packagist/PackagistSearchProvider.php @@ -15,6 +15,9 @@ use PackApi\Provider\PackageSearchInterface; +/** + * @author Simon André + */ final class PackagistSearchProvider implements PackageSearchInterface { public function __construct(private readonly PackagistApiClient $client) diff --git a/src/Bridge/Packagist/PackagistSecurityProvider.php b/src/Bridge/Packagist/PackagistSecurityProvider.php index 2854826..6e95bed 100644 --- a/src/Bridge/Packagist/PackagistSecurityProvider.php +++ b/src/Bridge/Packagist/PackagistSecurityProvider.php @@ -19,6 +19,9 @@ use PackApi\Provider\SecurityProviderInterface; use Symfony\Component\Yaml\Yaml; +/** + * @author Simon André + */ final class PackagistSecurityProvider implements SecurityProviderInterface { public function __construct( diff --git a/src/Bridge/Packagist/PackagistStatisticsProvider.php b/src/Bridge/Packagist/PackagistStatisticsProvider.php index 369a478..105210b 100644 --- a/src/Bridge/Packagist/PackagistStatisticsProvider.php +++ b/src/Bridge/Packagist/PackagistStatisticsProvider.php @@ -13,6 +13,9 @@ namespace PackApi\Bridge\Packagist; +/** + * @author Simon André + */ final class PackagistStatisticsProvider { } diff --git a/src/Exception/ApiException.php b/src/Exception/ApiException.php index aa7744d..7a47b28 100644 --- a/src/Exception/ApiException.php +++ b/src/Exception/ApiException.php @@ -13,6 +13,9 @@ namespace PackApi\Exception; +/** + * @author Simon André + */ class ApiException extends \RuntimeException { /** diff --git a/src/Exception/NetworkException.php b/src/Exception/NetworkException.php index 6d5c85a..df075b7 100644 --- a/src/Exception/NetworkException.php +++ b/src/Exception/NetworkException.php @@ -13,6 +13,9 @@ namespace PackApi\Exception; +/** + * @author Simon André + */ class NetworkException extends \RuntimeException { } diff --git a/src/Exception/PackageNotFoundException.php b/src/Exception/PackageNotFoundException.php index 6621ac8..d329707 100644 --- a/src/Exception/PackageNotFoundException.php +++ b/src/Exception/PackageNotFoundException.php @@ -13,6 +13,9 @@ namespace PackApi\Exception; +/** + * @author Simon André + */ class PackageNotFoundException extends \Exception implements PackageExceptionInterface { } diff --git a/src/Exception/RateLimitException.php b/src/Exception/RateLimitException.php index fb922ef..c7669e9 100644 --- a/src/Exception/RateLimitException.php +++ b/src/Exception/RateLimitException.php @@ -13,6 +13,9 @@ namespace PackApi\Exception; +/** + * @author Simon André + */ class RateLimitException extends \Exception implements ProviderExceptionInterface { } diff --git a/src/Exception/UnsupportedPackageException.php b/src/Exception/UnsupportedPackageException.php index 656f9e5..9c36d9f 100644 --- a/src/Exception/UnsupportedPackageException.php +++ b/src/Exception/UnsupportedPackageException.php @@ -13,6 +13,9 @@ namespace PackApi\Exception; +/** + * @author Simon André + */ final class UnsupportedPackageException extends \LogicException { public function __construct(string $packageClass) diff --git a/src/Exception/ValidationException.php b/src/Exception/ValidationException.php index 1376f76..2698203 100644 --- a/src/Exception/ValidationException.php +++ b/src/Exception/ValidationException.php @@ -13,6 +13,9 @@ namespace PackApi\Exception; +/** + * @author Simon André + */ class ValidationException extends \InvalidArgumentException { } diff --git a/src/Http/HttpClientFactory.php b/src/Http/HttpClientFactory.php index a967a07..ef06182 100644 --- a/src/Http/HttpClientFactory.php +++ b/src/Http/HttpClientFactory.php @@ -21,6 +21,8 @@ /** * HTTP client factory that creates a decorated client with caching and logging middleware. + * + * @author Simon André */ final class HttpClientFactory implements HttpClientFactoryInterface { diff --git a/src/Http/Middleware/LoggingMiddleware.php b/src/Http/Middleware/LoggingMiddleware.php index 3e53852..4625438 100644 --- a/src/Http/Middleware/LoggingMiddleware.php +++ b/src/Http/Middleware/LoggingMiddleware.php @@ -18,6 +18,9 @@ use Symfony\Contracts\HttpClient\ResponseInterface; use Symfony\Contracts\HttpClient\ResponseStreamInterface; +/** + * @author Simon André + */ final class LoggingMiddleware implements HttpClientInterface { public function __construct( diff --git a/src/Inspector/ActivityInspector.php b/src/Inspector/ActivityInspector.php index eec3a61..38870ef 100644 --- a/src/Inspector/ActivityInspector.php +++ b/src/Inspector/ActivityInspector.php @@ -17,6 +17,9 @@ use PackApi\Package\Package; use PackApi\Provider\ActivityProviderInterface; +/** + * @author Simon André + */ final class ActivityInspector implements ActivityInspectorInterface { /** diff --git a/src/Inspector/ContentInspector.php b/src/Inspector/ContentInspector.php index 1243267..fe85008 100644 --- a/src/Inspector/ContentInspector.php +++ b/src/Inspector/ContentInspector.php @@ -17,6 +17,9 @@ use PackApi\Package\Package; use PackApi\Provider\ContentProviderInterface; +/** + * @author Simon André + */ final class ContentInspector implements ContentInspectorInterface { /** diff --git a/src/Inspector/DownloadStatsInspector.php b/src/Inspector/DownloadStatsInspector.php index 96ccb2b..fcb3c9e 100644 --- a/src/Inspector/DownloadStatsInspector.php +++ b/src/Inspector/DownloadStatsInspector.php @@ -18,6 +18,9 @@ use PackApi\Package\Package; use PackApi\Provider\DownloadStatsProviderInterface; +/** + * @author Simon André + */ final class DownloadStatsInspector implements DownloadStatsInspectorInterface { /** diff --git a/src/Inspector/MetadataInspector.php b/src/Inspector/MetadataInspector.php index 6a600b6..a226c39 100644 --- a/src/Inspector/MetadataInspector.php +++ b/src/Inspector/MetadataInspector.php @@ -17,6 +17,9 @@ use PackApi\Package\Package; use PackApi\Provider\MetadataProviderInterface; +/** + * @author Simon André + */ final class MetadataInspector implements MetadataInspectorInterface { /** diff --git a/src/Inspector/PackageInspectorFacade.php b/src/Inspector/PackageInspectorFacade.php index 1b64a5a..cdd9350 100644 --- a/src/Inspector/PackageInspectorFacade.php +++ b/src/Inspector/PackageInspectorFacade.php @@ -15,6 +15,9 @@ use PackApi\Package\Package; +/** + * @author Simon André + */ final class PackageInspectorFacade { public function __construct( diff --git a/src/Inspector/QualityInspector.php b/src/Inspector/QualityInspector.php index 6ad3b8e..2e1fec4 100644 --- a/src/Inspector/QualityInspector.php +++ b/src/Inspector/QualityInspector.php @@ -17,6 +17,9 @@ use PackApi\Package\Package; use PackApi\Provider\ContentProviderInterface; +/** + * @author Simon André + */ final class QualityInspector implements QualityInspectorInterface { public function __construct( @@ -48,22 +51,18 @@ public function getQualityScore(Package $package): ?QualityScore $criteria['hasGitignore'] = (bool) array_filter($bestPractices->ignoredFiles, fn ($f) => '.gitignore' === $f); } - // Improved scoring: each criterion is worth 15 points, minus penalty for ignored files + // Each positive criterion is worth 15 points, minus a penalty for ignored files. $score = 0; $score += $criteria['hasReadme'] ? 15 : 0; $score += $criteria['hasLicense'] ? 15 : 0; $score += $criteria['hasTests'] ? 15 : 0; - $score += $criteria['hasDescription'] ? 10 : 0; - $score += $criteria['hasRepository'] ? 10 : 0; - $score += ($criteria['hasGitattributes'] ?? false) ? 10 : 0; - $score += ($criteria['hasGitignore'] ?? false) ? 10 : 0; + $score += $criteria['hasDescription'] ? 15 : 0; + $score += $criteria['hasRepository'] ? 15 : 0; + $score += ($criteria['hasGitattributes'] ?? false) ? 15 : 0; + $score += ($criteria['hasGitignore'] ?? false) ? 15 : 0; $score -= min($criteria['ignoredFiles'] * 2, 15); // up to -15 for ignored files $score = max(0, min(100, $score)); - $delta = (int) (getenv('PACKAPI_SCORE_DELTA') ?: 0); - $score += $delta; - $score -= $delta; - $grade = match (true) { $score >= 90 => 'A', $score >= 75 => 'B', diff --git a/src/Inspector/SecurityInspector.php b/src/Inspector/SecurityInspector.php index e36b2e7..5f35671 100644 --- a/src/Inspector/SecurityInspector.php +++ b/src/Inspector/SecurityInspector.php @@ -17,6 +17,9 @@ use PackApi\Package\Package; use PackApi\Provider\SecurityProviderInterface; +/** + * @author Simon André + */ final class SecurityInspector implements SecurityInspectorInterface { /** diff --git a/src/Model/ActivitySummary.php b/src/Model/ActivitySummary.php index 14fb5ae..7b117d7 100644 --- a/src/Model/ActivitySummary.php +++ b/src/Model/ActivitySummary.php @@ -13,6 +13,9 @@ namespace PackApi\Model; +/** + * @author Simon André + */ final class ActivitySummary { public function __construct( diff --git a/src/Model/BundleSize.php b/src/Model/BundleSize.php index 8031ee0..d91d7bd 100644 --- a/src/Model/BundleSize.php +++ b/src/Model/BundleSize.php @@ -13,6 +13,9 @@ namespace PackApi\Model; +/** + * @author Simon André + */ final class BundleSize { public function __construct( @@ -117,7 +120,7 @@ private function formatBytes(int $bytes): string $units = ['B', 'KB', 'MB', 'GB']; $base = 1024; - $exp = floor(log($bytes) / log($base)); + $exp = (int) floor(log($bytes) / log($base)); return round($bytes / pow($base, $exp), 1).' '.$units[$exp]; } diff --git a/src/Model/ContentOverview.php b/src/Model/ContentOverview.php index 7d4f72f..a2c9700 100644 --- a/src/Model/ContentOverview.php +++ b/src/Model/ContentOverview.php @@ -13,6 +13,9 @@ namespace PackApi\Model; +/** + * @author Simon André + */ final class ContentOverview { /** diff --git a/src/Model/DownloadPeriod.php b/src/Model/DownloadPeriod.php index 073d2b1..c396331 100644 --- a/src/Model/DownloadPeriod.php +++ b/src/Model/DownloadPeriod.php @@ -13,6 +13,9 @@ namespace PackApi\Model; +/** + * @author Simon André + */ final class DownloadPeriod { public function __construct( diff --git a/src/Model/DownloadStats.php b/src/Model/DownloadStats.php index 0cb2c6d..d5f709a 100644 --- a/src/Model/DownloadStats.php +++ b/src/Model/DownloadStats.php @@ -13,6 +13,9 @@ namespace PackApi\Model; +/** + * @author Simon André + */ final class DownloadStats { /** diff --git a/src/Model/File.php b/src/Model/File.php index da40384..e4b0439 100644 --- a/src/Model/File.php +++ b/src/Model/File.php @@ -13,6 +13,9 @@ namespace PackApi\Model; +/** + * @author Simon André + */ final class File { public function __construct( diff --git a/src/Model/Metadata.php b/src/Model/Metadata.php index 72238b9..7dfd0d0 100644 --- a/src/Model/Metadata.php +++ b/src/Model/Metadata.php @@ -13,6 +13,9 @@ namespace PackApi\Model; +/** + * @author Simon André + */ final class Metadata { public function __construct( diff --git a/src/Model/Package.php b/src/Model/Package.php index 2aed001..73dc1d0 100644 --- a/src/Model/Package.php +++ b/src/Model/Package.php @@ -13,6 +13,9 @@ namespace PackApi\Model; +/** + * @author Simon André + */ final class Package { public function __construct( diff --git a/src/Model/QualityScore.php b/src/Model/QualityScore.php index 297f899..d5488f3 100644 --- a/src/Model/QualityScore.php +++ b/src/Model/QualityScore.php @@ -13,6 +13,9 @@ namespace PackApi\Model; +/** + * @author Simon André + */ final class QualityScore { /** diff --git a/src/Model/Release.php b/src/Model/Release.php index 8a3cb9b..11f8acb 100644 --- a/src/Model/Release.php +++ b/src/Model/Release.php @@ -13,6 +13,9 @@ namespace PackApi\Model; +/** + * @author Simon André + */ final class Release { public function __construct( diff --git a/src/Model/Repository.php b/src/Model/Repository.php index f49df53..791b802 100644 --- a/src/Model/Repository.php +++ b/src/Model/Repository.php @@ -13,6 +13,9 @@ namespace PackApi\Model; +/** + * @author Simon André + */ final class Repository { public function __construct( diff --git a/src/Model/SecurityAdvisory.php b/src/Model/SecurityAdvisory.php index aeef72f..84e5bac 100644 --- a/src/Model/SecurityAdvisory.php +++ b/src/Model/SecurityAdvisory.php @@ -13,6 +13,9 @@ namespace PackApi\Model; +/** + * @author Simon André + */ final class SecurityAdvisory { public function __construct( diff --git a/src/Model/Version.php b/src/Model/Version.php index f56ed0d..b4d228c 100644 --- a/src/Model/Version.php +++ b/src/Model/Version.php @@ -13,6 +13,9 @@ namespace PackApi\Model; +/** + * @author Simon André + */ final class Version { public function __construct( diff --git a/src/Package/ComposerPackage.php b/src/Package/ComposerPackage.php index d27e57b..dab5aac 100644 --- a/src/Package/ComposerPackage.php +++ b/src/Package/ComposerPackage.php @@ -13,6 +13,9 @@ namespace PackApi\Package; +/** + * @author Simon André + */ final class ComposerPackage extends Package { public function __construct(string $name) diff --git a/src/Package/NpmPackage.php b/src/Package/NpmPackage.php index cb46b43..07528bb 100644 --- a/src/Package/NpmPackage.php +++ b/src/Package/NpmPackage.php @@ -15,6 +15,9 @@ use PackApi\Exception\ValidationException; +/** + * @author Simon André + */ final class NpmPackage extends Package { public function __construct(string $name) diff --git a/src/Package/Package.php b/src/Package/Package.php index 4b39f4b..31ffaf1 100644 --- a/src/Package/Package.php +++ b/src/Package/Package.php @@ -13,6 +13,9 @@ namespace PackApi\Package; +/** + * @author Simon André + */ abstract class Package { protected ?string $repositoryUrl = null; diff --git a/src/Security/SecureFileHandler.php b/src/Security/SecureFileHandler.php index 7d971f0..14b7958 100644 --- a/src/Security/SecureFileHandler.php +++ b/src/Security/SecureFileHandler.php @@ -17,6 +17,9 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; +/** + * @author Simon André + */ final class SecureFileHandler implements SecureFileHandlerInterface { public const int MAX_FILE_SIZE = 104857600; // 100MB diff --git a/src/System/Composer/ComposerDownloadStatsProvider.php b/src/System/Composer/ComposerDownloadStatsProvider.php index 3fd71b5..4d27514 100644 --- a/src/System/Composer/ComposerDownloadStatsProvider.php +++ b/src/System/Composer/ComposerDownloadStatsProvider.php @@ -20,6 +20,9 @@ use PackApi\Package\Package; use PackApi\Provider\DownloadStatsProviderInterface; +/** + * @author Simon André + */ final class ComposerDownloadStatsProvider implements DownloadStatsProviderInterface { public function __construct(private readonly PackagistApiClient $client) diff --git a/src/System/Composer/ComposerMetadataProvider.php b/src/System/Composer/ComposerMetadataProvider.php index c0ad80d..bdcc4a3 100644 --- a/src/System/Composer/ComposerMetadataProvider.php +++ b/src/System/Composer/ComposerMetadataProvider.php @@ -19,6 +19,9 @@ use PackApi\Package\Package; use PackApi\Provider\MetadataProviderInterface; +/** + * @author Simon André + */ final class ComposerMetadataProvider implements MetadataProviderInterface { public function __construct(private readonly PackagistApiClient $client) diff --git a/src/System/Npm/NpmContentProvider.php b/src/System/Npm/NpmContentProvider.php index 2e14a7c..a95cb4b 100644 --- a/src/System/Npm/NpmContentProvider.php +++ b/src/System/Npm/NpmContentProvider.php @@ -19,6 +19,9 @@ use PackApi\Package\Package; use PackApi\Provider\ContentProviderInterface; +/** + * @author Simon André + */ final class NpmContentProvider implements ContentProviderInterface { public function __construct(private readonly NpmApiClient $client) diff --git a/src/System/Npm/NpmDownloadStatsProvider.php b/src/System/Npm/NpmDownloadStatsProvider.php index b04bc9b..d99e6a8 100644 --- a/src/System/Npm/NpmDownloadStatsProvider.php +++ b/src/System/Npm/NpmDownloadStatsProvider.php @@ -20,6 +20,9 @@ use PackApi\Package\Package; use PackApi\Provider\DownloadStatsProviderInterface; +/** + * @author Simon André + */ final class NpmDownloadStatsProvider implements DownloadStatsProviderInterface { public function __construct(private readonly NpmApiClient $client) diff --git a/src/System/Npm/NpmMetadataProvider.php b/src/System/Npm/NpmMetadataProvider.php index 1835d2a..858923d 100644 --- a/src/System/Npm/NpmMetadataProvider.php +++ b/src/System/Npm/NpmMetadataProvider.php @@ -19,6 +19,9 @@ use PackApi\Package\Package; use PackApi\Provider\MetadataProviderInterface; +/** + * @author Simon André + */ final class NpmMetadataProvider implements MetadataProviderInterface { public function __construct(private readonly NpmApiClient $client) diff --git a/tests/Bridge/BundlePhobia/BundlePhobiaApiClientTest.php b/tests/Bridge/BundlePhobia/BundlePhobiaApiClientTest.php index 8b476c7..5f6097b 100644 --- a/tests/Bridge/BundlePhobia/BundlePhobiaApiClientTest.php +++ b/tests/Bridge/BundlePhobia/BundlePhobiaApiClientTest.php @@ -19,6 +19,8 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; #[CoversClass(BundlePhobiaApiClient::class)] final class BundlePhobiaApiClientTest extends TestCase @@ -102,6 +104,17 @@ public function testGetBundleSizeWithVersion(): void $this->assertSame($responseData, $result); } + public function testGetBundleSizeWrapsTransportErrors(): void + { + $transport = new class extends \RuntimeException implements TransportExceptionInterface {}; + $http = $this->createStub(HttpClientInterface::class); + $http->method('request')->willThrowException($transport); + $client = new BundlePhobiaApiClient($http); + + $this->expectException(NetworkException::class); + $client->getBundleSize('react'); + } + public function testGetPackageHistoryReturnsData(): void { $responseData = [ @@ -134,4 +147,25 @@ public function testGetPackageHistoryReturnsNullOn404(): void $this->assertNull($result); } + + public function testGetPackageHistoryThrowsNetworkExceptionOnError(): void + { + $client = new BundlePhobiaApiClient(new MockHttpClient([ + new MockResponse('', ['http_code' => 500]), + ])); + + $this->expectException(NetworkException::class); + $client->getPackageHistory('react'); + } + + public function testGetPackageHistoryWrapsTransportErrors(): void + { + $transport = new class extends \RuntimeException implements TransportExceptionInterface {}; + $http = $this->createStub(HttpClientInterface::class); + $http->method('request')->willThrowException($transport); + $client = new BundlePhobiaApiClient($http); + + $this->expectException(NetworkException::class); + $client->getPackageHistory('react'); + } } diff --git a/tests/Bridge/BundlePhobia/BundlePhobiaProviderFactoryTest.php b/tests/Bridge/BundlePhobia/BundlePhobiaProviderFactoryTest.php index 5a3d67c..23d8c8d 100644 --- a/tests/Bridge/BundlePhobia/BundlePhobiaProviderFactoryTest.php +++ b/tests/Bridge/BundlePhobia/BundlePhobiaProviderFactoryTest.php @@ -20,7 +20,6 @@ use PackApi\Provider\BundleSizeProviderInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; #[CoversClass(BundlePhobiaProviderFactory::class)] @@ -31,12 +30,10 @@ final class BundlePhobiaProviderFactoryTest extends TestCase protected function setUp(): void { - // Create a mock factory that returns a mock HTTP client - $this->httpClientFactory = $this->createMock(HttpClientFactoryInterface::class); - $mockHttpClient = new MockHttpClient(); + // Create a factory stub that returns a scoped HTTP client stub. + $this->httpClientFactory = $this->createStub(HttpClientFactoryInterface::class); - // Mock the withOptions method to return the same client for testing - $scopedClient = $this->createMock(HttpClientInterface::class); + $scopedClient = $this->createStub(HttpClientInterface::class); $scopedClient->method('withOptions')->willReturnSelf(); $this->httpClientFactory diff --git a/tests/Bridge/BundlePhobia/BundlePhobiaSizeProviderTest.php b/tests/Bridge/BundlePhobia/BundlePhobiaSizeProviderTest.php index 3194ab8..9b40d0a 100644 --- a/tests/Bridge/BundlePhobia/BundlePhobiaSizeProviderTest.php +++ b/tests/Bridge/BundlePhobia/BundlePhobiaSizeProviderTest.php @@ -29,6 +29,12 @@ final class BundlePhobiaSizeProviderTest extends TestCase private BundlePhobiaSizeProvider $provider; protected function setUp(): void + { + $this->bundlePhobiaApiClient = $this->createStub(BundlePhobiaApiClient::class); + $this->provider = new BundlePhobiaSizeProvider($this->bundlePhobiaApiClient); + } + + private function useMockApiClient(): void { $this->bundlePhobiaApiClient = $this->createMock(BundlePhobiaApiClient::class); $this->provider = new BundlePhobiaSizeProvider($this->bundlePhobiaApiClient); @@ -51,6 +57,7 @@ public function testDoesNotSupportComposerPackage(): void public function testGetBundleSizeReturnsNullWhenApiReturnsNull(): void { $package = new NpmPackage('nonexistent-package'); + $this->useMockApiClient(); $this->bundlePhobiaApiClient ->expects($this->once()) @@ -81,6 +88,7 @@ public function testGetBundleSizeReturnsBundleSize(): void 'url' => 'https://github.com/facebook/react.git', ], ]; + $this->useMockApiClient(); $this->bundlePhobiaApiClient ->expects($this->once()) @@ -114,6 +122,7 @@ public function testGetBundleSizeForVersionCallsApiWithVersion(): void 'size' => 39000, 'gzip' => 12000, ]; + $this->useMockApiClient(); $this->bundlePhobiaApiClient ->expects($this->once()) @@ -130,6 +139,30 @@ public function testGetBundleSizeForVersionCallsApiWithVersion(): void $this->assertSame(12000, $result->getGzipSize()); } + public function testGetBundleSizeForVersionReturnsNullWhenApiReturnsNull(): void + { + $package = new NpmPackage('react'); + $this->useMockApiClient(); + $this->bundlePhobiaApiClient + ->expects($this->once()) + ->method('getBundleSize') + ->willReturn(null); + + $this->assertNull($this->provider->getBundleSizeForVersion($package, '1.0.0')); + } + + public function testGetBundleSizeForVersionReturnsNullOnFailure(): void + { + $package = new NpmPackage('react'); + $this->useMockApiClient(); + $this->bundlePhobiaApiClient + ->expects($this->once()) + ->method('getBundleSize') + ->willThrowException(new \RuntimeException('API error')); + + $this->assertNull($this->provider->getBundleSizeForVersion($package, '1.0.0')); + } + public function testGetPackageHistoryReturnsHistoryData(): void { $package = new NpmPackage('react'); @@ -141,6 +174,7 @@ public function testGetPackageHistoryReturnsHistoryData(): void ['version' => '18.0.0', 'size' => 41000, 'gzip' => 12500], ], ]; + $this->useMockApiClient(); $this->bundlePhobiaApiClient ->expects($this->once()) @@ -162,9 +196,22 @@ public function testGetPackageHistoryReturnsNullForUnsupportedPackage(): void $this->assertNull($result); } + public function testGetPackageHistoryReturnsNullOnFailure(): void + { + $package = new NpmPackage('react'); + $this->useMockApiClient(); + $this->bundlePhobiaApiClient + ->expects($this->once()) + ->method('getPackageHistory') + ->willThrowException(new \RuntimeException('API error')); + + $this->assertNull($this->provider->getPackageHistory($package)); + } + public function testGetBundleSizeHandlesExceptionGracefully(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $this->bundlePhobiaApiClient ->expects($this->once()) @@ -183,6 +230,7 @@ public function testGetBundleSizeHandlesMissingFields(): void 'name' => 'minimal-package', // Missing most fields to test defaults ]; + $this->useMockApiClient(); $this->bundlePhobiaApiClient ->expects($this->once()) diff --git a/tests/Bridge/GitHub/GitHubActivityProviderTest.php b/tests/Bridge/GitHub/GitHubActivityProviderTest.php index 7ac7141..b42c007 100644 --- a/tests/Bridge/GitHub/GitHubActivityProviderTest.php +++ b/tests/Bridge/GitHub/GitHubActivityProviderTest.php @@ -102,6 +102,8 @@ public function testSupportsIdentifiesGithubRepositories(): void $pkg3 = new ComposerPackage('acme/foo'); $pkg3->setRepositoryUrl('https://gitlab.com/acme/foo'); $this->assertFalse($provider->supports($pkg3)); + + $this->assertFalse($provider->supports(new ComposerPackage('invalid'))); } public function testGetActivitySummaryReturnsData(): void @@ -125,4 +127,42 @@ public function testGetActivitySummaryReturnsData(): void $this->assertSame(5, $summary->getOpenIssues()); $this->assertSame('2024-06-02T00:00:00Z', $summary->getLastRelease()); } + + public function testGetActivitySummaryReturnsNullWithoutRepository(): void + { + $provider = new GitHubActivityProvider(new GitHubApiClient($this->getStubClient())); + + $this->assertNull($provider->getActivitySummary(new ComposerPackage('owner/repo'))); + } + + public function testGetActivitySummaryReturnsNullForInvalidRepositoryUrl(): void + { + $provider = new GitHubActivityProvider(new GitHubApiClient($this->getStubClient())); + $package = new ComposerPackage('owner/repo'); + $package->setRepositoryUrl('not-a-repository'); + + $this->assertNull($provider->getActivitySummary($package)); + } + + public function testGetActivitySummaryReturnsNullWhenActivityIsUnavailable(): void + { + $provider = new GitHubActivityProvider(new GitHubApiClient($this->getStubClient([ + 'GET /repos/owner/repo' => [404, ['message' => 'Not Found']], + ]))); + $package = new ComposerPackage('owner/repo'); + $package->setRepositoryUrl('https://github.com/owner/repo'); + + $this->assertNull($provider->getActivitySummary($package)); + } + + public function testGetActivitySummaryReturnsNullOnApiFailure(): void + { + $provider = new GitHubActivityProvider(new GitHubApiClient($this->getStubClient([ + 'GET /repos/owner/repo' => [500, ['message' => 'Unavailable']], + ]))); + $package = new ComposerPackage('owner/repo'); + $package->setRepositoryUrl('https://github.com/owner/repo'); + + $this->assertNull($provider->getActivitySummary($package)); + } } diff --git a/tests/Bridge/GitHub/GitHubApiClientTest.php b/tests/Bridge/GitHub/GitHubApiClientTest.php index 11255ff..c9e5a5e 100644 --- a/tests/Bridge/GitHub/GitHubApiClientTest.php +++ b/tests/Bridge/GitHub/GitHubApiClientTest.php @@ -14,10 +14,17 @@ namespace PackApi\Tests\Bridge\GitHub; use PackApi\Bridge\GitHub\GitHubApiClient; +use PackApi\Exception\ApiException; +use PackApi\Exception\NetworkException; +use PackApi\Exception\ValidationException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; #[CoversClass(GitHubApiClient::class)] final class GitHubApiClientTest extends TestCase @@ -34,6 +41,38 @@ private function getMockClient(array $responses, array &$calls): MockHttpClient }); } + public function testExtractRepoNameReturnsNullForEmptyString(): void + { + $calls = []; + $client = new GitHubApiClient($this->getMockClient([], $calls)); + + $this->assertNull($client->extractRepoName('')); + } + + public function testRepositoryMethodsRejectInvalidName(): void + { + $calls = []; + $client = new GitHubApiClient($this->getMockClient([], $calls)); + $operations = [ + static fn (): mixed => $client->fetchRepoMetadata('invalid'), + static fn (): mixed => $client->fetchRepoActivity('invalid'), + static fn (): mixed => $client->fetchSecurityAdvisories('invalid'), + static fn (): mixed => $client->fetchRepoFiles('invalid'), + static fn (): mixed => $client->fetchRepoContents('invalid'), + ]; + + foreach ($operations as $operation) { + try { + $operation(); + $this->fail('A malformed repository name must be rejected.'); + } catch (ValidationException) { + $this->addToAssertionCount(1); + } + } + + $this->assertSame([], $calls); + } + public function testFetchRepoMetadataMakesCorrectRequest(): void { $responses = [ @@ -50,6 +89,95 @@ public function testFetchRepoMetadataMakesCorrectRequest(): void $this->assertSame(['name' => 'repo'], $data); } + public function testFetchRepoMetadataReturnsNullWhenRepositoryDoesNotExist(): void + { + $calls = []; + $client = new GitHubApiClient($this->getMockClient([ + 'GET /repos/owner/missing' => [404, ['message' => 'Not Found']], + ], $calls)); + + $this->assertNull($client->fetchRepoMetadata('owner/missing')); + } + + public function testFetchRepoMetadataRethrowsApiErrors(): void + { + $calls = []; + $client = new GitHubApiClient($this->getMockClient([ + 'GET /repos/owner/repo' => [500, ['message' => 'Unavailable']], + ], $calls)); + + try { + $client->fetchRepoMetadata('owner/repo'); + $this->fail('The API error must be rethrown.'); + } catch (ApiException $exception) { + $this->assertSame(500, $exception->httpCode); + $this->assertSame('GitHub API error: Unavailable', $exception->getMessage()); + } + } + + public function testFetchRepoActivityAggregatesRepositoryActivity(): void + { + $responses = [ + 'GET /repos/owner/repo' => [200, ['name' => 'repo']], + 'GET /repos/owner/repo/commits' => [200, [ + ['commit' => ['committer' => ['date' => '2026-08-20T12:00:00Z']]], + ['commit' => ['committer' => ['date' => '2026-08-19T12:00:00Z']]], + ]], + 'GET /repos/owner/repo/contributors' => [200, [['login' => 'one'], ['login' => 'two']]], + 'GET /repos/owner/repo/releases' => [200, [['published_at' => '2026-08-18T12:00:00Z']]], + ]; + $calls = []; + $client = new GitHubApiClient($this->getMockClient($responses, $calls)); + + $activity = $client->fetchRepoActivity('owner/repo'); + + $this->assertNotNull($activity); + $this->assertSame(['name' => 'repo'], $activity['repository']); + $this->assertSame(2, $activity['activity_stats']['commit_count_last_year']); + $this->assertSame(2, $activity['activity_stats']['contributor_count']); + $this->assertSame(1, $activity['activity_stats']['release_count']); + $this->assertSame('2026-08-20T12:00:00Z', $activity['activity_stats']['last_commit_date']); + $this->assertSame('2026-08-18T12:00:00Z', $activity['activity_stats']['last_release_date']); + $this->assertSame(100, $calls[1][2]['query']['per_page']); + $this->assertArrayHasKey('since', $calls[1][2]['query']); + $this->assertSame(50, $calls[2][2]['query']['per_page']); + $this->assertSame(10, $calls[3][2]['query']['per_page']); + } + + public function testFetchRepoActivityReturnsNullWhenRepositoryDoesNotExist(): void + { + $calls = []; + $client = new GitHubApiClient($this->getMockClient([ + 'GET /repos/owner/missing' => [404, ['message' => 'Not Found']], + ], $calls)); + + $this->assertNull($client->fetchRepoActivity('owner/missing')); + } + + public function testFetchRepoActivityReturnsNullWhenActivityEndpointIsMissing(): void + { + $calls = []; + $client = new GitHubApiClient($this->getMockClient([ + 'GET /repos/owner/repo' => [200, ['name' => 'repo']], + 'GET /repos/owner/repo/commits' => [404, ['message' => 'Not Found']], + ], $calls)); + + $this->assertNull($client->fetchRepoActivity('owner/repo')); + } + + public function testFetchRepoActivityRethrowsUnexpectedErrors(): void + { + $calls = []; + $client = new GitHubApiClient($this->getMockClient([ + 'GET /repos/owner/repo' => [200, ['name' => 'repo']], + 'GET /repos/owner/repo/commits' => [500, ['message' => 'Unavailable']], + ], $calls)); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('GitHub API error: Unavailable'); + $client->fetchRepoActivity('owner/repo'); + } + public function testSearchRepositoriesUsesQueryParameters(): void { $responses = [ @@ -70,6 +198,18 @@ public function testSearchRepositoriesUsesQueryParameters(): void ); } + public function testSearchRepositoriesRethrowsApiErrors(): void + { + $calls = []; + $client = new GitHubApiClient($this->getMockClient([ + 'GET /search/repositories' => [422, ['message' => 'Invalid query']], + ], $calls)); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('GitHub API error: Invalid query'); + $client->searchRepositories('broken'); + } + public function testFetchSecurityAdvisoriesAggregatesData(): void { $responses = [ @@ -86,4 +226,193 @@ public function testFetchSecurityAdvisoriesAggregatesData(): void $this->assertSame(1, $data['advisory_count']); $this->assertFalse($data['has_security_policy']); } + + public function testFetchSecurityAdvisoriesIncludesAlertsAndSecurityPolicy(): void + { + $responses = [ + 'GET /repos/owner/repo/security-advisories' => [200, [['id' => 1], ['id' => 2]]], + 'GET /repos/owner/repo/vulnerability-alerts' => [200, ['enabled' => true]], + 'GET /repos/owner/repo/contents/SECURITY.md' => [200, ['name' => 'SECURITY.md']], + ]; + $calls = []; + $client = new GitHubApiClient($this->getMockClient($responses, $calls)); + + $data = $client->fetchSecurityAdvisories('owner/repo'); + + $this->assertNotNull($data); + $this->assertSame(['enabled' => true], $data['vulnerability_alerts']); + $this->assertSame(2, $data['advisory_count']); + $this->assertTrue($data['has_security_policy']); + } + + public function testFetchSecurityAdvisoriesReturnsNullWhenEndpointIsMissing(): void + { + $calls = []; + $client = new GitHubApiClient($this->getMockClient([ + 'GET /repos/owner/repo/security-advisories' => [404, ['message' => 'Not Found']], + ], $calls)); + + $this->assertNull($client->fetchSecurityAdvisories('owner/repo')); + } + + public function testFetchSecurityAdvisoriesRethrowsUnexpectedAlertsError(): void + { + $responses = [ + 'GET /repos/owner/repo/security-advisories' => [200, []], + 'GET /repos/owner/repo/vulnerability-alerts' => [500, ['message' => 'Unavailable']], + ]; + $calls = []; + $client = new GitHubApiClient($this->getMockClient($responses, $calls)); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('GitHub API error: Unavailable'); + $client->fetchSecurityAdvisories('owner/repo'); + } + + public function testFetchSecurityAdvisoriesRethrowsSecurityPolicyErrors(): void + { + $responses = [ + 'GET /repos/owner/repo/security-advisories' => [200, []], + 'GET /repos/owner/repo/vulnerability-alerts' => [200, []], + 'GET /repos/owner/repo/contents/SECURITY.md' => [500, ['message' => 'Unavailable']], + ]; + $calls = []; + $client = new GitHubApiClient($this->getMockClient($responses, $calls)); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('GitHub API error: Unavailable'); + $client->fetchSecurityAdvisories('owner/repo'); + } + + public function testFetchRepoFilesBuildsRepositoryOverview(): void + { + $responses = [ + 'GET /repos/owner/repo' => [200, ['name' => 'repo']], + 'GET /repos/owner/repo/contents' => [200, [['name' => 'README.md'], ['name' => 'src']]], + 'GET /repos/owner/repo/contents/README.md' => [200, ['name' => 'README.md']], + 'GET /repos/owner/repo/contents/LICENSE' => [200, ['name' => 'LICENSE']], + 'GET /repos/owner/repo/contents/SECURITY.md' => [200, ['name' => 'SECURITY.md']], + 'GET /repos/owner/repo/contents/composer.json' => [404, ['message' => 'Not Found']], + 'GET /repos/owner/repo/contents/package.json' => [404, ['message' => 'Not Found']], + ]; + $calls = []; + $client = new GitHubApiClient($this->getMockClient($responses, $calls)); + + $files = $client->fetchRepoFiles('owner/repo'); + + $this->assertNotNull($files); + $this->assertSame('main', $files['default_branch']); + $this->assertSame(2, $files['file_count']); + $this->assertTrue($files['has_readme']); + $this->assertTrue($files['has_license']); + $this->assertTrue($files['has_security_policy']); + $this->assertArrayNotHasKey('composer.json', $files['important_files']); + $this->assertSame(['ref' => 'main'], $calls[1][2]['query']); + } + + public function testFetchRepoFilesReturnsNullWhenRepositoryDoesNotExist(): void + { + $calls = []; + $client = new GitHubApiClient($this->getMockClient([ + 'GET /repos/owner/missing' => [404, ['message' => 'Not Found']], + ], $calls)); + + $this->assertNull($client->fetchRepoFiles('owner/missing')); + } + + public function testFetchRepoFilesReturnsNullWhenContentsEndpointIsMissing(): void + { + $calls = []; + $client = new GitHubApiClient($this->getMockClient([ + 'GET /repos/owner/repo' => [200, ['name' => 'repo']], + 'GET /repos/owner/repo/contents' => [404, ['message' => 'Not Found']], + ], $calls)); + + $this->assertNull($client->fetchRepoFiles('owner/repo')); + } + + public function testFetchRepoFilesRethrowsUnexpectedFileError(): void + { + $responses = [ + 'GET /repos/owner/repo' => [200, ['default_branch' => 'stable']], + 'GET /repos/owner/repo/contents' => [200, []], + 'GET /repos/owner/repo/contents/README.md' => [500, ['message' => 'Unavailable']], + ]; + $calls = []; + $client = new GitHubApiClient($this->getMockClient($responses, $calls)); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('GitHub API error: Unavailable'); + $client->fetchRepoFiles('owner/repo'); + } + + public function testFetchRepoContentsRethrowsApiErrors(): void + { + $calls = []; + $client = new GitHubApiClient($this->getMockClient([ + 'GET /repos/owner/repo/contents/file.txt' => [500, ['message' => 'Unavailable']], + ], $calls)); + + $this->expectException(ApiException::class); + $client->fetchRepoContents('owner/repo', 'file.txt'); + } + + public function testFetchFileContentReturnsNullWithoutContent(): void + { + $calls = []; + $client = new GitHubApiClient($this->getMockClient([ + 'GET /repos/owner/repo/contents/file.txt' => [200, ['name' => 'file.txt']], + ], $calls)); + + $this->assertNull($client->fetchFileContent('owner/repo', 'file.txt')); + } + + public function testInvalidJsonResponseThrowsApiException(): void + { + $client = new GitHubApiClient(new MockHttpClient([ + new MockResponse('{invalid'), + ])); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Invalid JSON response from GitHub API'); + $client->fetchRepoMetadata('owner/repo'); + } + + public function testTransportErrorsBecomeNetworkExceptions(): void + { + $transport = new class('Connection failed') extends \RuntimeException implements TransportExceptionInterface {}; + $httpClient = $this->createStub(HttpClientInterface::class); + $httpClient->method('request')->willThrowException($transport); + $client = new GitHubApiClient($httpClient); + + $this->expectException(NetworkException::class); + $this->expectExceptionMessage('Network error while calling GitHub API: Connection failed'); + $client->fetchRepoMetadata('owner/repo'); + } + + public function testHttpClientErrorsBecomeApiExceptions(): void + { + $response = $this->createStub(ResponseInterface::class); + $httpError = new class($response) extends \RuntimeException implements ClientExceptionInterface { + public function __construct(private readonly ResponseInterface $response) + { + parent::__construct('Request failed'); + } + + public function getResponse(): ResponseInterface + { + return $this->response; + } + }; + $httpResponse = $this->createStub(ResponseInterface::class); + $httpResponse->method('getStatusCode')->willReturn(200); + $httpResponse->method('getContent')->willThrowException($httpError); + $httpClient = $this->createStub(HttpClientInterface::class); + $httpClient->method('request')->willReturn($httpResponse); + $client = new GitHubApiClient($httpClient); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('HTTP error while calling GitHub API: Request failed'); + $client->fetchRepoMetadata('owner/repo'); + } } diff --git a/tests/Bridge/GitHub/GitHubContentProviderTest.php b/tests/Bridge/GitHub/GitHubContentProviderTest.php index 74801cc..e1675a4 100644 --- a/tests/Bridge/GitHub/GitHubContentProviderTest.php +++ b/tests/Bridge/GitHub/GitHubContentProviderTest.php @@ -25,6 +25,18 @@ #[CoversClass(GitHubContentProvider::class)] final class GitHubContentProviderTest extends TestCase { + public function testSupportsOnlyGitHubRepositories(): void + { + $provider = new GitHubContentProvider(new GitHubApiClient(new MockHttpClient())); + $package = new ComposerPackage('owner/repo'); + + $this->assertFalse($provider->supports($package)); + $package->setRepositoryUrl('https://gitlab.com/owner/repo'); + $this->assertFalse($provider->supports($package)); + $package->setRepositoryUrl('https://github.com/owner/repo'); + $this->assertTrue($provider->supports($package)); + } + public function testGetContentOverviewBuildsModel(): void { $responses = [ @@ -54,4 +66,42 @@ public function testGetContentOverviewBuildsModel(): void $this->assertTrue($overview->hasLicense()); $this->assertTrue($overview->hasGitignore()); } + + public function testGetContentOverviewReturnsNullWithoutRepository(): void + { + $provider = new GitHubContentProvider(new GitHubApiClient(new MockHttpClient())); + + $this->assertNull($provider->getContentOverview(new ComposerPackage('owner/repo'))); + } + + public function testGetContentOverviewReturnsNullForInvalidRepositoryUrl(): void + { + $provider = new GitHubContentProvider(new GitHubApiClient(new MockHttpClient())); + $package = new ComposerPackage('owner/repo'); + $package->setRepositoryUrl('not-a-repository'); + + $this->assertNull($provider->getContentOverview($package)); + } + + public function testGetContentOverviewReturnsNullWhenRepositoryIsMissing(): void + { + $provider = new GitHubContentProvider(new GitHubApiClient(new MockHttpClient([ + new MockResponse('', ['http_code' => 404]), + ]))); + $package = new ComposerPackage('owner/repo'); + $package->setRepositoryUrl('https://github.com/owner/repo'); + + $this->assertNull($provider->getContentOverview($package)); + } + + public function testGetContentOverviewReturnsNullOnApiFailure(): void + { + $provider = new GitHubContentProvider(new GitHubApiClient(new MockHttpClient([ + new MockResponse('', ['http_code' => 500]), + ]))); + $package = new ComposerPackage('owner/repo'); + $package->setRepositoryUrl('https://github.com/owner/repo'); + + $this->assertNull($provider->getContentOverview($package)); + } } diff --git a/tests/Bridge/GitHub/GitHubMetadataProviderTest.php b/tests/Bridge/GitHub/GitHubMetadataProviderTest.php index 193aa0b..3b9e663 100644 --- a/tests/Bridge/GitHub/GitHubMetadataProviderTest.php +++ b/tests/Bridge/GitHub/GitHubMetadataProviderTest.php @@ -56,10 +56,47 @@ public function testSupportsReturnTrueForGitHubRepositories(): void $provider = new GitHubMetadataProvider($client); $package = new ComposerPackage('owner/repo'); + $this->assertFalse($provider->supports($package)); $package->setRepositoryUrl('https://example.com/owner/repo'); $this->assertFalse($provider->supports($package)); $package->setRepositoryUrl('https://github.com/owner/repo'); $this->assertTrue($provider->supports($package)); } + + public function testGetMetadataReturnsNullWithoutRepository(): void + { + $provider = new GitHubMetadataProvider(new GitHubApiClient(new MockHttpClient())); + + $this->assertNull($provider->getMetadata(new ComposerPackage('owner/repo'))); + } + + public function testGetMetadataReturnsNullForInvalidRepositoryUrl(): void + { + $provider = new GitHubMetadataProvider(new GitHubApiClient(new MockHttpClient())); + $package = new ComposerPackage('owner/repo'); + $package->setRepositoryUrl('not-a-repository'); + + $this->assertNull($provider->getMetadata($package)); + } + + public function testGetMetadataReturnsNullWhenRepositoryIsMissing(): void + { + $provider = new GitHubMetadataProvider(new GitHubApiClient(new MockHttpClient([ + new MockResponse('', ['http_code' => 404]), + ]))); + $package = new ComposerPackage('owner/repo'); + $package->setRepositoryUrl('https://github.com/owner/repo'); + + $this->assertNull($provider->getMetadata($package)); + } + + public function testGetMetadataReturnsNullForMalformedGitHubRepository(): void + { + $provider = new GitHubMetadataProvider(new GitHubApiClient(new MockHttpClient())); + $package = new ComposerPackage('owner/repo'); + $package->setRepositoryUrl('https://github.com/owner!/repo'); + + $this->assertNull($provider->getMetadata($package)); + } } diff --git a/tests/Bridge/GitHub/GitHubSearchProviderTest.php b/tests/Bridge/GitHub/GitHubSearchProviderTest.php index 411b0f4..d4e178c 100644 --- a/tests/Bridge/GitHub/GitHubSearchProviderTest.php +++ b/tests/Bridge/GitHub/GitHubSearchProviderTest.php @@ -82,4 +82,33 @@ public function testSearchByKeywordDelegatesToSearchAndMapsResults(): void ], ], $result); } + + public function testGetPopularMapsRepositoriesSortedByStars(): void + { + $responses = [ + new MockResponse(json_encode([ + 'items' => [ + [ + 'full_name' => 'popular/project', + 'name' => 'project', + 'description' => 'Popular project', + 'html_url' => 'https://github.com/popular/project', + ], + ], + ])), + ]; + $api = new GitHubApiClient(new MockHttpClient($responses)); + $provider = new GitHubSearchProvider($api); + + $result = $provider->getPopular(1); + + $this->assertSame([ + [ + 'identifier' => 'popular/project', + 'name' => 'project', + 'description' => 'Popular project', + 'repository' => 'https://github.com/popular/project', + ], + ], $result); + } } diff --git a/tests/Bridge/GitHub/GitHubSecurityProviderTest.php b/tests/Bridge/GitHub/GitHubSecurityProviderTest.php index 770a3ae..4c66832 100644 --- a/tests/Bridge/GitHub/GitHubSecurityProviderTest.php +++ b/tests/Bridge/GitHub/GitHubSecurityProviderTest.php @@ -30,6 +30,8 @@ public function testSupportsChecksRepository(): void $client = new GitHubApiClient(new MockHttpClient()); $provider = new GitHubSecurityProvider($client); + $this->assertFalse($provider->supports(new ComposerPackage('owner/repo'))); + $pkg = new ComposerPackage('owner/repo'); $pkg->setRepositoryUrl('https://github.com/owner/repo'); $this->assertTrue($provider->supports($pkg)); @@ -59,4 +61,55 @@ public function testGetSecurityAdvisoriesReturnsObjects(): void $this->assertInstanceOf(SecurityAdvisory::class, $result[0]); $this->assertSame('GHSA-1', $result[0]->getId()); } + + public function testGetSecurityAdvisoriesReturnsEmptyWithoutRepository(): void + { + $provider = new GitHubSecurityProvider(new GitHubApiClient(new MockHttpClient())); + + $this->assertSame([], $provider->getSecurityAdvisories(new ComposerPackage('owner/repo'))); + } + + public function testGetSecurityAdvisoriesReturnsEmptyForInvalidRepositoryUrl(): void + { + $provider = new GitHubSecurityProvider(new GitHubApiClient(new MockHttpClient())); + $package = new ComposerPackage('owner/repo'); + $package->setRepositoryUrl('not-a-repository'); + + $this->assertSame([], $provider->getSecurityAdvisories($package)); + } + + public function testGetSecurityAdvisoriesReturnsEmptyWhenRepositoryIsMissing(): void + { + $provider = new GitHubSecurityProvider(new GitHubApiClient(new MockHttpClient([ + new MockResponse('', ['http_code' => 404]), + ]))); + $package = new ComposerPackage('owner/repo'); + $package->setRepositoryUrl('https://github.com/owner/repo'); + + $this->assertSame([], $provider->getSecurityAdvisories($package)); + } + + public function testGetSecurityAdvisoriesReturnsEmptyWithoutAdvisories(): void + { + $provider = new GitHubSecurityProvider(new GitHubApiClient(new MockHttpClient([ + new MockResponse('[]'), + new MockResponse('[]'), + new MockResponse('', ['http_code' => 404]), + ]))); + $package = new ComposerPackage('owner/repo'); + $package->setRepositoryUrl('https://github.com/owner/repo'); + + $this->assertSame([], $provider->getSecurityAdvisories($package)); + } + + public function testGetSecurityAdvisoriesReturnsEmptyOnApiFailure(): void + { + $provider = new GitHubSecurityProvider(new GitHubApiClient(new MockHttpClient([ + new MockResponse('', ['http_code' => 500]), + ]))); + $package = new ComposerPackage('owner/repo'); + $package->setRepositoryUrl('https://github.com/owner/repo'); + + $this->assertSame([], $provider->getSecurityAdvisories($package)); + } } diff --git a/tests/Bridge/GitHub/GitHubStatisticProviderTest.php b/tests/Bridge/GitHub/GitHubStatisticProviderTest.php index 91b89e7..046807c 100644 --- a/tests/Bridge/GitHub/GitHubStatisticProviderTest.php +++ b/tests/Bridge/GitHub/GitHubStatisticProviderTest.php @@ -15,6 +15,7 @@ use PackApi\Bridge\GitHub\GitHubApiClient; use PackApi\Bridge\GitHub\GitHubStatisticProvider; +use PackApi\Model\DownloadPeriod; use PackApi\Model\DownloadStats; use PackApi\Package\ComposerPackage; use PHPUnit\Framework\Attributes\CoversClass; @@ -25,6 +26,14 @@ #[CoversClass(GitHubStatisticProvider::class)] final class GitHubStatisticProviderTest extends TestCase { + private function createPackage(?string $repository): ComposerPackage + { + $package = new ComposerPackage('owner/repo'); + $package->setRepositoryUrl($repository); + + return $package; + } + public function testGetStatsReturnsDownloadStats(): void { $responses = [ @@ -45,4 +54,72 @@ public function testGetStatsReturnsDownloadStats(): void $this->assertInstanceOf(DownloadStats::class, $stats); $this->assertTrue($stats->has('monthly')); } + + public function testSupportsOnlyGitHubRepositories(): void + { + $provider = new GitHubStatisticProvider(new GitHubApiClient(new MockHttpClient())); + + $this->assertFalse($provider->supports($this->createPackage(null))); + $this->assertFalse($provider->supports($this->createPackage('https://gitlab.com/owner/repo'))); + $this->assertTrue($provider->supports($this->createPackage('https://github.com/owner/repo'))); + } + + public function testGetStatsForPeriodReturnsNullWithoutRepository(): void + { + $provider = new GitHubStatisticProvider(new GitHubApiClient(new MockHttpClient())); + + $this->assertNull($provider->getStatsForPeriod($this->createPackage(null), $this->createPeriod())); + } + + public function testGetStatsForPeriodReturnsNullForInvalidRepositoryUrl(): void + { + $provider = new GitHubStatisticProvider(new GitHubApiClient(new MockHttpClient())); + + $this->assertNull($provider->getStatsForPeriod($this->createPackage('not-a-repository'), $this->createPeriod())); + } + + public function testGetStatsForPeriodReturnsNullWhenRepositoryIsMissing(): void + { + $provider = new GitHubStatisticProvider(new GitHubApiClient(new MockHttpClient([ + new MockResponse('', ['http_code' => 404]), + ]))); + + $this->assertNull($provider->getStatsForPeriod($this->createPackage('https://github.com/owner/repo'), $this->createPeriod())); + } + + public function testGetStatsForPeriodReturnsNullWhenActivityIsMissing(): void + { + $provider = new GitHubStatisticProvider(new GitHubApiClient(new MockHttpClient([ + new MockResponse('{"stargazers_count":5}'), + new MockResponse('', ['http_code' => 404]), + ]))); + + $this->assertNull($provider->getStatsForPeriod($this->createPackage('https://github.com/owner/repo'), $this->createPeriod())); + } + + public function testGetStatsForPeriodReturnsNullForMalformedGitHubRepository(): void + { + $provider = new GitHubStatisticProvider(new GitHubApiClient(new MockHttpClient())); + + $this->assertNull($provider->getStatsForPeriod($this->createPackage('https://github.com/owner!/repo'), $this->createPeriod())); + } + + public function testCapabilities(): void + { + $package = $this->createPackage('https://github.com/owner/repo'); + $provider = new GitHubStatisticProvider(new GitHubApiClient(new MockHttpClient())); + + $this->assertSame(['total', 'monthly'], $provider->getAvailablePeriods($package)); + $this->assertFalse($provider->hasCdnStats($package)); + } + + private function createPeriod(): DownloadPeriod + { + return new DownloadPeriod( + 'custom', + 0, + new \DateTimeImmutable('2026-08-01'), + new \DateTimeImmutable('2026-08-02'), + ); + } } diff --git a/tests/Bridge/JsDelivr/JsDelivrApiClientTest.php b/tests/Bridge/JsDelivr/JsDelivrApiClientTest.php index fa1947e..ce55af5 100644 --- a/tests/Bridge/JsDelivr/JsDelivrApiClientTest.php +++ b/tests/Bridge/JsDelivr/JsDelivrApiClientTest.php @@ -58,8 +58,8 @@ public function testFetchPackageMetaThrowsOnError(): void public function testFetchPackageMetaThrowsOnTransportException(): void { - $transport = $this->createMock(TransportExceptionInterface::class); - $http = $this->createMock(HttpClientInterface::class); + $transport = new class extends \RuntimeException implements TransportExceptionInterface {}; + $http = $this->createStub(HttpClientInterface::class); $http->method('request')->willThrowException($transport); $client = new JsDelivrApiClient($http); @@ -119,8 +119,8 @@ public function testFetchFileListThrowsOnError(): void public function testFetchFileListThrowsOnTransportException(): void { - $transport = $this->createMock(TransportExceptionInterface::class); - $http = $this->createMock(HttpClientInterface::class); + $transport = new class extends \RuntimeException implements TransportExceptionInterface {}; + $http = $this->createStub(HttpClientInterface::class); $http->method('request')->willThrowException($transport); $client = new JsDelivrApiClient($http); diff --git a/tests/Bridge/JsDelivr/JsDelivrProviderFactoryTest.php b/tests/Bridge/JsDelivr/JsDelivrProviderFactoryTest.php index 8ef0f5b..c3d2855 100644 --- a/tests/Bridge/JsDelivr/JsDelivrProviderFactoryTest.php +++ b/tests/Bridge/JsDelivr/JsDelivrProviderFactoryTest.php @@ -21,7 +21,6 @@ use PackApi\Http\HttpClientFactoryInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; #[CoversClass(JsDelivrProviderFactory::class)] @@ -30,8 +29,7 @@ final class JsDelivrProviderFactoryTest extends TestCase public function testFactoryCreatesProvidersAndApiClient(): void { $httpFactory = $this->createMock(HttpClientFactoryInterface::class); - $client = new MockHttpClient(); - $scoped = $this->createMock(HttpClientInterface::class); + $scoped = $this->createStub(HttpClientInterface::class); $scoped->method('withOptions')->willReturnSelf(); $httpFactory->expects($this->once())->method('createClient')->willReturn($scoped); diff --git a/tests/Bridge/Npm/NpmApiClientTest.php b/tests/Bridge/Npm/NpmApiClientTest.php index 73b7b5f..9423c15 100644 --- a/tests/Bridge/Npm/NpmApiClientTest.php +++ b/tests/Bridge/Npm/NpmApiClientTest.php @@ -14,10 +14,13 @@ namespace PackApi\Tests\Bridge\Npm; use PackApi\Bridge\Npm\NpmApiClient; +use PackApi\Exception\NetworkException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; #[CoversClass(NpmApiClient::class)] class NpmApiClientTest extends TestCase @@ -54,14 +57,23 @@ public function testFetchPackageInfoReturnsNullForNotFound(): void public function testFetchPackageInfoThrowsNetworkExceptionOnError(): void { - $registryClient = new MockHttpClient(function () { - throw new \Exception('Network error'); - }); + $registryClient = new MockHttpClient(new MockResponse('', ['http_code' => 500])); $statsClient = new MockHttpClient(); $client = new NpmApiClient($registryClient, $statsClient); - $this->expectException(\Exception::class); + $this->expectException(NetworkException::class); + $client->fetchPackageInfo('example-package'); + } + + public function testFetchPackageInfoWrapsTransportErrors(): void + { + $transport = new class extends \RuntimeException implements TransportExceptionInterface {}; + $registryClient = $this->createStub(HttpClientInterface::class); + $registryClient->method('request')->willThrowException($transport); + $client = new NpmApiClient($registryClient, new MockHttpClient()); + + $this->expectException(NetworkException::class); $client->fetchPackageInfo('example-package'); } @@ -97,14 +109,23 @@ public function testFetchDownloadStatsReturnsNullForNotFound(): void public function testFetchDownloadStatsThrowsNetworkExceptionOnError(): void { - $statsClient = new MockHttpClient(function () { - throw new \Exception('Network error'); - }); + $statsClient = new MockHttpClient(new MockResponse('', ['http_code' => 500])); $registryClient = new MockHttpClient(); $client = new NpmApiClient($registryClient, $statsClient); - $this->expectException(\Exception::class); + $this->expectException(NetworkException::class); + $client->fetchDownloadStats('example-package'); + } + + public function testFetchDownloadStatsWrapsTransportErrors(): void + { + $transport = new class extends \RuntimeException implements TransportExceptionInterface {}; + $statsClient = $this->createStub(HttpClientInterface::class); + $statsClient->method('request')->willThrowException($transport); + $client = new NpmApiClient(new MockHttpClient(), $statsClient); + + $this->expectException(NetworkException::class); $client->fetchDownloadStats('example-package'); } } diff --git a/tests/Bridge/Npm/NpmProviderFactoryTest.php b/tests/Bridge/Npm/NpmProviderFactoryTest.php index 72689a8..fc02da9 100644 --- a/tests/Bridge/Npm/NpmProviderFactoryTest.php +++ b/tests/Bridge/Npm/NpmProviderFactoryTest.php @@ -29,7 +29,7 @@ final class NpmProviderFactoryTest extends TestCase { public function testProvidesReturnsExpectedInterfaces(): void { - $httpClientFactory = $this->createMock(HttpClientFactoryInterface::class); + $httpClientFactory = $this->createStub(HttpClientFactoryInterface::class); $factory = new NpmProviderFactory($httpClientFactory); $result = $factory->provides(); @@ -43,7 +43,7 @@ public function testProvidesReturnsExpectedInterfaces(): void public function testCreateThrowsLogicExceptionForUnsupportedInterface(): void { - $httpClientFactory = $this->createMock(HttpClientFactoryInterface::class); + $httpClientFactory = $this->createStub(HttpClientFactoryInterface::class); $factory = new NpmProviderFactory($httpClientFactory); $this->expectException(\LogicException::class); @@ -54,7 +54,7 @@ public function testCreateThrowsLogicExceptionForUnsupportedInterface(): void public function testCreateMetadataProviderReturnsInstance(): void { - $httpClientFactory = $this->createMock(HttpClientFactoryInterface::class); + $httpClientFactory = $this->createStub(HttpClientFactoryInterface::class); $factory = new NpmProviderFactory($httpClientFactory); $result = $factory->create(MetadataProviderInterface::class); @@ -64,7 +64,7 @@ public function testCreateMetadataProviderReturnsInstance(): void public function testCreateDownloadStatsProviderReturnsInstance(): void { - $httpClientFactory = $this->createMock(HttpClientFactoryInterface::class); + $httpClientFactory = $this->createStub(HttpClientFactoryInterface::class); $factory = new NpmProviderFactory($httpClientFactory); $result = $factory->create(DownloadStatsProviderInterface::class); @@ -74,7 +74,7 @@ public function testCreateDownloadStatsProviderReturnsInstance(): void public function testCreateContentProviderReturnsInstance(): void { - $httpClientFactory = $this->createMock(HttpClientFactoryInterface::class); + $httpClientFactory = $this->createStub(HttpClientFactoryInterface::class); $factory = new NpmProviderFactory($httpClientFactory); $result = $factory->create(ContentProviderInterface::class); diff --git a/tests/Bridge/OSV/OSVApiClientTest.php b/tests/Bridge/OSV/OSVApiClientTest.php index 51e268d..c6f2adb 100644 --- a/tests/Bridge/OSV/OSVApiClientTest.php +++ b/tests/Bridge/OSV/OSVApiClientTest.php @@ -19,6 +19,8 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; #[CoversClass(OSVApiClient::class)] final class OSVApiClientTest extends TestCase @@ -93,6 +95,17 @@ public function testQueryVulnerabilitiesWithVersion(): void $this->assertSame($responseData, $result); } + public function testQueryVulnerabilitiesWrapsTransportErrors(): void + { + $transport = new class extends \RuntimeException implements TransportExceptionInterface {}; + $http = $this->createStub(HttpClientInterface::class); + $http->method('request')->willThrowException($transport); + $client = new OSVApiClient($http); + + $this->expectException(NetworkException::class); + $client->queryVulnerabilities('npm', 'test-package'); + } + public function testGetVulnerabilityByIdReturnsData(): void { $responseData = [ @@ -123,6 +136,27 @@ public function testGetVulnerabilityByIdReturnsNullOn404(): void $this->assertNull($result); } + public function testGetVulnerabilityByIdThrowsNetworkExceptionOnError(): void + { + $client = new OSVApiClient(new MockHttpClient([ + new MockResponse('', ['http_code' => 500]), + ])); + + $this->expectException(NetworkException::class); + $client->getVulnerabilityById('OSV-2023-1234'); + } + + public function testGetVulnerabilityByIdWrapsTransportErrors(): void + { + $transport = new class extends \RuntimeException implements TransportExceptionInterface {}; + $http = $this->createStub(HttpClientInterface::class); + $http->method('request')->willThrowException($transport); + $client = new OSVApiClient($http); + + $this->expectException(NetworkException::class); + $client->getVulnerabilityById('OSV-2023-1234'); + } + public function testBatchQueryVulnerabilities(): void { $responseData = [ @@ -146,4 +180,25 @@ public function testBatchQueryVulnerabilities(): void $this->assertSame($responseData, $result); } + + public function testBatchQueryVulnerabilitiesThrowsNetworkExceptionOnError(): void + { + $client = new OSVApiClient(new MockHttpClient([ + new MockResponse('', ['http_code' => 500]), + ])); + + $this->expectException(NetworkException::class); + $client->batchQueryVulnerabilities([]); + } + + public function testBatchQueryVulnerabilitiesWrapsTransportErrors(): void + { + $transport = new class extends \RuntimeException implements TransportExceptionInterface {}; + $http = $this->createStub(HttpClientInterface::class); + $http->method('request')->willThrowException($transport); + $client = new OSVApiClient($http); + + $this->expectException(NetworkException::class); + $client->batchQueryVulnerabilities([]); + } } diff --git a/tests/Bridge/OSV/OSVProviderFactoryTest.php b/tests/Bridge/OSV/OSVProviderFactoryTest.php index 71cd15e..18756d0 100644 --- a/tests/Bridge/OSV/OSVProviderFactoryTest.php +++ b/tests/Bridge/OSV/OSVProviderFactoryTest.php @@ -20,7 +20,6 @@ use PackApi\Provider\SecurityProviderInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; #[CoversClass(OSVProviderFactory::class)] @@ -31,12 +30,10 @@ final class OSVProviderFactoryTest extends TestCase protected function setUp(): void { - // Create a mock factory that returns a mock HTTP client - $this->httpClientFactory = $this->createMock(HttpClientFactoryInterface::class); - $mockHttpClient = new MockHttpClient(); + // Create a factory stub that returns a scoped HTTP client stub. + $this->httpClientFactory = $this->createStub(HttpClientFactoryInterface::class); - // Mock the withOptions method to return the same client for testing - $scopedClient = $this->createMock(HttpClientInterface::class); + $scopedClient = $this->createStub(HttpClientInterface::class); $scopedClient->method('withOptions')->willReturnSelf(); $this->httpClientFactory diff --git a/tests/Bridge/OSV/OSVSecurityProviderTest.php b/tests/Bridge/OSV/OSVSecurityProviderTest.php index 461a2bc..893e86a 100644 --- a/tests/Bridge/OSV/OSVSecurityProviderTest.php +++ b/tests/Bridge/OSV/OSVSecurityProviderTest.php @@ -18,6 +18,7 @@ use PackApi\Model\SecurityAdvisory; use PackApi\Package\ComposerPackage; use PackApi\Package\NpmPackage; +use PackApi\Package\Package; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -29,6 +30,12 @@ final class OSVSecurityProviderTest extends TestCase private OSVSecurityProvider $provider; protected function setUp(): void + { + $this->osvApiClient = $this->createStub(OSVApiClient::class); + $this->provider = new OSVSecurityProvider($this->osvApiClient); + } + + private function useMockApiClient(): void { $this->osvApiClient = $this->createMock(OSVApiClient::class); $this->provider = new OSVSecurityProvider($this->osvApiClient); @@ -48,9 +55,19 @@ public function testSupportsNpmPackage(): void $this->assertTrue($this->provider->supports($package)); } + public function testUnsupportedPackageReturnsNoAdvisories(): void + { + $package = new class('other', 'other') extends Package {}; + + $this->assertFalse($this->provider->supports($package)); + $this->assertSame([], $this->provider->getSecurityAdvisories($package)); + $this->assertNull($this->provider->getSecurityAdvisoriesForVersion($package, '1.0.0')); + } + public function testGetSecurityAdvisoriesReturnsEmptyArrayWhenNoVulnerabilities(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $this->osvApiClient ->expects($this->once()) @@ -66,6 +83,7 @@ public function testGetSecurityAdvisoriesReturnsEmptyArrayWhenNoVulnerabilities( public function testGetSecurityAdvisoriesReturnsNullWhenApiReturnsNull(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $this->osvApiClient ->expects($this->once()) @@ -99,6 +117,7 @@ public function testGetSecurityAdvisoriesReturnsAdvisories(): void ], ], ]; + $this->useMockApiClient(); $this->osvApiClient ->expects($this->once()) @@ -123,6 +142,7 @@ public function testGetSecurityAdvisoriesForVersionCallsApiWithVersion(): void { $package = new NpmPackage('test-package'); $version = '1.0.0'; + $this->useMockApiClient(); $this->osvApiClient ->expects($this->once()) @@ -135,6 +155,43 @@ public function testGetSecurityAdvisoriesForVersionCallsApiWithVersion(): void $this->assertSame([], $result); } + public function testGetSecurityAdvisoriesForVersionMapsAdvisories(): void + { + $package = new NpmPackage('test-package'); + $this->useMockApiClient(); + $this->osvApiClient + ->expects($this->once()) + ->method('queryVulnerabilities') + ->with('npm', 'test-package', '1.0.0') + ->willReturn(['vulns' => [ + ['summary' => 'Missing identifier'], + ['id' => 'OSV-CRITICAL', 'severity' => [['score' => 9.5]]], + ['id' => 'OSV-MEDIUM', 'severity' => [['score' => 5.0]]], + ['id' => 'OSV-LOW', 'severity' => [['score' => 2.0]]], + ['id' => 'OSV-DEFAULT'], + ]]); + + $result = $this->provider->getSecurityAdvisoriesForVersion($package, '1.0.0'); + + $this->assertNotNull($result); + $this->assertSame(['CRITICAL', 'MEDIUM', 'LOW', 'MEDIUM'], array_map( + static fn (SecurityAdvisory $advisory): string => $advisory->getSeverity(), + $result, + )); + } + + public function testGetSecurityAdvisoriesForVersionReturnsNullOnFailure(): void + { + $package = new NpmPackage('test-package'); + $this->useMockApiClient(); + $this->osvApiClient + ->expects($this->once()) + ->method('queryVulnerabilities') + ->willThrowException(new \RuntimeException('API error')); + + $this->assertNull($this->provider->getSecurityAdvisoriesForVersion($package, '1.0.0')); + } + public function testIsVulnerabilityRelevantReturnsTrueWhenPackageMatches(): void { $package = new NpmPackage('test-package'); @@ -150,6 +207,7 @@ public function testIsVulnerabilityRelevantReturnsTrueWhenPackageMatches(): void ], ], ]; + $this->useMockApiClient(); $this->osvApiClient ->expects($this->once()) @@ -177,6 +235,7 @@ public function testIsVulnerabilityRelevantReturnsFalseWhenPackageDoesNotMatch() ], ], ]; + $this->useMockApiClient(); $this->osvApiClient ->expects($this->once()) @@ -189,6 +248,30 @@ public function testIsVulnerabilityRelevantReturnsFalseWhenPackageDoesNotMatch() $this->assertFalse($result); } + public function testIsVulnerabilityRelevantReturnsFalseWithoutAffectedPackages(): void + { + $package = new NpmPackage('test-package'); + $this->useMockApiClient(); + $this->osvApiClient + ->expects($this->once()) + ->method('getVulnerabilityById') + ->willReturn(null); + + $this->assertFalse($this->provider->isVulnerabilityRelevant($package, 'OSV-1')); + } + + public function testIsVulnerabilityRelevantReturnsFalseOnFailure(): void + { + $package = new NpmPackage('test-package'); + $this->useMockApiClient(); + $this->osvApiClient + ->expects($this->once()) + ->method('getVulnerabilityById') + ->willThrowException(new \RuntimeException('API error')); + + $this->assertFalse($this->provider->isVulnerabilityRelevant($package, 'OSV-1')); + } + public function testGetVulnerabilityDetailsReturnsData(): void { $vulnId = 'OSV-2023-1234'; @@ -197,6 +280,7 @@ public function testGetVulnerabilityDetailsReturnsData(): void 'summary' => 'Test vulnerability', 'details' => 'Detailed description', ]; + $this->useMockApiClient(); $this->osvApiClient ->expects($this->once()) @@ -209,9 +293,21 @@ public function testGetVulnerabilityDetailsReturnsData(): void $this->assertSame($vulnerabilityData, $result); } + public function testGetVulnerabilityDetailsReturnsNullOnFailure(): void + { + $this->useMockApiClient(); + $this->osvApiClient + ->expects($this->once()) + ->method('getVulnerabilityById') + ->willThrowException(new \RuntimeException('API error')); + + $this->assertNull($this->provider->getVulnerabilityDetails('OSV-1')); + } + public function testGetSecurityAdvisoriesHandlesExceptionGracefully(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $this->osvApiClient ->expects($this->once()) diff --git a/tests/Bridge/Packagist/PackagistActivityProviderTest.php b/tests/Bridge/Packagist/PackagistActivityProviderTest.php index ae14984..2b51e40 100644 --- a/tests/Bridge/Packagist/PackagistActivityProviderTest.php +++ b/tests/Bridge/Packagist/PackagistActivityProviderTest.php @@ -137,4 +137,9 @@ public function testGetActivitySummaryReturnsLatestReleaseInfo(): void $this->assertSame(0, $summary->getContributors()); $this->assertSame(0, $summary->getOpenIssues()); } + + public function testGetActivitySummaryReturnsNullWithoutPackageData(): void + { + $this->assertNull($this->provider->getActivitySummary(new ComposerPackage('vendor/package'))); + } } diff --git a/tests/Bridge/Packagist/PackagistApiClientTest.php b/tests/Bridge/Packagist/PackagistApiClientTest.php index a0bf7e4..b2d46f9 100644 --- a/tests/Bridge/Packagist/PackagistApiClientTest.php +++ b/tests/Bridge/Packagist/PackagistApiClientTest.php @@ -25,10 +25,10 @@ public function testFetchPackageReturnsDecodedJson(): void $data = ['package' => ['downloads' => ['monthly' => 5]]]; $json = json_encode($data, JSON_THROW_ON_ERROR); - $response = $this->createMock(ResponseInterface::class); - $response->method('getContent')->with(false)->willReturn($json); + $response = $this->createStub(ResponseInterface::class); + $response->method('getContent')->willReturn($json); - $http = $this->createMock(HttpClientInterface::class); + $http = $this->createStub(HttpClientInterface::class); $http->method('request')->willReturn($response); $client = new PackagistApiClient($http); @@ -46,10 +46,10 @@ public function testFetchDownloadsRangeReturnsDownloadsArray(): void $data = ['downloads' => $downloads]; $json = json_encode($data, JSON_THROW_ON_ERROR); - $response = $this->createMock(ResponseInterface::class); - $response->method('getContent')->with(false)->willReturn($json); + $response = $this->createStub(ResponseInterface::class); + $response->method('getContent')->willReturn($json); - $http = $this->createMock(HttpClientInterface::class); + $http = $this->createStub(HttpClientInterface::class); $http->method('request')->willReturn($response); $client = new PackagistApiClient($http); diff --git a/tests/Bridge/Packagist/PackagistContentProviderTest.php b/tests/Bridge/Packagist/PackagistContentProviderTest.php index 820c84c..53b7aa2 100644 --- a/tests/Bridge/Packagist/PackagistContentProviderTest.php +++ b/tests/Bridge/Packagist/PackagistContentProviderTest.php @@ -167,6 +167,27 @@ public function testGetContentOverviewReturnsNullOnInvalidDistUrl(): void $this->assertNull($provider->getContentOverview($package)); } + public function testGetContentOverviewReturnsNullWhenExtractionDirectoryCannotBeCreated(): void + { + $package = new ComposerPackage('vendor/package'); + $client = new PackagistApiClient($this->getStubClient([ + 'GET packages/vendor/package.json' => [200, [ + 'package' => ['versions' => ['1.0.0' => ['dist' => ['url' => 'https://example.org/archive.zip']]]], + ]], + ])); + $fileHandler = $this->createStub(SecureFileHandlerInterface::class); + $fileHandler->method('downloadSafely')->willReturn('/missing/archive.zip'); + $tempFile = tempnam(sys_get_temp_dir(), 'packapi_blocked_'); + $this->assertNotFalse($tempFile); + $provider = new PackagistContentProvider($client, $fileHandler, $tempFile); + + try { + $this->assertNull($provider->getContentOverview($package)); + } finally { + unlink($tempFile); + } + } + public function testGetContentOverviewSuccessBuildsOverview(): void { $package = new ComposerPackage('vendor/package'); @@ -187,11 +208,14 @@ public function testGetContentOverviewSuccessBuildsOverview(): void /** @var SecureFileHandlerInterface&\PHPUnit\Framework\MockObject\MockObject $fileHandler */ $fileHandler = $this->createMock(SecureFileHandlerInterface::class); + $archivePath = tempnam(sys_get_temp_dir(), 'packapi_archive_'); + $this->assertNotFalse($archivePath); + $fileHandler ->expects($this->once()) ->method('downloadSafely') ->with('https://example.org/archive.zip') - ->willReturn(sys_get_temp_dir().'/fake_archive.zip'); + ->willReturn($archivePath); $fileHandler ->expects($this->once()) @@ -211,15 +235,15 @@ public function testGetContentOverviewSuccessBuildsOverview(): void $fileHandler ->method('validatePath') - ->willReturn(true); + ->willReturnCallback(static fn (string $path): bool => 'docs/guide.md' !== $path); $provider = new PackagistContentProvider($client, $fileHandler); $overview = $provider->getContentOverview($package); $this->assertNotNull($overview); - $this->assertSame(6, $overview->fileCount); - $this->assertSame(34, $overview->totalSize); // sum of file sizes written above + $this->assertSame(5, $overview->fileCount); + $this->assertSame(30, $overview->totalSize); $this->assertTrue($overview->hasReadme); $this->assertTrue($overview->hasLicense); $this->assertTrue($overview->hasTests); @@ -228,6 +252,7 @@ public function testGetContentOverviewSuccessBuildsOverview(): void $ignored = $overview->ignoredFiles; $this->assertContains('tests/ExampleTest.php', $ignored); - $this->assertContains('docs/guide.md', $ignored); + $this->assertNotContains('docs/guide.md', $ignored); + $this->assertFileDoesNotExist($archivePath); } } diff --git a/tests/Bridge/Packagist/PackagistMetadataProviderTest.php b/tests/Bridge/Packagist/PackagistMetadataProviderTest.php index b5088c9..6e8354b 100644 --- a/tests/Bridge/Packagist/PackagistMetadataProviderTest.php +++ b/tests/Bridge/Packagist/PackagistMetadataProviderTest.php @@ -131,4 +131,9 @@ public function testGetMetadataReturnsModel(): void $this->assertSame('MIT', $metadata->license); $this->assertSame('https://example.com/vendor/package', $metadata->repository); } + + public function testGetMetadataReturnsNullWithoutPackageData(): void + { + $this->assertNull($this->provider->getMetadata(new ComposerPackage('vendor/package'))); + } } diff --git a/tests/Bridge/Packagist/PackagistProviderFactoryTest.php b/tests/Bridge/Packagist/PackagistProviderFactoryTest.php index 3e217a3..71d51e3 100644 --- a/tests/Bridge/Packagist/PackagistProviderFactoryTest.php +++ b/tests/Bridge/Packagist/PackagistProviderFactoryTest.php @@ -45,10 +45,10 @@ public function testConstructorScopesHttpClient(): void public function testCreateMethodsReturnCorrectInstances(): void { - $client = $this->createMock(HttpClientInterface::class); + $client = $this->createStub(HttpClientInterface::class); $client->method('withOptions')->willReturnSelf(); - $factoryMock = $this->createMock(HttpClientFactoryInterface::class); + $factoryMock = $this->createStub(HttpClientFactoryInterface::class); $factoryMock->method('createClient')->willReturn($client); $factory = new PackagistProviderFactory($factoryMock); diff --git a/tests/Bridge/Packagist/PackagistSearchProviderTest.php b/tests/Bridge/Packagist/PackagistSearchProviderTest.php index 867892e..fa7964b 100644 --- a/tests/Bridge/Packagist/PackagistSearchProviderTest.php +++ b/tests/Bridge/Packagist/PackagistSearchProviderTest.php @@ -109,4 +109,23 @@ public function testSearchReturnsNormalizedResults(): void $this->assertSame('desc', $results[0]['description']); $this->assertSame('https://example.com/vendor/package', $results[0]['repository']); } + + public function testSearchByKeywordDelegatesToSearch(): void + { + $client = new PackagistApiClient($this->getStubClient([ + 'GET search.json' => [200, ['results' => [[ + 'name' => 'vendor/package', + ]]]], + ])); + $provider = new PackagistSearchProvider($client); + + $this->assertSame('vendor/package', $provider->searchByKeyword('vendor')[0]['identifier']); + } + + public function testGetPopularReturnsEmptyArray(): void + { + $provider = new PackagistSearchProvider(new PackagistApiClient($this->getStubClient())); + + $this->assertSame([], $provider->getPopular()); + } } diff --git a/tests/Bridge/Packagist/PackagistSecurityProviderTest.php b/tests/Bridge/Packagist/PackagistSecurityProviderTest.php index b736c71..383710e 100644 --- a/tests/Bridge/Packagist/PackagistSecurityProviderTest.php +++ b/tests/Bridge/Packagist/PackagistSecurityProviderTest.php @@ -139,4 +139,31 @@ public function testGetSecurityAdvisoriesReturnsModels(): void $this->assertSame('high', $advisories[0]->getSeverity()); $this->assertSame('https://example.com/CVE-1234.yaml', $advisories[0]->getLink()); } + + public function testGetSecurityAdvisoriesReturnsEmptyWhenDirectoryIsMissing(): void + { + $githubClient = new GitHubApiClient($this->getStubClient([ + 'GET /repos/FriendsOfPHP/security-advisories/contents/vendor/package' => [404, ['message' => 'Not Found']], + ])); + $provider = new PackagistSecurityProvider($githubClient); + + $this->assertSame([], $provider->getSecurityAdvisories(new ComposerPackage('vendor/package'))); + } + + public function testGetSecurityAdvisoriesSkipsMissingFileContent(): void + { + $files = [[ + 'type' => 'file', + 'name' => 'CVE-1234.yaml', + 'path' => 'vendor/package/CVE-1234.yaml', + 'html_url' => 'https://example.com/CVE-1234.yaml', + ]]; + $githubClient = new GitHubApiClient($this->getStubClient([ + 'GET /repos/FriendsOfPHP/security-advisories/contents/vendor/package' => [200, $files], + 'GET /repos/FriendsOfPHP/security-advisories/contents/vendor/package/CVE-1234.yaml' => [404, ['message' => 'Not Found']], + ])); + $provider = new PackagistSecurityProvider($githubClient); + + $this->assertSame([], $provider->getSecurityAdvisories(new ComposerPackage('vendor/package'))); + } } diff --git a/tests/Http/HttpClientFactoryTest.php b/tests/Http/HttpClientFactoryTest.php index 73a500f..8bc46d7 100644 --- a/tests/Http/HttpClientFactoryTest.php +++ b/tests/Http/HttpClientFactoryTest.php @@ -14,8 +14,12 @@ namespace PackApi\Tests\Http; use PackApi\Http\HttpClientFactory; +use PackApi\Http\Middleware\LoggingMiddleware; +use Psr\Log\LoggerInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\CachingHttpClient; +use Symfony\Contracts\Cache\TagAwareCacheInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; #[CoversClass(HttpClientFactory::class)] @@ -48,4 +52,18 @@ public function testCreateClientWithQuic(): void $this->assertArrayHasKey('http_version', $options); $this->assertSame('3', $options['http_version']); } + + public function testCreateClientAddsCachingDecorator(): void + { + $factory = new HttpClientFactory(cacheStore: $this->createStub(TagAwareCacheInterface::class)); + + $this->assertInstanceOf(CachingHttpClient::class, $factory->createClient()); + } + + public function testCreateClientAddsLoggingDecorator(): void + { + $factory = new HttpClientFactory(logger: $this->createStub(LoggerInterface::class)); + + $this->assertInstanceOf(LoggingMiddleware::class, $factory->createClient()); + } } diff --git a/tests/Http/LoggingMiddlewareTest.php b/tests/Http/LoggingMiddlewareTest.php index 2a49074..7f8d65b 100644 --- a/tests/Http/LoggingMiddlewareTest.php +++ b/tests/Http/LoggingMiddlewareTest.php @@ -62,8 +62,8 @@ public function withOptions(array $options): static public function testRequestLogsMethodAndUrl(): void { - $response = $this->createMock(ResponseInterface::class); - $stream = $this->createMock(ResponseStreamInterface::class); + $response = $this->createStub(ResponseInterface::class); + $stream = $this->createStub(ResponseStreamInterface::class); $client = $this->getStubClient($response, $stream); $logger = $this->createMock(LoggerInterface::class); @@ -82,10 +82,10 @@ public function testRequestLogsMethodAndUrl(): void public function testDelegatesRequestStreamAndWithOptions(): void { - $response = $this->createMock(ResponseInterface::class); - $stream = $this->createMock(ResponseStreamInterface::class); + $response = $this->createStub(ResponseInterface::class); + $stream = $this->createStub(ResponseStreamInterface::class); $client = $this->getStubClient($response, $stream); - $logger = $this->createMock(LoggerInterface::class); + $logger = $this->createStub(LoggerInterface::class); $middleware = new LoggingMiddleware($client, $logger); diff --git a/tests/Http/Middleware/LoggingMiddlewareTest.php b/tests/Http/Middleware/LoggingMiddlewareTest.php index 7229e82..7404b82 100644 --- a/tests/Http/Middleware/LoggingMiddlewareTest.php +++ b/tests/Http/Middleware/LoggingMiddlewareTest.php @@ -25,12 +25,12 @@ final class LoggingMiddlewareTest extends TestCase public function testRequestHandlesExceptionAndStillLogsA(): void { $calls = []; - $logger = $this->createMock(LoggerInterface::class); + $logger = $this->createStub(LoggerInterface::class); $logger->method('info')->willReturnCallback(function ($message, $context) use (&$calls) { $calls[] = [$message, $context]; }); - $client = $this->createMock(HttpClientInterface::class); + $client = $this->createStub(HttpClientInterface::class); $client->method('request')->will($this->throwException(new \Exception('fail'))); $middleware = new LoggingMiddleware($client, $logger); diff --git a/tests/Inspector/DownloadStatsInspectorTest.php b/tests/Inspector/DownloadStatsInspectorTest.php index 6b0e740..dd00d36 100644 --- a/tests/Inspector/DownloadStatsInspectorTest.php +++ b/tests/Inspector/DownloadStatsInspectorTest.php @@ -33,13 +33,13 @@ public function testGetStatsReturnsNullWhenNoProviderSupports(): void public function testGetStatsDelegatesToFirstSupportingProvider(): void { - $package = $this->createMock(Package::class); + $package = $this->createStub(Package::class); // Corrected DownloadStats instantiation with correct DownloadPeriod arguments $expected = new DownloadStats(['daily' => new DownloadPeriod('daily', 10, new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-01-01'))]); $provider = $this->createMock(DownloadStatsProviderInterface::class); - $provider->method('supports')->with($package)->willReturn(true); - $provider->method('getStats')->with($package)->willReturn($expected); + $provider->expects($this->once())->method('supports')->with($package)->willReturn(true); + $provider->expects($this->once())->method('getStats')->with($package)->willReturn($expected); $inspector = new DownloadStatsInspector([$provider]); $this->assertSame($expected, $inspector->getStats($package)); @@ -47,16 +47,24 @@ public function testGetStatsDelegatesToFirstSupportingProvider(): void public function testGetStatsForPeriodDelegatesToFirstSupportingProvider(): void { - $package = $this->createMock(Package::class); + $package = $this->createStub(Package::class); // Corrected DownloadPeriod instantiation with correct arguments $period = new DownloadPeriod('daily', 10, new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-01-05')); $expected = new DownloadStats(['daily' => $period]); $provider = $this->createMock(DownloadStatsProviderInterface::class); - $provider->method('supports')->with($package)->willReturn(true); - $provider->method('getStatsForPeriod')->with($package, $period)->willReturn($expected); + $provider->expects($this->once())->method('supports')->with($package)->willReturn(true); + $provider->expects($this->once())->method('getStatsForPeriod')->with($package, $period)->willReturn($expected); $inspector = new DownloadStatsInspector([$provider]); $this->assertSame($expected, $inspector->getStatsForPeriod($package, $period)); } + + public function testGetStatsForPeriodReturnsNullWhenNoProviderSupports(): void + { + $package = $this->createStub(Package::class); + $period = new DownloadPeriod('daily', 0, new \DateTimeImmutable('2023-01-01'), new \DateTimeImmutable('2023-01-02')); + + $this->assertNull((new DownloadStatsInspector([]))->getStatsForPeriod($package, $period)); + } } diff --git a/tests/Inspector/MetadataInspectorTest.php b/tests/Inspector/MetadataInspectorTest.php index dc23c5f..412542d 100644 --- a/tests/Inspector/MetadataInspectorTest.php +++ b/tests/Inspector/MetadataInspectorTest.php @@ -26,7 +26,7 @@ final class MetadataInspectorTest extends TestCase public function testGetMetadataReturnsNullWhenNoProviderSupports(): void { $package = $this->createStub(Package::class); - $provider = $this->createMock(MetadataProviderInterface::class); + $provider = $this->createStub(MetadataProviderInterface::class); $provider->method('supports')->willReturn(false); $inspector = new MetadataInspector([$provider]); @@ -39,7 +39,7 @@ public function testGetMetadataReturnsMetadataWhenProviderSupports(): void $package = $this->createStub(Package::class); $expectedMetadata = new Metadata('test', 'test', 'test', 'test'); - $provider = $this->createMock(MetadataProviderInterface::class); + $provider = $this->createStub(MetadataProviderInterface::class); $provider->method('supports')->willReturn(true); $provider->method('getMetadata')->willReturn($expectedMetadata); @@ -52,7 +52,7 @@ public function testGetMetadataReturnsNullWhenProviderSupportsButReturnsNull(): { $package = $this->createStub(Package::class); - $provider = $this->createMock(MetadataProviderInterface::class); + $provider = $this->createStub(MetadataProviderInterface::class); $provider->method('supports')->willReturn(true); $provider->method('getMetadata')->willReturn(null); diff --git a/tests/Inspector/QualityInspectorTest.php b/tests/Inspector/QualityInspectorTest.php index 0ed831b..dffb447 100644 --- a/tests/Inspector/QualityInspectorTest.php +++ b/tests/Inspector/QualityInspectorTest.php @@ -92,7 +92,7 @@ public function testCalculatesScoreWithoutBestPractices(): void $score = $inspector->getQualityScore($package); $this->assertNotNull($score); - $this->assertSame(63, $score->score); + $this->assertSame(73, $score->score); $this->assertSame('C', $score->grade); $this->assertSame('Average quality, some best practices missing.', $score->comment); $this->assertSame([ @@ -126,9 +126,9 @@ public function testCalculatesScoreWithBestPracticeProvider(): void $score = $inspector->getQualityScore($package); $this->assertNotNull($score); - $this->assertSame(85, $score->score); - $this->assertSame('B', $score->grade); - $this->assertSame('Good, but could be improved (see best practices).', $score->comment); + $this->assertSame(100, $score->score); + $this->assertSame('A', $score->grade); + $this->assertSame('Excellent package hygiene and best practices.', $score->comment); $this->assertSame([ 'hasReadme' => true, 'hasLicense' => true, @@ -140,4 +140,54 @@ public function testCalculatesScoreWithBestPracticeProvider(): void 'hasGitignore' => true, ], $score->criteria); } + + public function testAssignsBGrade(): void + { + $score = $this->calculateScore( + new ContentOverview(3, 100, true, true, true), + new Metadata('pkg', 'Description', 'MIT', 'https://example.com/repo'), + ); + + $this->assertSame(75, $score->score); + $this->assertSame('B', $score->grade); + $this->assertSame('Good, but could be improved (see best practices).', $score->comment); + } + + public function testAssignsDGrade(): void + { + $score = $this->calculateScore( + new ContentOverview(3, 100, true, true, true), + new Metadata('pkg'), + ); + + $this->assertSame(45, $score->score); + $this->assertSame('D', $score->grade); + $this->assertSame('Below average, missing key best practices.', $score->comment); + } + + public function testAssignsFGrade(): void + { + $score = $this->calculateScore( + new ContentOverview(0, 0), + new Metadata('pkg'), + ); + + $this->assertSame(0, $score->score); + $this->assertSame('F', $score->grade); + $this->assertSame('Poor quality, needs significant improvement.', $score->comment); + } + + private function calculateScore(ContentOverview $content, Metadata $metadata): \PackApi\Model\QualityScore + { + $package = $this->createStub(Package::class); + $contentInspector = $this->createStub(ContentInspectorInterface::class); + $contentInspector->method('getContentOverview')->willReturn($content); + $metadataInspector = $this->createStub(MetadataInspectorInterface::class); + $metadataInspector->method('getMetadata')->willReturn($metadata); + + $score = (new QualityInspector($contentInspector, $metadataInspector))->getQualityScore($package); + $this->assertNotNull($score); + + return $score; + } } diff --git a/tests/System/Composer/ComposerDownloadStatsProviderTest.php b/tests/System/Composer/ComposerDownloadStatsProviderTest.php index 8ed4a8f..62275d6 100644 --- a/tests/System/Composer/ComposerDownloadStatsProviderTest.php +++ b/tests/System/Composer/ComposerDownloadStatsProviderTest.php @@ -16,16 +16,27 @@ use PackApi\Bridge\Packagist\PackagistApiClient; use PackApi\Model\DownloadPeriod; use PackApi\Package\ComposerPackage; +use PackApi\Package\NpmPackage; use PackApi\System\Composer\ComposerDownloadStatsProvider; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; class ComposerDownloadStatsProviderTest extends TestCase { + public function testSupportsOnlyComposerPackages(): void + { + $provider = new ComposerDownloadStatsProvider(new PackagistApiClient(new MockHttpClient())); + + $this->assertTrue($provider->supports(new ComposerPackage('foo/bar'))); + $this->assertFalse($provider->supports(new NpmPackage('foo'))); + } + public function testGetStatsReturnsPeriods(): void { - $response = $this->createMock(ResponseInterface::class); + $response = $this->createStub(ResponseInterface::class); $response->method('getContent')->willReturn(json_encode([ 'package' => [ 'downloads' => [ @@ -36,7 +47,7 @@ public function testGetStatsReturnsPeriods(): void ], ])); - $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient = $this->createStub(HttpClientInterface::class); $httpClient->method('request')->willReturn($response); $client = new PackagistApiClient($httpClient); @@ -55,10 +66,10 @@ public function testGetStatsReturnsPeriods(): void public function testGetStatsReturnsNullIfNoDownloads(): void { - $response = $this->createMock(ResponseInterface::class); + $response = $this->createStub(ResponseInterface::class); $response->method('getContent')->willReturn(json_encode(['package' => []])); - $httpClient = $this->createMock(HttpClientInterface::class); + $httpClient = $this->createStub(HttpClientInterface::class); $httpClient->method('request')->willReturn($response); $client = new PackagistApiClient($httpClient); @@ -68,9 +79,65 @@ public function testGetStatsReturnsNullIfNoDownloads(): void $this->assertNull($provider->getStats($package)); } + public function testGetStatsReturnsNullWithoutSupportedPeriods(): void + { + $client = new PackagistApiClient(new MockHttpClient([ + new MockResponse('{"package":{"downloads":{"weekly":10}}}'), + ])); + $provider = new ComposerDownloadStatsProvider($client); + + $this->assertNull($provider->getStats(new ComposerPackage('foo/bar'))); + } + + public function testGetStatsForPeriodAggregatesDailyDownloads(): void + { + $client = new PackagistApiClient(new MockHttpClient([ + new MockResponse('{"downloads":[{"date":"2026-08-01","download":4},{"date":"2026-08-02","download":6}]}'), + ])); + $provider = new ComposerDownloadStatsProvider($client); + $period = new DownloadPeriod('custom', 0, new \DateTimeImmutable('2026-08-01'), new \DateTimeImmutable('2026-08-02')); + + $stats = $provider->getStatsForPeriod(new ComposerPackage('foo/bar'), $period); + + $this->assertSame(10, $stats?->get('custom')?->getCount()); + $this->assertSame($period->getStart(), $stats?->get('custom')?->getStart()); + $this->assertSame($period->getEnd(), $stats?->get('custom')?->getEnd()); + } + + public function testGetStatsForPeriodReturnsNullWithoutDailyDownloads(): void + { + $client = new PackagistApiClient(new MockHttpClient([ + new MockResponse('{"downloads":"unavailable"}'), + ])); + $provider = new ComposerDownloadStatsProvider($client); + $period = new DownloadPeriod('custom', 0, new \DateTimeImmutable('2026-08-01'), new \DateTimeImmutable('2026-08-02')); + + $this->assertNull($provider->getStatsForPeriod(new ComposerPackage('foo/bar'), $period)); + } + + public function testGetAvailablePeriodsReturnsDownloadKeys(): void + { + $client = new PackagistApiClient(new MockHttpClient([ + new MockResponse('{"package":{"downloads":{"total":100,"monthly":10}}}'), + ])); + $provider = new ComposerDownloadStatsProvider($client); + + $this->assertSame(['total', 'monthly'], $provider->getAvailablePeriods(new ComposerPackage('foo/bar'))); + } + + public function testGetAvailablePeriodsReturnsEmptyArrayWithoutDownloads(): void + { + $client = new PackagistApiClient(new MockHttpClient([ + new MockResponse('{"package":{}}'), + ])); + $provider = new ComposerDownloadStatsProvider($client); + + $this->assertSame([], $provider->getAvailablePeriods(new ComposerPackage('foo/bar'))); + } + public function testHasCdnStats(): void { - $client = new PackagistApiClient($this->createMock(HttpClientInterface::class)); + $client = new PackagistApiClient($this->createStub(HttpClientInterface::class)); $provider = new ComposerDownloadStatsProvider($client); $package = new ComposerPackage('foo/bar'); diff --git a/tests/System/Composer/ComposerMetadataProviderTest.php b/tests/System/Composer/ComposerMetadataProviderTest.php index 97d6b57..7c5e613 100644 --- a/tests/System/Composer/ComposerMetadataProviderTest.php +++ b/tests/System/Composer/ComposerMetadataProviderTest.php @@ -32,6 +32,13 @@ final class ComposerMetadataProviderTest extends TestCase private ComposerMetadataProvider $provider; protected function setUp(): void + { + $this->httpClient = $this->createStub(HttpClientInterface::class); + $this->apiClient = new PackagistApiClient($this->httpClient); + $this->provider = new ComposerMetadataProvider($this->apiClient); + } + + private function useMockHttpClient(): void { $this->httpClient = $this->createMock(HttpClientInterface::class); $this->apiClient = new PackagistApiClient($this->httpClient); @@ -55,9 +62,10 @@ public function testSupportsReturnsFalseForNonComposerPackage(): void public function testGetMetadataReturnsNullWhenApiReturnsEmpty(): void { $package = new ComposerPackage('vendor/package'); + $this->useMockHttpClient(); - $response = $this->createMock(ResponseInterface::class); - $response->method('getContent')->with(false)->willReturn(json_encode([])); + $response = $this->createStub(ResponseInterface::class); + $response->method('getContent')->willReturn(json_encode([])); $this->httpClient ->expects($this->once()) @@ -71,6 +79,7 @@ public function testGetMetadataReturnsNullWhenApiReturnsEmpty(): void public function testGetMetadataReturnsMetadata(): void { $package = new ComposerPackage('vendor/package'); + $this->useMockHttpClient(); $apiData = [ 'package' => [ 'name' => 'vendor/package', @@ -80,8 +89,8 @@ public function testGetMetadataReturnsMetadata(): void ], ]; - $response = $this->createMock(ResponseInterface::class); - $response->method('getContent')->with(false)->willReturn(json_encode($apiData)); + $response = $this->createStub(ResponseInterface::class); + $response->method('getContent')->willReturn(json_encode($apiData)); $this->httpClient ->expects($this->once()) @@ -97,4 +106,24 @@ public function testGetMetadataReturnsMetadata(): void $this->assertSame('MIT', $metadata->license); $this->assertSame('https://example.com/vendor/package', $metadata->repository); } + + public function testGetMetadataAcceptsStringLicense(): void + { + $package = new ComposerPackage('vendor/package'); + $this->useMockHttpClient(); + $response = $this->createStub(ResponseInterface::class); + $response->method('getContent')->willReturn(json_encode([ + 'package' => [ + 'name' => 'vendor/package', + 'license' => 'MIT', + ], + ])); + + $this->httpClient + ->expects($this->once()) + ->method('request') + ->willReturn($response); + + $this->assertSame('MIT', $this->provider->getMetadata($package)?->getLicense()); + } } diff --git a/tests/System/Npm/NpmContentProviderTest.php b/tests/System/Npm/NpmContentProviderTest.php index e31abb0..1b5ee6d 100644 --- a/tests/System/Npm/NpmContentProviderTest.php +++ b/tests/System/Npm/NpmContentProviderTest.php @@ -29,6 +29,12 @@ final class NpmContentProviderTest extends TestCase private NpmContentProvider $provider; protected function setUp(): void + { + $this->npmApiClient = $this->createStub(NpmApiClient::class); + $this->provider = new NpmContentProvider($this->npmApiClient); + } + + private function useMockApiClient(): void { $this->npmApiClient = $this->createMock(NpmApiClient::class); $this->provider = new NpmContentProvider($this->npmApiClient); @@ -51,6 +57,7 @@ public function testSupportsReturnsFalseForNonNpmPackage(): void public function testGetContentOverviewReturnsNullWhenApiClientReturnsNull(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $this->npmApiClient ->expects($this->once()) @@ -66,6 +73,7 @@ public function testGetContentOverviewReturnsNullWhenApiClientReturnsNull(): voi public function testGetContentOverviewReturnsContentOverviewWithBasicInfo(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $apiData = [ 'name' => 'test-package', 'dist' => [ @@ -93,6 +101,7 @@ public function testGetContentOverviewReturnsContentOverviewWithBasicInfo(): voi public function testGetContentOverviewDetectsReadme(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $apiData = [ 'name' => 'test-package', 'readme' => '# Test Package\n\nThis is a test package.', @@ -112,6 +121,7 @@ public function testGetContentOverviewDetectsReadme(): void public function testGetContentOverviewDetectsLicense(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $apiData = [ 'name' => 'test-package', 'license' => 'MIT', @@ -131,6 +141,7 @@ public function testGetContentOverviewDetectsLicense(): void public function testGetContentOverviewDetectsTestsFromScripts(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $apiData = [ 'name' => 'test-package', 'scripts' => [ @@ -153,6 +164,7 @@ public function testGetContentOverviewDetectsTestsFromScripts(): void public function testGetContentOverviewDetectsTestsFromDevDependencies(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $apiData = [ 'name' => 'test-package', 'devDependencies' => [ @@ -204,6 +216,7 @@ public function testGetContentOverviewDetectsVariousTestFrameworks(): void public function testGetContentOverviewHandlesEmptyData(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $apiData = []; $this->npmApiClient diff --git a/tests/System/Npm/NpmDownloadStatsProviderTest.php b/tests/System/Npm/NpmDownloadStatsProviderTest.php index 597c25d..627fcaa 100644 --- a/tests/System/Npm/NpmDownloadStatsProviderTest.php +++ b/tests/System/Npm/NpmDownloadStatsProviderTest.php @@ -18,6 +18,7 @@ use PackApi\Model\DownloadPeriod; use PackApi\Model\DownloadStats; use PackApi\Package\NpmPackage; +use PackApi\Package\ComposerPackage; use PackApi\System\Npm\NpmDownloadStatsProvider; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -27,6 +28,14 @@ #[CoversClass(NpmDownloadStatsProvider::class)] final class NpmDownloadStatsProviderTest extends TestCase { + public function testSupportsOnlyNpmPackages(): void + { + $provider = new NpmDownloadStatsProvider(new NpmApiClient(new MockHttpClient(), new MockHttpClient())); + + $this->assertTrue($provider->supports(new NpmPackage('package'))); + $this->assertFalse($provider->supports(new ComposerPackage('vendor/package'))); + } + public function testGetStatsParsesMonthlyDownloads(): void { $statsClient = new MockHttpClient([ @@ -88,4 +97,13 @@ public function testGetStatsForSpecificPeriod(): void $this->assertSame(21, $downloadPeriod->getCount()); $this->assertSame('weekly', $downloadPeriod->getType()); } + + public function testCapabilities(): void + { + $provider = new NpmDownloadStatsProvider(new NpmApiClient(new MockHttpClient(), new MockHttpClient())); + $package = new NpmPackage('package'); + + $this->assertSame(['total', 'monthly', 'weekly', 'daily'], $provider->getAvailablePeriods($package)); + $this->assertFalse($provider->hasCdnStats($package)); + } } diff --git a/tests/System/Npm/NpmMetadataProviderTest.php b/tests/System/Npm/NpmMetadataProviderTest.php index 3ec9d5a..ebb287f 100644 --- a/tests/System/Npm/NpmMetadataProviderTest.php +++ b/tests/System/Npm/NpmMetadataProviderTest.php @@ -29,6 +29,12 @@ final class NpmMetadataProviderTest extends TestCase private NpmMetadataProvider $provider; protected function setUp(): void + { + $this->npmApiClient = $this->createStub(NpmApiClient::class); + $this->provider = new NpmMetadataProvider($this->npmApiClient); + } + + private function useMockApiClient(): void { $this->npmApiClient = $this->createMock(NpmApiClient::class); $this->provider = new NpmMetadataProvider($this->npmApiClient); @@ -51,6 +57,7 @@ public function testSupportsReturnsFalseForNonNpmPackage(): void public function testGetMetadataReturnsNullWhenApiClientReturnsNull(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $this->npmApiClient ->expects($this->once()) @@ -66,6 +73,7 @@ public function testGetMetadataReturnsNullWhenApiClientReturnsNull(): void public function testGetMetadataReturnsMetadataWithAllFields(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $apiData = [ 'name' => 'test-package', 'description' => 'A test package', @@ -93,6 +101,7 @@ public function testGetMetadataReturnsMetadataWithAllFields(): void public function testGetMetadataHandlesMissingFields(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $apiData = [ 'name' => 'test-package', ]; @@ -115,6 +124,7 @@ public function testGetMetadataHandlesMissingFields(): void public function testGetMetadataHandlesStringRepository(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $apiData = [ 'name' => 'test-package', 'repository' => 'https://github.com/user/repo.git', @@ -134,6 +144,7 @@ public function testGetMetadataHandlesStringRepository(): void public function testGetMetadataHandlesObjectLicense(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $apiData = [ 'name' => 'test-package', 'license' => [ @@ -155,6 +166,7 @@ public function testGetMetadataHandlesObjectLicense(): void public function testGetMetadataFallsBackToPackageNameWhenNameMissing(): void { $package = new NpmPackage('test-package'); + $this->useMockApiClient(); $apiData = []; $this->npmApiClient