diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6376ac0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,33 @@ +# Normalize line endings +* text=auto + +# PHP files +*.php text eol=lf diff=php + +# Config files +*.json text eol=lf +*.md text eol=lf +*.neon text eol=lf +*.xml text eol=lf +*.yaml text eol=lf +*.yml text eol=lf + +# Exclude from releases +.editorconfig export-ignore +.gitattributes export-ignore +.github/ export-ignore +.gitignore export-ignore +.markdownlint.json export-ignore +.php-cs-fixer.php export-ignore +.phpstan/ export-ignore +.phpunit.* export-ignore +codecov.yml export-ignore +coverage/ export-ignore +coverage.clover export-ignore +coverage.xml export-ignore +DEVELOPMENT.md export-ignore +phpstan/ export-ignore +phpunit.xml* export-ignore +tests/ export-ignore +var/ export-ignore +vendor/ export-ignore \ No newline at end of file diff --git a/.github/phpstan/generic-baseline.neon b/.github/phpstan/generic-baseline.neon deleted file mode 100644 index 2160f4e..0000000 --- a/.github/phpstan/generic-baseline.neon +++ /dev/null @@ -1,5 +0,0 @@ -parameters: - ignoreErrors: - - - identifier: missingType.generics - path: ../../src/DependencyInjection/Configuration.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52bb255..e5f83f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: branches: - 'main' - 'legacy/*' # Legacy branches: legacy/v3.x - - 'feature/*' # Feature branches: feature/new-feature + - 'feature/*' # Feature branches: feature/new-feature - 'hotfix/*' # Hotfix branches: hotfix/urgent-fix - 'release/*' # Release branches: release/v4.0.0 pull_request: @@ -22,81 +22,58 @@ jobs: fail-fast: false matrix: include: - # Core PHP version testing - - php: '8.1' - allowed-to-fail: false - - php: '8.2' - allowed-to-fail: false - - php: '8.3' - allowed-to-fail: false - - php: '8.4' - allowed-to-fail: false - # Symfony 6.4 LTS compatibility - php: '8.2' - symfony-version: '^6.4' + symfony-version: '6.4.*' allowed-to-fail: false - php: '8.3' - symfony-version: '^6.4' + symfony-version: '6.4.*' allowed-to-fail: false - php: '8.4' - symfony-version: '^6.4' + symfony-version: '6.4.*' allowed-to-fail: false - + # Symfony 7.x current versions - php: '8.2' - symfony-version: '^7.0' + symfony-version: '7.0.*' allowed-to-fail: false - php: '8.3' - symfony-version: '^7.0' + symfony-version: '7.0.*' allowed-to-fail: false - php: '8.4' - symfony-version: '^7.0' + symfony-version: '7.0.*' allowed-to-fail: false - php: '8.3' - symfony-version: '^7.1' + symfony-version: '7.1.*' allowed-to-fail: false - php: '8.4' - symfony-version: '^7.1' + symfony-version: '7.1.*' allowed-to-fail: false - php: '8.4' - symfony-version: '^7.2' + symfony-version: '7.2.*' allowed-to-fail: false - php: '8.4' - symfony-version: '^7.3' + symfony-version: '7.3.*' allowed-to-fail: false - php: '8.4' - symfony-version: '^7.4' - stability: 'dev' - allowed-to-fail: true + symfony-version: '7.4.*' + allowed-to-fail: false - # Future-ready: Symfony 8.0 (beta/dev) - Requires PHP 8.4+ + # Symfony 8.0 - Requires PHP 8.4+ - php: '8.4' - symfony-version: '^8.0' - stability: 'dev' - allowed-to-fail: true + symfony-version: '8.0.*' + allowed-to-fail: false - # Future-ready: PHP 8.5 (alpha/dev) - when available - - php: '8.5' - symfony-version: '^7.3' - stability: 'dev' - allowed-to-fail: true + # PHP 8.5 with various Symfony versions - php: '8.5' - symfony-version: '^7.4' - stability: 'dev' - allowed-to-fail: true + symfony-version: '7.3.*' + allowed-to-fail: false - php: '8.5' - symfony-version: '^8.0' - stability: 'dev' - allowed-to-fail: true - - # Development stability tests - - php: '8.4' - stability: 'dev' - allowed-to-fail: true + symfony-version: '7.4.*' + allowed-to-fail: false - php: '8.5' - stability: 'dev' - allowed-to-fail: true + symfony-version: '8.0.*' + allowed-to-fail: false name: "PHP ${{ matrix.php }}${{ matrix.symfony-version && format(' | Symfony {0}', matrix.symfony-version) || '' }}${{ matrix.stability && format(' | {0}', matrix.stability) || '' }}" @@ -148,21 +125,38 @@ jobs: run: composer update --prefer-dist --no-interaction --no-progress - name: Validate composer.json and composer.lock - run: composer validate --strict + run: | + if [[ "${{ github.ref }}" == *"beta"* || "${{ github.ref }}" == *"alpha"* || "${{ github.ref }}" == *"rc"* ]]; then + composer validate --no-check-version + else + composer validate --strict + fi - name: Run code style check run: composer cs - name: Run static analysis run: | - if [[ "${{ matrix.symfony-version }}" == "^7.4" || "${{ matrix.symfony-version }}" == "^8.0" ]]; then - composer analyse-generics + if [[ "${{ matrix.symfony-version }}" == "8.0.*" ]]; then + # Symfony 8.0: Use specific baseline for remaining issues + vendor/bin/phpstan analyse src --level=8 --configuration phpstan/symfony8-baseline.neon + elif [[ "${{ matrix.symfony-version }}" == "7.4.*" ]]; then + # Symfony 7.4 LTS: Functionally identical to 8.0, needs same baseline + vendor/bin/phpstan analyse src --level=8 --configuration phpstan/symfony74-baseline.neon + elif [[ "${{ matrix.symfony-version }}" == "6.4.*" || "${{ matrix.symfony-version }}" == "7.0.*" || "${{ matrix.symfony-version }}" == "7.1.*" || "${{ matrix.symfony-version }}" == "7.2.*" || "${{ matrix.symfony-version }}" == "7.3.*" ]]; then + # Older Symfony versions 6.4-7.3: Use legacy baseline for children() issue + composer analyse-legacy else + # Future versions: Try without baseline first composer analyse fi - name: Run tests - run: ./vendor/bin/simple-phpunit -v + run: composer test + env: + DISCOGS_CONSUMER_KEY: ${{ secrets.DISCOGS_CONSUMER_KEY }} + DISCOGS_CONSUMER_SECRET: ${{ secrets.DISCOGS_CONSUMER_SECRET }} + DISCOGS_PERSONAL_ACCESS_TOKEN: ${{ secrets.DISCOGS_PERSONAL_ACCESS_TOKEN }} code-quality: runs-on: ubuntu-latest @@ -195,7 +189,12 @@ jobs: run: composer install --prefer-dist --no-interaction --no-progress - name: Validate composer.json and composer.lock - run: composer validate --strict + run: | + if [[ "${{ github.ref }}" == *"beta"* || "${{ github.ref }}" == *"alpha"* || "${{ github.ref }}" == *"rc"* ]]; then + composer validate --no-check-version + else + composer validate --strict + fi coverage: runs-on: ubuntu-latest @@ -228,7 +227,11 @@ jobs: run: composer install --prefer-dist --no-interaction --no-progress - name: Run tests with coverage - run: ./vendor/bin/simple-phpunit --coverage-clover coverage.xml + run: composer test-coverage + env: + DISCOGS_CONSUMER_KEY: ${{ secrets.DISCOGS_CONSUMER_KEY }} + DISCOGS_CONSUMER_SECRET: ${{ secrets.DISCOGS_CONSUMER_SECRET }} + DISCOGS_PERSONAL_ACCESS_TOKEN: ${{ secrets.DISCOGS_PERSONAL_ACCESS_TOKEN }} - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b997a6..344e5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,53 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.0.0](https://github.com/calliostro/discogs-bundle/releases/tag/v4.0.0) โ€“ 2025-12-01 + +### ๐Ÿš€ Complete Rewrite โ€” Fresh Start + +This version represents a complete architectural rewrite. v4.0.0 is essentially a new bundle that happens to have the same name. + +### Added + +- **Personal Access Token Support** for simple authentication +- **All 60 Discogs API Methods** with consistent verb-first naming and modern parameter style +- **Zero Configuration Mode** for public API access +- **Named Parameter Support** โ€“ Methods accept individual parameters in camelCase instead of arrays +- **Built-in OAuth 1.0a** with no external dependencies +- **Symfony Rate Limiter Integration** โ€“ Optional advanced rate limiting with configurable policies (sliding_window, fixed_window, token_bucket) +- **Symfony 6.4 | 7.x | 8.x Support** with future compatibility +- **Modern PHP 8.1+ Architecture** with full type safety and modern features +- **Comprehensive Test Suite** with unit and integration tests +- **Professional Documentation** with clear examples and setup guides +- **Modern Bundle Structure** following all Symfony best practices +- **Robust Configuration Validation** with meaningful error messages +- **Modern Music References** throughout documentation and examples + +### Changed + +- **Configuration Structure** simplified and more intuitive +- **Method Parameter Style** โ€“ All methods now accept individual parameters (e.g., `getArtist(artistId: 123)`) instead of arrays (`getArtist(['id' => '123'])`) +- **Method Names** use consistent verb-first patterns (e.g., `listArtistReleases()`) +- **Parameter Naming** โ€“ All parameters use camelCase convention (e.g., `perPage` instead of `per_page`) +- **Rate Limiting** โ€“ Replaced simple throttle system with Symfony Rate Limiter component integration +- **Service Naming** follows modern Symfony conventions with proper aliases +- **Error Handling** improved with better exceptions and validation +- **Performance** optimized for modern PHP versions +- **Complete API Integration** now based on `calliostro/php-discogs-api` +- **Code Standards** fully compliant with @Symfony and @Symfony:risky rules + +### Removed + +- **Complex Configuration** โ€“ Simplified to essential options only +- **Array Parameter Style** โ€“ Methods no longer accept parameter arrays +- **Throttle Configuration** โ€“ Legacy `throttle` config replaced with modern `rate_limiter` integration +- **Legacy Dependencies** โ€“ No more Guzzle Services or external OAuth libraries +- **Backward Compatibility** โ€“ This is a fresh start, not an upgrade + +--- + +## Historical Releases (Pre-v4.0) + ## [3.1.4](https://github.com/calliostro/discogs-bundle/releases/tag/v3.1.4) โ€“ 2025-09-11 ### Added @@ -19,7 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Improved exception documentation specificity (XML loading exceptions vs generic exceptions) +- Improved exception documentation specificity (XML loading exceptions vs. generic exceptions) - Enhanced test coverage from 96.9% to 100% with comprehensive OAuth token scenarios --- @@ -55,8 +102,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- PHP 8.5 beta compatibility โ€“ Early support for the upcoming PHP 8.5 release -- Symfony 8.0 beta testing โ€“ Ready for Symfony 8.0 when it arrives +- PHP 8.5 compatibility โ€“ Early support for the upcoming PHP 8.5 release +- Symfony 8.0 testing โ€“ Ready for Symfony 8.0 when it arrives - Enhanced stability โ€“ Improved build reliability and faster dependency resolution ### Changed diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..434c18d --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,154 @@ +# Development Guide + +This guide is for contributors and developers working on the discogs-bundle itself. + +## ๐Ÿงช Testing + +### Quick Commands + +```bash +# Unit tests (fast, CI-compatible, no external dependencies) +composer test + +# Integration tests (requires Discogs API credentials) +composer test-integration + +# All tests together (unit + integration) +composer test-all + +# Code coverage (HTML + XML reports) +composer test-coverage +``` + +### Static Analysis & Code Quality + +```bash +# Static analysis (PHPStan Level 8) - Default for Symfony 7.4 / 8.0 +composer analyse + +# Static analysis with legacy baseline (required for Symfony 6.4 - 7.3) +composer analyse-legacy + +# Code style check (Symfony standards) +composer cs + +# Auto-fix code style +composer cs-fix +``` + +## ๐Ÿ”— Integration Tests + +Integration tests are **separated from the CI pipeline** to prevent: + +- ๐Ÿšซ Rate limiting (429 Too Many Requests) +- ๐Ÿšซ Flaky builds due to network issues +- ๐Ÿšซ Dependency on external API availability +- ๐Ÿšซ Slow build times (2+ minutes vs. 0.4 seconds) + +### Test Strategy + +- **Unit Tests**: Fast, reliable, no external dependencies โ†’ **CI default** +- **Integration Tests**: Real API calls, rate-limited โ†’ **Manual execution** +- **Bundle Focus**: Test Symfony integration, service wiring, configuration + +### Test Levels + +#### 1. Public API Tests (Always Run) + +- File: `tests/Integration/PublicApiIntegrationTest.php` +- No credentials required +- Tests public endpoints through Bundle: artists, releases, labels, masters +- Safe for forks and pull requests + +#### 2. Authentication Levels Test (Conditional) + +- File: `tests/Integration/AuthenticatedApiIntegrationTest.php` +- Requires environment variables below +- Tests Bundle authentication configuration: + - Level 2: Consumer credentials (search) + - Level 3: Personal token (user data) + - Level 4: OAuth tokens (direct library usage) + +### GitHub Secrets Required + +To enable authenticated integration tests in CI/CD, add these secrets to your GitHub repository: + +#### Repository Settings โ†’ Secrets and variables โ†’ Actions + +| Secret Name | Description | Where to get it | +|---------------------------------|----------------------------------|---------------------------------------------------------------------------| +| `DISCOGS_CONSUMER_KEY` | Your Discogs app consumer key | [Discogs Developer Settings](https://www.discogs.com/settings/developers) | +| `DISCOGS_CONSUMER_SECRET` | Your Discogs app consumer secret | [Discogs Developer Settings](https://www.discogs.com/settings/developers) | +| `DISCOGS_PERSONAL_ACCESS_TOKEN` | Your personal access token | [Discogs Developer Settings](https://www.discogs.com/settings/developers) | + +### Local Development + +```bash +# Set environment variables +export DISCOGS_CONSUMER_KEY="your-consumer-key" +export DISCOGS_CONSUMER_SECRET="your-consumer-secret" +export DISCOGS_PERSONAL_ACCESS_TOKEN="your-personal-access-token" + +# Run public tests only +vendor/bin/phpunit tests/Integration/PublicApiIntegrationTest.php + +# Run authentication tests (requires env vars) +vendor/bin/phpunit tests/Integration/AuthenticatedApiIntegrationTest.php + +# Run all integration tests +vendor/bin/phpunit tests/Integration/ --testdox +``` + +### Safety Notes + +- Public tests are safe for any environment +- Authentication tests will be skipped if secrets are missing +- No credentials are logged or exposed in the test output +- Tests use read-only operations only (no data modification) + +## ๐Ÿ› ๏ธ Development Workflow + +1. Fork the repository +2. Create feature branch (`git checkout -b feature/name`) +3. Make changes with tests +4. Run test suite (`composer test-all`) +5. Check code quality (`composer analyse && composer cs`) +6. Commit changes (`git commit -m 'Add feature'`) +7. Push to branch (`git push origin feature/name`) +8. Open Pull Request + +## ๐Ÿ“‹ Code Standards + +- **PHP Version**: ^8.1 +- **Code Style**: Symfony Coding Standards (@Symfony + @Symfony:risky) +- **Static Analysis**: PHPStan Level 8 +- **Test Coverage**: Comprehensive unit and integration tests +- **Symfony Compatibility**: 6.4+ | 7.x | 8.x +- **Bundle Focus**: Minimal footprint, clean integration + +## ๐Ÿ—๏ธ Bundle Architecture + +The Symfony bundle provides: + +1. **Service Integration**: Seamless DiscogsClient autowiring +2. **Configuration Management**: YAML-based bundle configuration +3. **Authentication Setup**: Personal tokens, consumer credentials, OAuth +4. **Rate Limiting**: Optional throttling for API calls +5. **Symfony Integration**: Compatible with Symfony 6.4+ | 7.x | 8.x + +### Bundle-Specific Testing Focus + +Integration tests ensure: + +- **Bundle Integration**: Bundle correctly configures the Discogs API client +- **Symfony Integration**: Services are properly wired and injectable +- **Configuration**: Bundle configuration is correctly applied +- **Error Handling**: Bundle handles API errors gracefully +- **Rate Limiting**: Throttling works as configured + +## ๐Ÿ” Getting Credentials + +1. Go to [Discogs Developer Settings](https://www.discogs.com/settings/developers) +2. Create a new application +3. Note down Consumer Key and Consumer Secret +4. Generate a Personal Access Token diff --git a/README.md b/README.md index bf44f81..980cf66 100644 --- a/README.md +++ b/README.md @@ -1,157 +1,228 @@ -# ๐ŸŽต Discogs Bundle +# โšก Discogs Client Bundle for Symfony โ€“ Complete Music Database Access [![Package Version](https://img.shields.io/packagist/v/calliostro/discogs-bundle.svg)](https://packagist.org/packages/calliostro/discogs-bundle) [![Total Downloads](https://img.shields.io/packagist/dt/calliostro/discogs-bundle.svg)](https://packagist.org/packages/calliostro/discogs-bundle) [![License](https://poser.pugx.org/calliostro/discogs-bundle/license)](https://packagist.org/packages/calliostro/discogs-bundle) [![PHP Version](https://img.shields.io/badge/php-%5E8.1-blue.svg)](https://php.net) -[![CI](https://github.com/calliostro/discogs-bundle/actions/workflows/ci.yml/badge.svg?branch=legacy%2Fv3.x)](https://github.com/calliostro/discogs-bundle/actions/workflows/ci.yml) -[![Code Coverage](https://codecov.io/gh/calliostro/discogs-bundle/branch/legacy%2Fv3.x/graph/badge.svg?token=3ATEFYF7A0)](https://codecov.io/gh/calliostro/discogs-bundle) +[![CI](https://github.com/calliostro/discogs-bundle/actions/workflows/ci.yml/badge.svg)](https://github.com/calliostro/discogs-bundle/actions/workflows/ci.yml) +[![Code Coverage](https://codecov.io/gh/calliostro/discogs-bundle/graph/badge.svg?token=3ATEFYF7A0)](https://codecov.io/gh/calliostro/discogs-bundle) [![PHPStan Level](https://img.shields.io/badge/PHPStan-level%208-brightgreen.svg)](https://phpstan.org/) [![Code Style](https://img.shields.io/badge/code%20style-Symfony-brightgreen.svg)](https://github.com/FriendsOfPHP/PHP-CS-Fixer) -> ๐Ÿš€ **Seamless integration of [calliostro/php-discogs-api](https://github.com/calliostro/php-discogs-api) into Symfony 6.4, 7, and 8.** -> -> Use [v4.0.0](https://github.com/calliostro/discogs-bundle) for new projects with modern tooling and breaking changes. Legacy support for v3.x continues on the [`legacy/v3.x`](https://github.com/calliostro/discogs-bundle/tree/legacy/v3.x) branch. +> **๐Ÿš€ SYMFONY INTEGRATION!** Seamless autowiring for the complete Discogs music database API. Zero bloat, maximum performance. -## โšก Requirements - -- **PHP**: 8.1 or higher -- **Symfony**: 6.4, 7.x, or 8.x +Symfony bundle that integrates the **modern** [calliostro/php-discogs-api](https://github.com/calliostro/php-discogs-api) into your Symfony application. Built with modern PHP 8.1+ features, dependency injection, and powered by Guzzle. ## ๐Ÿ“ฆ Installation -### Symfony Flex (Recommended) +Install via Composer: -```console +```bash composer require calliostro/discogs-bundle ``` -### Without Symfony Flex +## โš™๏ธ Configuration -1๏ธโƒฃ **Install the Bundle** +Configure the bundle in `config/packages/calliostro_discogs.yaml`: -```console -composer require calliostro/discogs-bundle +```yaml +calliostro_discogs: + # Recommended: Personal Access Token (get from https://www.discogs.com/settings/developers) + personal_access_token: '%env(DISCOGS_PERSONAL_ACCESS_TOKEN)%' + + # Alternative: Consumer credentials for OAuth applications + # consumer_key: '%env(DISCOGS_CONSUMER_KEY)%' + # consumer_secret: '%env(DISCOGS_CONSUMER_SECRET)%' + + # Optional: HTTP User-Agent header for API requests + # user_agent: 'MyApp/1.0 +https://myapp.com' + + # Optional: Professional rate limiting (requires symfony/rate-limiter) + # rate_limiter: discogs_api # Your configured RateLimiterFactory service ``` -2๏ธโƒฃ **Register the Bundle** +**Personal Access Token:** You need to [get your token](https://www.discogs.com/settings/developers) from Discogs to access your account data and get higher rate limits. For read-only operations on public data, you can use anonymous access. -Add to `config/bundles.php`: +**Consumer Credentials:** For building applications that need OAuth authentication, [register your app](https://www.discogs.com/applications) at Discogs to get your consumer key and secret. -```php -// config/bundles.php -return [ - // ...existing bundles... - Calliostro\DiscogsBundle\CalliostroDiscogsBundle::class => ['all' => true], -]; -``` +**User-Agent:** By default, the client uses `DiscogsClient/4.0.0 (+https://github.com/calliostro/php-discogs-api)` as User-Agent. You can override this in the configuration if needed. -## ๐ŸŽธ Usage +## ๐Ÿš€ Quick Start -The bundle provides a `DiscogsClient` service for autowiring: +### Basic Usage ```php +getArtist(['id' => 8760]); + $artist = $client->getArtist(artistId: $id); + $releases = $client->listArtistReleases(artistId: $id, perPage: 5); return new JsonResponse([ - 'name' => $artist['name'], + 'artist' => $artist['name'], 'profile' => $artist['profile'] ?? null, + 'releases' => $releases['releases'], ]); } } ``` -## โš™๏ธ Configuration +### Collection and Wantlist + +```php +// Requires Personal Access Token +$collection = $client->listCollectionItems(username: 'your-username', folderId: 0); +$wantlist = $client->getUserWantlist(username: 'your-username'); -Create `config/packages/calliostro_discogs.yaml`: +$client->addToCollection( + username: 'your-username', + folderId: 1, + releaseId: 30359313 // Billie Eilish - Happier Than Ever +); -```yaml -# config/packages/calliostro_discogs.yaml -calliostro_discogs: - # Required: HTTP User-Agent header for API requests - user_agent: 'MyApp/1.0 +https://myapp.com' - - # Recommended: Your application credentials from discogs.com/applications - consumer_key: ~ - consumer_secret: ~ - - # Rate limiting configuration - throttle: - enabled: true - microseconds: 1000000 # Wait time when the rate limit is hit - - # OAuth 1.0a authentication (for user-specific data) - oauth: - enabled: false - token_provider: calliostro_discogs.hwi_oauth_token_provider +$client->addToWantlist(username: 'your-username', releaseId: 28409710); // Taylor Swift - Midnights ``` -### ๐Ÿ” Authentication +### Search and Discovery -#### Basic Authentication (Recommended) +```php +$results = $client->search( + q: 'Billie Eilish', + type: 'artist' +); + +$releases = $client->listArtistReleases(artistId: 4470662); // Billie Eilish +$release = $client->getRelease(releaseId: 30359313); // Happier Than Ever +$master = $client->getMaster(masterId: 2835729); // Midnights master +$label = $client->getLabel(labelId: 12677); // Interscope Records +``` -Register your app at [Discogs Applications](https://www.discogs.com/applications) to get: +## โœจ Key Features -- `consumer_key` -- `consumer_secret` +- **Ultra-Lightweight** โ€“ Minimal Symfony integration with zero bloat for the ultra-lightweight Discogs client +- **Complete API Coverage** โ€“ All 60 Discogs API endpoints supported +- **Direct API Calls** โ€“ `$client->getArtist(id: 123)` maps to `/artists/{id}`, no abstractions +- **Type Safe + IDE Support** โ€“ Full PHP 8.1+ types, PHPStan Level 8, method autocomplete +- **Symfony Native** โ€“ Seamless autowiring with Symfony 6.4, 7.x & 8.x +- **Well Tested** โ€“ Comprehensive test coverage, Symfony coding standards +- **Multiple Auth Methods** โ€“ Personal Access Token, OAuth 1.0a, Consumer Credentials, Anonymous -This enables access to protected endpoints and higher rate limits. ๐Ÿšฆ +## ๐ŸŽต All Discogs API Methods as Direct Calls -#### OAuth 1.0a (Optional) +- **Database Methods** โ€“ search(), getArtist(), listArtistReleases(), getRelease(), getUserReleaseRating(), updateUserReleaseRating(), deleteUserReleaseRating(), getCommunityReleaseRating(), getReleaseStats(), getMaster(), listMasterVersions(), getLabel(), listLabelReleases() +- **User Identity Methods** โ€“ getIdentity(), getUser(), updateUser(), listUserSubmissions(), listUserContributions() +- **User Collection Methods** โ€“ listCollectionFolders(), getCollectionFolder(), createCollectionFolder(), updateCollectionFolder(), deleteCollectionFolder(), listCollectionItems(), getCollectionItemsByRelease(), addToCollection(), updateCollectionItem(), removeFromCollection(), getCustomFields(), setCustomFields(), getCollectionValue() +- **User Wantlist Methods** โ€“ getUserWantlist(), addToWantlist(), updateWantlistItem(), removeFromWantlist() +- **User Lists Methods** โ€“ getUserLists(), getUserList() +- **Marketplace Methods** โ€“ getUserInventory(), getMarketplaceListing(), createMarketplaceListing(), updateMarketplaceListing(), deleteMarketplaceListing(), getMarketplaceFee(), getMarketplaceFeeByCurrency(), getMarketplacePriceSuggestions(), getMarketplaceStats(), getMarketplaceOrder(), getMarketplaceOrders(), updateMarketplaceOrder(), getMarketplaceOrderMessages(), addMarketplaceOrderMessage() +- **Inventory Export Methods** โ€“ createInventoryExport(), listInventoryExports(), getInventoryExport(), downloadInventoryExport() +- **Inventory Upload Methods** โ€“ addInventoryUpload(), changeInventoryUpload(), deleteInventoryUpload(), listInventoryUploads(), getInventoryUpload() -For user-specific data, OAuth 1.0a is supported via [HWIOAuthBundle](https://github.com/hwi/HWIOAuthBundle): +*All 60 Discogs API endpoints are supported with clean documentation โ€” see [Discogs API Documentation](https://www.discogs.com/developers/) for complete method reference* -```yaml -# config/packages/calliostro_discogs.yaml -calliostro_discogs: - oauth: - enabled: true - # token_provider: calliostro_discogs.hwi_oauth_token_provider # Default, no need to specify -``` +## ๐Ÿ“‹ Requirements -### ๐Ÿ›ก๏ธ Custom Token Provider +- php ^8.1 +- symfony ^6.4 | ^7.0 | ^8.0 +- calliostro/php-discogs-api ^4.0 -Implement `OAuthTokenProviderInterface` for custom OAuth token handling: +## ๐Ÿ”ง Service Integration ```php -use Calliostro\DiscogsBundle\OAuthTokenProviderInterface; +client->getArtist(artistId: $artistId); + $releases = $this->client->listArtistReleases( + artistId: $artistId, + perPage: 10 + ); + + return [ + 'artist' => $artist, + 'releases' => $releases['releases'], + ]; } - public function getTokenSecret(): string + public function addToMyCollection(int $releaseId): void { - // Return OAuth token secret + // Requires Personal Access Token + $this->client->addToCollection( + username: 'your-username', // Replace with actual username + folderId: 1, // "Uncategorized" folder + releaseId: $releaseId + ); } } ``` -## ๐Ÿ“– Documentation +## โšก Rate Limiting (Optional) -- **API Client**: See [calliostro/php-discogs-api](https://github.com/calliostro/php-discogs-api) -- **Discogs API**: [Official Documentation](https://www.discogs.com/developers) -- **Example Application**: [discogs-bundle-demo](https://github.com/calliostro/discogs-bundle-demo) +For high-volume applications, use the powerful [symfony/rate-limiter](https://symfony.com/doc/current/rate_limiter.html) component: -## ๐Ÿค Contributing +```bash +composer require symfony/rate-limiter +``` + +### 1. Configure Rate Limiter + +```yaml +# config/packages/rate_limiter.yaml +rate_limiter: + discogs_api: + policy: 'sliding_window' + limit: 25 # Safe for both anonymous and authenticated access + interval: '1 minute' +``` + +### 2. Configure Bundle + +```yaml +# config/packages/calliostro_discogs.yaml +calliostro_discogs: + personal_access_token: '%env(DISCOGS_PERSONAL_ACCESS_TOKEN)%' + rate_limiter: discogs_api +``` + +**Choose your rate limit based on your authentication:** + +- **Anonymous access:** Use 25/min (as shown above) +- **Authenticated only:** Change limit to 60 for maximum performance -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request +The bundle uses a Guzzle middleware that automatically handles rate limiting by: -Please ensure your code follows Symfony coding standards and includes tests. +- Intercepting outgoing requests before they're sent +- Checking rate limit availability using Symfony's RateLimiter +- Automatically waiting when limits are exceeded (using microsecond precision) +- Retrying requests after the appropriate delay + +This seamless integration ensures your application never exceeds API limits without requiring any code changes. Higher rates may result in HTTP 429 responses if rate limiting is not configured. + +## ๐Ÿค Contributing + +Contributions are welcome! Please see [DEVELOPMENT.md](DEVELOPMENT.md) for detailed setup instructions, testing guide, and development workflow. ## ๐Ÿ“„ License @@ -160,6 +231,9 @@ This project is licensed under the MIT License โ€” see the [LICENSE](LICENSE) fi ## ๐Ÿ™ Acknowledgments - [Discogs](https://www.discogs.com/) for providing the excellent music database API -- [ricbra/RicbraDiscogsBundle](https://github.com/ricbra/RicbraDiscogsBundle) for the original inspiration +- [Symfony](https://symfony.com) for the robust framework and DI container +- [calliostro/php-discogs-api](https://github.com/calliostro/php-discogs-api) for the modern client library + +--- -> **โญ Star this repo if you find it useful! It helps others discover this lightweight solution.** +> **โญ Star this repo** if you find it useful! It helps others discover this lightweight solution. diff --git a/UPGRADE.md b/UPGRADE.md index 2c20cd2..27985e8 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,77 +1,164 @@ -# Upgrade Guide +# ๐Ÿš€ Upgrade Guide โ€“ v4.0.0 Complete Rewrite -## Upgrading from 3.0.x to 3.1.0 +**v4.0.0 is a complete rewrite and fresh start.** If you're coming from v2.x or earlier, this is essentially a new bundle with the same name, but with modern architecture and much better developer experience. -### ๐Ÿ”ง System Requirements +## ๐Ÿ“ˆ Migration from v2.x -**Before upgrading, ensure your system meets the new requirements:** +**Coming from v2.x?** While this is technically a new bundle, migration is straightforward and brings significant benefits. Here's what you need to know: -- **PHP 8.1+** (previously 7.3+) -- **Symfony 6.4+ or 7.x** (previously 5.x+) +### Why Upgrade? -### ๐Ÿ“ฆ Installation +- **Simpler Installation**: No complex OAuth setup required +- **Better Authentication**: Personal Access Tokens (much easier than v2.x OAuth) +- **Better Performance**: Modern PHP 8.1+ with an optimized HTTP client +- **Type Safety**: Full PHPStan Level 8 compliance with better IDE support +- **Consistent APIs**: All method names follow clear patterns +- **Less Configuration**: Works out of the box for most use cases +- **Clean Migration**: Clear migration path with comprehensive documentation -```bash -composer require calliostro/discogs-bundle:^3.1 -``` +### Quick Migration Steps + +1. **Update Composer**: `composer require calliostro/discogs-bundle:^4.0` +2. **Update Type Hints**: Change `DiscogsApiClient` โ†’ `DiscogsClient` +3. **Update Imports**: Change namespace in use statements +4. **Simplify Config**: Use Personal Access Token instead of OAuth +5. **Update Method Names**: Some methods have clearer names (see below) +6. **Test & Deploy**: Your app will be faster and more reliable! -### โœ… What's Included +## ๐ŸŽฏ What's New in v4.0.0 -- **Zero Code Changes Required** โ€“ All existing configurations and code continue to work -- **Better Error Messages** โ€“ Clearer validation messages when OAuth is misconfigured -- **Improved Performance** โ€“ Optimized internal service handling -- **Enhanced Documentation** โ€“ Updated examples and configuration guides +- **Modern PHP 8.1+ Architecture**: Built with modern PHP features and type safety +- **Symfony 6.4+ Integration**: Full support for current Symfony versions +- **Personal Access Token Support**: Simple authentication with Discogs tokens +- **Built-in OAuth 1.0a**: No external dependencies required +- **Consistent API Methods**: All 60 Discogs API endpoints with verb-first naming +- **Zero Configuration**: Works out of the box for public API access -### ๐Ÿšจ Potential Issues +## ๐Ÿ“‹ System Requirements -#### If you're using PHP < 8.1 +- **PHP**: 8.1+ +- **Symfony**: 6.4+ | 7.x | 8.x +- **calliostro/php-discogs-api**: ^4.0 + +## ๐Ÿ“ฆ Fresh Installation ```bash -# Update your PHP version first -php --version # Should show 8.1 or higher +composer require calliostro/discogs-bundle:^4.0 ``` -#### If you're using Symfony < 6.4 +## ๐Ÿš€ Quick Migration Overview -```bash -# Check your Symfony version -composer show symfony/framework-bundle | grep versions +The key difference: **v4.0 uses named parameters instead of arrays** -# Upgrade Symfony first -composer require symfony/framework-bundle:^6.4 +```php +// v4.0 - Modern approach with named parameters +$artist = $client->getArtist(artistId: $id); +$releases = $client->listArtistReleases(artistId: $id, perPage: 5); + +// v2.x - Old array-based parameters (no longer supported) +// $artist = $client->getArtist(['id' => $id]); +// $releases = $client->getArtistReleases(['id' => $id, 'per_page' => 5]); ``` -### ๐Ÿ” Testing Your Upgrade +**Configuration is now simpler:** Use Personal Access Token instead of complex OAuth setup. + +See [README.md](README.md) for complete setup and usage documentation. + +## ๐Ÿ”„ Key Migration Changes (v2.x โ†’ v4.0) -After upgrading, test your Discogs integration: +### Type Hints & Imports ```php -// Basic test - should work without changes -$artist = $discogs->getArtist(['id' => 8760]); -echo $artist['name']; // Should display artist name -``` +// v2.x +use Discogs\DiscogsClient; + +public function show(DiscogsClient $discogs): Response +{ + // ... +} -### ๐Ÿ’ก Configuration Improvements +// v4.0 +use Calliostro\Discogs\DiscogsClient; -Your existing configuration continues to work, but you can now benefit from: +public function show(DiscogsClient $discogs): Response +{ + // ... +} +``` -#### Clearer Error Messages +### Configuration Changes ```yaml -# config/packages/calliostro_discogs.yaml +# v2.x - Complex OAuth setup +calliostro_discogs: + oauth: + enabled: true + consumer_key: '%env(DISCOGS_CONSUMER_KEY)%' + consumer_secret: '%env(DISCOGS_CONSUMER_SECRET)%' + # ... more complex configuration ... + +# v4.0 - Simple Personal Access Token calliostro_discogs: - oauth: - enabled: true - # If consumer_key is missing, you'll get a clearer error message + personal_access_token: '%env(DISCOGS_PERSONAL_ACCESS_TOKEN)%' ``` -#### Better Documentation +### Most Common Method Changes -Check the updated README.md for modern examples and best practices. +| v2.x Method | v4.0 Method | Notes | +|-------------------------------------|------------------------------------------|-----------------------------------------| +| `getProfile(['username' => $user])` | `getUser(username: $user)` | Clearer naming & parameter style | +| `getArtistReleases(['id' => $id])` | `listArtistReleases(artistId: $id)` | Consistent verb-first & parameter style | +| `getCollectionFolders([...])` | `listCollectionFolders(username: $user)` | Consistent verb-first & parameter style | +| `getUserWants([...])` | `getUserWantlist(username: $user)` | Clearer naming & parameter style | +| `getInventory([...])` | `getUserInventory(username: $user)` | More specific & parameter style | ---- +**Note:** Most methods stay the same! Parameters and return values are identical. + +## ๐Ÿ“– API Changes + +**All 60 Discogs API endpoints** are now available with **consistent verb-first naming** and **named parameters**. -**Need Help?** +See [README.md](README.md) for complete API documentation. + +## โœ… Migration Checklist (v2.x โ†’ v4.0) + +Use this checklist to ensure a smooth migration: + +- **Backup your current implementation**: just in case +- **Get a Personal Access Token**: from [Discogs Developer Settings](https://www.discogs.com/settings/developers) +- **Update composer.json**: `composer require calliostro/discogs-bundle:^4.0` +- **Update imports**: Find/replace `use Discogs\DiscogsClient;` โ†’ `use Calliostro\Discogs\DiscogsClient;` +- **Update type hints**: Find/replace `DiscogsApiClient` โ†’ `DiscogsClient` +- **Simplify configuration**: Replace OAuth config with Personal Access Token +- **Update method calls**: Check the method mapping table above +- **Run your tests**: Ensure everything works as expected +- **Deploy & enjoy**: Your app is now more modern and maintainable! + +## ๐Ÿ› ๏ธ Find & Replace Commands + +These commands help you find code that might need updating: + +```bash +# Find old type hints and imports +grep -r "DiscogsApiClient" /path/to/your/project --exclude-dir=vendor + +# Find methods that changed names +grep -r "getProfile\|getArtistReleases\|getCollectionFolders\|getUserWants\|getInventory" /path/to/your/project --exclude-dir=vendor + +# Find old configuration patterns +grep -r "oauth:" /path/to/your/config --include="*.yaml" +``` + +## ๐Ÿ“š Documentation + +- **Bundle Documentation**: [README.md](README.md) +- **API Documentation**: [Discogs API Docs](https://www.discogs.com/developers/) + +## ๐Ÿ†˜ Need Help? + +- **Issues**: [GitHub Issues](https://github.com/calliostro/discogs-bundle/issues) +- **API Client**: [calliostro/php-discogs-api](https://github.com/calliostro/php-discogs-api) + +--- -- [Create an issue](https://github.com/calliostro/discogs-bundle/issues) if you encounter problems -- [Check the documentation](https://github.com/calliostro/discogs-bundle#readme) for updated examples +**Welcome to v4.0.0! The most modern and developer-friendly version yet!** diff --git a/composer.json b/composer.json index 6cd9338..d1d3073 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "calliostro/discogs-bundle", - "description": "Symfony bundle for the Discogs API โ€” vinyl, music data & integration made easy", + "description": "Ultra-lightweight Symfony bundle for the Discogs API โ€” vinyl, music data & integration made easy", "type": "symfony-bundle", "keywords": [ "symfony-bundle", @@ -13,15 +13,12 @@ "web-api", "audio", "vinyl", - "php8" + "php8", + "lightweight" ], "homepage": "https://github.com/calliostro/discogs-bundle", "license": "MIT", "authors": [ - { - "name": "Richard van den Brand", - "email": "richard@vandenbrand.org" - }, { "name": "calliostro", "email": "calliostro@gmail.com" @@ -29,16 +26,20 @@ ], "require": { "php": "^8.1", - "calliostro/php-discogs-api": "^2.1", + "calliostro/php-discogs-api": "^4.0", "symfony/config": "^6.4|^7.0|^8.0", "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/security-core": "^6.4|^7.0|^8.0" + "symfony/http-kernel": "^6.4|^7.0|^8.0" + }, + "suggest": { + "symfony/rate-limiter": "For advanced rate limiting functionality" }, "require-dev": { "symfony/phpunit-bridge": "^6.4|^7.0|^8.0", "phpstan/phpstan": "^2.1", - "friendsofphp/php-cs-fixer": "^3.87" + "friendsofphp/php-cs-fixer": "^3.87", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0" }, "autoload": { "psr-4": { @@ -51,16 +52,17 @@ } }, "scripts": { - "test": "vendor/bin/simple-phpunit", - "test-coverage": "vendor/bin/simple-phpunit --coverage-html coverage --coverage-clover coverage.xml", + "test": "vendor/bin/simple-phpunit --testsuite=\"Unit Tests\"", + "test-integration": "vendor/bin/simple-phpunit --testsuite=\"Integration Tests\"", + "test-all": "vendor/bin/simple-phpunit --testsuite=\"All Tests\"", + "test-coverage": "vendor/bin/simple-phpunit --testsuite=\"Unit Tests\" --coverage-html coverage --coverage-clover coverage.xml", + "test-coverage-all": "vendor/bin/simple-phpunit --testsuite=\"All Tests\" --coverage-html coverage --coverage-clover coverage.xml", "cs": "vendor/bin/php-cs-fixer fix --dry-run --diff --verbose", "cs-fix": "vendor/bin/php-cs-fixer fix --verbose", - "analyse": "vendor/bin/phpstan analyse src --level=8", - "analyse-generics": "vendor/bin/phpstan analyse src --level=8 --configuration phpstan/generic-baseline.neon" + "analyse": "vendor/bin/phpstan analyse src --level=8 --configuration phpstan/symfony8-baseline.neon", + "analyse-legacy": "vendor/bin/phpstan analyse src --level=8 --configuration phpstan/symfony-legacy-baseline.neon" }, - "minimum-stability": "stable", - "prefer-stable": true, - "suggest": { - "hwi/HWIOAuthBundle": "Enable OAuth support using HWIOAuthBundle" - } + "minimum-stability": "dev", + "prefer-stable": true + } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon deleted file mode 100644 index ad180ee..0000000 --- a/phpstan-baseline.neon +++ /dev/null @@ -1,5 +0,0 @@ -parameters: - ignoreErrors: - - - identifier: missingType.generics - path: src/DependencyInjection/Configuration.php diff --git a/phpstan-symfony74.neon b/phpstan-symfony74.neon deleted file mode 100644 index c961b14..0000000 --- a/phpstan-symfony74.neon +++ /dev/null @@ -1,8 +0,0 @@ -parameters: - level: 8 - paths: - - src - ignoreErrors: - - - identifier: missingType.generics - path: src/DependencyInjection/Configuration.php diff --git a/phpstan/generic-baseline.neon b/phpstan/generic-baseline.neon deleted file mode 100644 index 6aa2dbf..0000000 --- a/phpstan/generic-baseline.neon +++ /dev/null @@ -1,5 +0,0 @@ -parameters: - ignoreErrors: - - - identifier: missingType.generics - path: ../src/DependencyInjection/Configuration.php diff --git a/phpstan/symfony-legacy-baseline.neon b/phpstan/symfony-legacy-baseline.neon new file mode 100644 index 0000000..a5b8502 --- /dev/null +++ b/phpstan/symfony-legacy-baseline.neon @@ -0,0 +1,7 @@ +parameters: + ignoreErrors: + - + message: '#^Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition\:\:children\(\)\.$#' + identifier: method.notFound + count: 1 + path: ../src/DependencyInjection/Configuration.php diff --git a/phpstan/symfony74-baseline.neon b/phpstan/symfony74-baseline.neon new file mode 100644 index 0000000..e5a3d87 --- /dev/null +++ b/phpstan/symfony74-baseline.neon @@ -0,0 +1,7 @@ +parameters: + ignoreErrors: + - + message: '#^Method Calliostro\\DiscogsBundle\\DependencyInjection\\Configuration\:\:getConfigTreeBuilder\(\) return type with generic class Symfony\\Component\\Config\\Definition\\Builder\\TreeBuilder does not specify its types\: T$#' + identifier: missingType.generics + count: 1 + path: ../src/DependencyInjection/Configuration.php \ No newline at end of file diff --git a/phpstan/symfony8-baseline.neon b/phpstan/symfony8-baseline.neon new file mode 100644 index 0000000..e5a3d87 --- /dev/null +++ b/phpstan/symfony8-baseline.neon @@ -0,0 +1,7 @@ +parameters: + ignoreErrors: + - + message: '#^Method Calliostro\\DiscogsBundle\\DependencyInjection\\Configuration\:\:getConfigTreeBuilder\(\) return type with generic class Symfony\\Component\\Config\\Definition\\Builder\\TreeBuilder does not specify its types\: T$#' + identifier: missingType.generics + count: 1 + path: ../src/DependencyInjection/Configuration.php \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7b07e80..75277c3 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -19,7 +19,13 @@ - + + ./tests/Unit + + + ./tests/Integration + + ./tests diff --git a/src/CalliostroDiscogsBundle.php b/src/CalliostroDiscogsBundle.php index 488b36b..cc5b188 100644 --- a/src/CalliostroDiscogsBundle.php +++ b/src/CalliostroDiscogsBundle.php @@ -1,9 +1,28 @@ extension) { + $this->extension = new CalliostroDiscogsExtension(); + } + + \assert($this->extension instanceof ExtensionInterface); + + return $this->extension; + } } diff --git a/src/DependencyInjection/CalliostroDiscogsExtension.php b/src/DependencyInjection/CalliostroDiscogsExtension.php index 59ddadb..4081340 100644 --- a/src/DependencyInjection/CalliostroDiscogsExtension.php +++ b/src/DependencyInjection/CalliostroDiscogsExtension.php @@ -1,20 +1,22 @@ processConfiguration($configuration, $configs); - $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); - $loader->load('services.xml'); + // Load services configuration + $this->loadServices($container); // Configure client based on authentication method + $this->configureClient($container, $config); + } - $params = [ - 'headers' => ['User-Agent' => $config['user_agent']], - ]; + /** + * @param array $config + */ + private function configureClient(ContainerBuilder $container, array $config): void + { + $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); - $this->configureThrottling($container, $config, $params); - $this->configureOAuth($container, $config, $params, $loader); + // Create a factory service that will handle validation at runtime + $factoryDefinition = $container->register('calliostro_discogs.client_factory', 'Calliostro\\DiscogsBundle\\DependencyInjection\\DiscogsClientFactory'); - $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); - $clientDefinition->replaceArgument(0, $params); + // Set the client to use our custom factory + $clientDefinition->setFactory([new Reference('calliostro_discogs.client_factory'), 'createClient']); + $clientDefinition->setArguments([ + $config['personal_access_token'] ?? null, + $config['consumer_key'] ?? null, + $config['consumer_secret'] ?? null, + $this->getClientOptions($container, $config), + ]); } /** * @param array $config - * @param array $params + * + * @return array */ - private function configureThrottling(ContainerBuilder $container, array $config, array &$params): void + private function getClientOptions(ContainerBuilder $container, array $config): array { - if (!$config['throttle']['enabled']) { - return; - } + $options = []; - $throttleDefinition = $container->getDefinition('calliostro_discogs.throttle_subscriber'); - $throttleDefinition->replaceArgument(0, $config['throttle']['microseconds']); + // Only set the User-Agent header if explicitly configured + if (!empty($config['user_agent'])) { + $options['headers'] = ['User-Agent' => $config['user_agent']]; + } - $throttleHandlerDefinition = $container->getDefinition('calliostro_discogs.throttle_handler_stack'); - $throttleHandlerDefinition->replaceArgument(0, new Reference('calliostro_discogs.throttle_subscriber')); + // Configure rate limiting if requested + if (!empty($config['rate_limiter'])) { + $this->configureSymfonyRateLimiter($container, $config['rate_limiter'], $options); + } - $params['handler'] = new Reference('calliostro_discogs.throttle_handler_stack'); + return $options; } /** - * @param array $config - * @param array $params + * Configure Symfony Rate Limiter integration. * - * @throws \Exception When OAuth service configuration file cannot be loaded + * @param array &$options */ - private function configureOAuth(ContainerBuilder $container, array $config, array &$params, Loader\XmlFileLoader $loader): void + private function configureSymfonyRateLimiter(ContainerBuilder $container, string $rateLimiterService, array &$options): void { - if ($config['oauth']['enabled']) { - $loader->load('oauth.xml'); - - $subscriber = $container->getDefinition('calliostro_discogs.subscriber.oauth'); - $subscriber->replaceArgument(0, new Reference($config['oauth']['token_provider'])); - $subscriber->replaceArgument(1, $config['consumer_key']); - $subscriber->replaceArgument(2, $config['consumer_secret']); - - $oauthHandlerDefinition = $container->getDefinition('calliostro_discogs.oauth_handler_stack'); - $oauthHandlerDefinition->replaceArgument(0, new Reference('calliostro_discogs.subscriber.oauth')); - - $params['handler'] = new Reference('calliostro_discogs.oauth_handler_stack'); - } elseif (isset($config['consumer_key'], $config['consumer_secret'])) { - $params['headers']['Authorization'] = \sprintf( - 'Discogs key=%s, secret=%s', - $config['consumer_key'], - $config['consumer_secret'], - ); + // Check if the symfony/rate-limiter component is available + if (!$this->isRateLimiterAvailable()) { + throw new \LogicException('To use the rate_limiter configuration, you must install symfony/rate-limiter. Run: composer require symfony/rate-limiter'); } + + // Create the rate limiter middleware service + $middlewareDefinition = $container->register('calliostro_discogs.rate_limiter_middleware', 'Calliostro\\DiscogsBundle\\Middleware\\RateLimiterMiddleware'); + $middlewareDefinition->setArguments([ + new Reference($rateLimiterService), + 'discogs_api', // Default limiter key + ]); + + // Create a handler stack with the rate limiter middleware + $handlerDefinition = $container->register('calliostro_discogs.rate_limiter_handler_stack', 'GuzzleHttp\\HandlerStack'); + $handlerDefinition->setFactory(['GuzzleHttp\\HandlerStack', 'create']); + $handlerDefinition->addMethodCall('push', [ + new Reference('calliostro_discogs.rate_limiter_middleware'), + 'rate_limiter', + ]); + + $options['handler'] = new Reference('calliostro_discogs.rate_limiter_handler_stack'); + } + + /** + * Load service configuration files. + * Uses PHP configuration for all Symfony versions (4.2+) for consistency and future-proofing. + */ + private function loadServices(ContainerBuilder $container): void + { + $fileLocator = new FileLocator(__DIR__.'/../Resources/config'); + $loader = new PhpFileLoader($container, $fileLocator); + $loader->load('services.php'); + } + + /** + * Check if the symfony/rate-limiter component is available. + * This method is protected to allow testing. + */ + protected function isRateLimiterAvailable(): bool + { + return class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory'); } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 6c9c311..4e998e0 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -1,15 +1,12 @@ getRootNode(); - // @phpstan-ignore-next-line: Symfony Config Builder has dynamic method resolution $rootNode ->children() - ->scalarNode('user_agent') - ->defaultValue( - 'CalliostroDiscogsBundle/2.0 +https://github.com/calliostro/discogs-bundle', - ) - ->info('Freely selectable and valid HTTP user agent identification (required)') - ->end() - ->scalarNode('consumer_key') - ->info('Your consumer key (recommended)') - ->end() - ->scalarNode('consumer_secret') - ->info('Your consumer secret (recommended)') - ->end() - ->arrayNode('throttle') - ->addDefaultsIfNotSet() - ->children() - ->booleanNode('enabled') - ->defaultTrue() - ->info('If activated, a new attempt is made later when the rate limit is reached') - ->end() - ->integerNode('microseconds') - ->defaultValue(1000000) - ->info( - 'Number of milliseconds to wait until the next attempt when the rate limit is reached', - ) - ->end() - ->end() - ->end() - ->arrayNode('oauth') - ->addDefaultsIfNotSet() - ->children() - ->booleanNode('enabled') - ->defaultFalse() - ->info('If enabled, full OAuth 1.0a with access token/secret is used') - ->end() - ->scalarNode('token_provider') - ->defaultValue('calliostro_discogs.hwi_oauth_token_provider') - ->info( - 'You can create a service implementing OAuthTokenProviderInterface '. - '(HWIOAuthBundle is supported by default)', - ) - ->end() - ->end() - ->end() + ->scalarNode('personal_access_token') + ->info('Your personal access token (recommended - get from https://www.discogs.com/settings/developers)') + ->end() + ->scalarNode('consumer_key') + ->info('Your consumer key (alternative for OAuth applications)') ->end() + ->scalarNode('consumer_secret') + ->info('Your consumer secret (alternative for OAuth applications)') + ->end() + ->scalarNode('user_agent') + ->defaultNull() + ->info('HTTP User-Agent header for API requests (optional)') ->validate() - ->ifTrue(function (array $config): bool { - $oauthEnabled = $config['oauth']['enabled']; - $hasConsumerKey = !empty($config['consumer_key']); - $hasConsumerSecret = !empty($config['consumer_secret']); - $hasTokenProvider = !empty($config['oauth']['token_provider']); - - return $oauthEnabled && (!$hasConsumerKey || !$hasConsumerSecret || !$hasTokenProvider); - }) - ->thenInvalid( - 'OAuth requires consumer_key, consumer_secret, and oauth.token_provider to be configured', - ) + ->ifTrue(fn ($v) => \is_string($v) && \strlen($v) > 200) + ->thenInvalid('User-Agent cannot be longer than 200 characters') + ->end() + ->end() + ->scalarNode('rate_limiter') + ->defaultNull() + ->info('Symfony RateLimiterFactory service ID for advanced rate limiting (requires symfony/rate-limiter)') + ->validate() + ->ifTrue(fn ($v) => \is_string($v) && '' === trim($v)) + ->thenInvalid('Rate limiter service ID cannot be empty') + ->end() ->end() - ; + ->end(); return $treeBuilder; } diff --git a/src/DependencyInjection/DiscogsClientFactory.php b/src/DependencyInjection/DiscogsClientFactory.php new file mode 100644 index 0000000..138b7cc --- /dev/null +++ b/src/DependencyInjection/DiscogsClientFactory.php @@ -0,0 +1,98 @@ + $options + */ + public function createClient( + ?string $personalAccessToken, + ?string $consumerKey, + ?string $consumerSecret, + array $options = [], + ): DiscogsClient { + // Trim all credentials to handle whitespace-only values and null + $personalAccessToken = $personalAccessToken ? trim($personalAccessToken) : ''; + $consumerKey = $consumerKey ? trim($consumerKey) : ''; + $consumerSecret = $consumerSecret ? trim($consumerSecret) : ''; + + // Validate and create client based on available credentials + if (!empty($personalAccessToken)) { + return $this->createWithPersonalAccessToken($personalAccessToken, $options); + } + + if (!empty($consumerKey) && !empty($consumerSecret)) { + return $this->createWithConsumerCredentials($consumerKey, $consumerSecret, $options); + } + + // Check for partial OAuth credentials and provide helpful error + if (!empty($consumerKey) || !empty($consumerSecret)) { + throw new \InvalidArgumentException('Incomplete OAuth credentials provided. Both consumer_key and consumer_secret are required for OAuth authentication. '.$this->getSetupInstructions()); + } + + // Create anonymous client (rate-limited) - this is allowed + return BaseDiscogsClientFactory::create($options); + } + + /** + * Create client with Personal Access Token and validate it. + * + * @param array $options + */ + private function createWithPersonalAccessToken(string $token, array $options): DiscogsClient + { + if (\strlen($token) < 10) { + throw new \InvalidArgumentException(\sprintf('Personal access token must be at least 10 characters long, got %d characters. %s', \strlen($token), $this->getSetupInstructions())); + } + + return BaseDiscogsClientFactory::createWithPersonalAccessToken($token, $options); + } + + /** + * Create client with Consumer Credentials. + * + * @param array $options + */ + private function createWithConsumerCredentials(string $consumerKey, string $consumerSecret, array $options): DiscogsClient + { + return BaseDiscogsClientFactory::createWithConsumerCredentials($consumerKey, $consumerSecret, $options); + } + + /** + * Get helpful setup instructions for the user. + */ + private function getSetupInstructions(): string + { + return "\n\nTo configure Discogs API credentials:\n". + "1. Personal Access Token (recommended):\n". + " - Get your token from: https://www.discogs.com/settings/developers\n". + " - Set environment variable: DISCOGS_PERSONAL_ACCESS_TOKEN=your_token_here\n". + " - Configure in config/packages/calliostro_discogs.yaml:\n". + " calliostro_discogs:\n". + " personal_access_token: '%env(DISCOGS_PERSONAL_ACCESS_TOKEN)%'\n\n". + "2. OAuth Consumer Credentials (for applications):\n". + " - Set environment variables:\n". + " DISCOGS_CONSUMER_KEY=your_key_here\n". + " DISCOGS_CONSUMER_SECRET=your_secret_here\n". + " - Configure in config/packages/calliostro_discogs.yaml:\n". + " calliostro_discogs:\n". + " consumer_key: '%env(DISCOGS_CONSUMER_KEY)%'\n". + " consumer_secret: '%env(DISCOGS_CONSUMER_SECRET)%'\n\n". + "3. Anonymous access (limited rate limits):\n". + " - No configuration needed, but subject to strict rate limits\n"; + } +} diff --git a/src/HWIOauthTokenProvider.php b/src/HWIOauthTokenProvider.php deleted file mode 100644 index 16ed71c..0000000 --- a/src/HWIOauthTokenProvider.php +++ /dev/null @@ -1,38 +0,0 @@ -getRawToken('oauth_token'); - } - - public function getTokenSecret(): string - { - return $this->getRawToken('oauth_token_secret'); - } - - private function getRawToken(string $name): string - { - // getToken() must be a HWI\Bundle\OAuthBundle\Security\Core\Authentication\Token\AbstractOAuthToken - $token = $this->tokenStorage->getToken(); - - if (null !== $token && method_exists($token, 'getRawToken')) { - // Safe call using method_exists check - HWIOAuthBundle's AbstractOAuthToken has this method - $rawToken = \call_user_func([$token, 'getRawToken']); - if (\is_array($rawToken) && isset($rawToken[$name])) { - return (string) $rawToken[$name]; - } - } - - return ''; - } -} diff --git a/src/Middleware/RateLimiterMiddleware.php b/src/Middleware/RateLimiterMiddleware.php new file mode 100644 index 0000000..7156ccc --- /dev/null +++ b/src/Middleware/RateLimiterMiddleware.php @@ -0,0 +1,49 @@ +rateLimiterFactory->create($this->limiterKey); + + // Try to consume from rate limiter with retry + $limit = $rateLimiter->consume(1); + + while (!$limit->isAccepted()) { + // If rate limit exceeded, wait for the retry after time + $retryAfter = $limit->getRetryAfter(); + $waitTime = $retryAfter->getTimestamp() - time(); + if ($waitTime > 0) { + usleep($waitTime * 1000000); // Convert to microseconds + } + + // Try again after waiting + $limit = $rateLimiter->consume(1); + } + + return $handler($request, $options); + }; + } +} diff --git a/src/OAuthHandlerStackFactory.php b/src/OAuthHandlerStackFactory.php deleted file mode 100644 index 1317f67..0000000 --- a/src/OAuthHandlerStackFactory.php +++ /dev/null @@ -1,20 +0,0 @@ -push($oauth, 'oauth'); - } - - return $handler; - } -} diff --git a/src/OAuthSubscriberFactory.php b/src/OAuthSubscriberFactory.php deleted file mode 100644 index 96f5e4d..0000000 --- a/src/OAuthSubscriberFactory.php +++ /dev/null @@ -1,21 +0,0 @@ - $consumerKey, - 'consumer_secret' => $consumerSecret, - 'token' => $provider->getToken(), - 'token_secret' => $provider->getTokenSecret(), - ]); - } -} diff --git a/src/OAuthTokenProviderInterface.php b/src/OAuthTokenProviderInterface.php deleted file mode 100644 index f712bc7..0000000 --- a/src/OAuthTokenProviderInterface.php +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php new file mode 100644 index 0000000..ee935f7 --- /dev/null +++ b/src/Resources/config/services.php @@ -0,0 +1,22 @@ +services(); + + // Main Discogs API Client + $services->set('calliostro_discogs.discogs_client', DiscogsClient::class) + ->public() + ->factory([DiscogsClientFactory::class, 'create']) + ->args([[]]); + + // Primary alias for autowiring (Symfony 7.4+ and 8.0+) + // Must be explicitly private to match XML configuration + $services->alias(DiscogsClient::class, 'calliostro_discogs.discogs_client') + ->private(); +}; diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml deleted file mode 100644 index 79befd1..0000000 --- a/src/Resources/config/services.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/ThrottleHandlerStackFactory.php b/src/ThrottleHandlerStackFactory.php deleted file mode 100644 index 31219a1..0000000 --- a/src/ThrottleHandlerStackFactory.php +++ /dev/null @@ -1,21 +0,0 @@ -push(Middleware::retry($subscriber->decider(), $subscriber->delay()), 'throttle'); - } - - return $handler; - } -} diff --git a/tests/CalliostroDiscogsExtensionTest.php b/tests/CalliostroDiscogsExtensionTest.php deleted file mode 100644 index a8de27a..0000000 --- a/tests/CalliostroDiscogsExtensionTest.php +++ /dev/null @@ -1,131 +0,0 @@ -load([], $container); - - $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); - } - - public function testLoadWithThrottleEnabled(): void - { - $container = new ContainerBuilder(); - $extension = new CalliostroDiscogsExtension(); - - $config = [ - [ - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 500000, - ], - ], - ]; - - $extension->load($config, $container); - - $this->assertTrue($container->hasDefinition('calliostro_discogs.throttle_subscriber')); - $this->assertTrue($container->hasDefinition('calliostro_discogs.throttle_handler_stack')); - } - - public function testLoadWithOAuthEnabled(): void - { - $container = new ContainerBuilder(); - $extension = new CalliostroDiscogsExtension(); - - $config = [ - [ - 'oauth' => [ - 'enabled' => true, - 'token_provider' => 'app.oauth_token_provider', - ], - 'consumer_key' => 'test_key', - 'consumer_secret' => 'test_secret', - ], - ]; - - $extension->load($config, $container); - - $this->assertTrue($container->hasDefinition('calliostro_discogs.subscriber.oauth')); - $this->assertTrue($container->hasDefinition('calliostro_discogs.oauth_handler_stack')); - } - - public function testLoadWithConsumerKeyAndSecretOnly(): void - { - $container = new ContainerBuilder(); - $extension = new CalliostroDiscogsExtension(); - - $config = [ - [ - 'consumer_key' => 'test_key', - 'consumer_secret' => 'test_secret', - ], - ]; - - $extension->load($config, $container); - - $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); - - // Check that the Authorization header is set correctly - $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); - $arguments = $clientDefinition->getArguments(); - $this->assertArrayHasKey('headers', $arguments[0]); - $this->assertArrayHasKey('Authorization', $arguments[0]['headers']); - $this->assertEquals( - 'Discogs key=test_key, secret=test_secret', - $arguments[0]['headers']['Authorization'] - ); - } - - public function testLoadWithThrottleDisabled(): void - { - $container = new ContainerBuilder(); - $extension = new CalliostroDiscogsExtension(); - - $config = [ - [ - 'throttle' => [ - 'enabled' => false, - ], - ], - ]; - - $extension->load($config, $container); - - $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); - // When the throttle is disabled, no throttle handler should be configured - $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); - $arguments = $clientDefinition->getArguments(); - $this->assertArrayNotHasKey('handler', $arguments[0]); - } - - public function testLoadWithCustomUserAgent(): void - { - $container = new ContainerBuilder(); - $extension = new CalliostroDiscogsExtension(); - - $config = [ - [ - 'user_agent' => 'CustomAgent/1.0', - ], - ]; - - $extension->load($config, $container); - - $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); - $arguments = $clientDefinition->getArguments(); - $this->assertArrayHasKey('headers', $arguments[0]); - $this->assertArrayHasKey('User-Agent', $arguments[0]['headers']); - $this->assertEquals('CustomAgent/1.0', $arguments[0]['headers']['User-Agent']); - } -} diff --git a/tests/Fixtures/TestKernel.php b/tests/Fixtures/TestKernel.php new file mode 100644 index 0000000..32fabc7 --- /dev/null +++ b/tests/Fixtures/TestKernel.php @@ -0,0 +1,139 @@ + + */ + private array $calliostroDiscogsConfig; + + /** + * @var array + */ + private array $extraBundles; + + /** + * @param array $calliostroDiscogsConfig + * @param string $environment Test environment name + * @param array $extraBundles Additional bundles to register + */ + public function __construct( + array $calliostroDiscogsConfig = [], + string $environment = 'test', + array $extraBundles = [], + ) { + $this->calliostroDiscogsConfig = $calliostroDiscogsConfig; + $this->extraBundles = $extraBundles; + + parent::__construct($environment, true); + } + + /** + * Helper method to create a kernel for specific test scenarios. + * + * @param array $config + */ + public static function createForIntegration(array $config = []): self + { + return new self($config, 'integration_test'); + } + + /** + * Helper method to create a kernel for unit test scenarios. + * + * @param array $config + */ + public static function createForUnit(array $config = []): self + { + return new self($config, 'unit_test'); + } + + /** + * Helper method to create a kernel for functional test scenarios. + * + * @param array $config + */ + public static function createForFunctional(array $config = []): self + { + return new self($config, 'functional_test'); + } + + /** + * @return array + */ + public function registerBundles(): array + { + $bundles = [ + new CalliostroDiscogsBundle(), + ]; + + return array_merge($bundles, $this->extraBundles); + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(function (ContainerBuilder $container) { + // Load the bundle configuration + if (!empty($this->calliostroDiscogsConfig)) { + $container->loadFromExtension('calliostro_discogs', $this->calliostroDiscogsConfig); + } + + // Add common test services + $container->setParameter('kernel.secret', 'test_secret'); + + // Disable logging in tests to reduce noise + $container->setParameter('kernel.logs_dir', $this->getLogDir()); + }); + } + + public function getLogDir(): string + { + return $this->getProjectDir().'/var/log/'.$this->environment; + } + + /** + * Cleanup method to remove the test cache after test execution. + */ + public function cleanupCache(): void + { + $cacheDir = $this->getCacheDir(); + if (is_dir($cacheDir)) { + $this->removeDirectory($cacheDir); + } + } + + public function getCacheDir(): string + { + return $this->getProjectDir().'/var/cache/'. + $this->environment.'/'. + md5(serialize($this->calliostroDiscogsConfig)).'/'. + spl_object_hash($this); + } + + /** + * Recursively remove a directory and its contents. + */ + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir) ?: [], ['.', '..']); + foreach ($files as $file) { + $path = $dir.\DIRECTORY_SEPARATOR.$file; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/tests/FunctionalTest.php b/tests/FunctionalTest.php deleted file mode 100644 index 40cc679..0000000 --- a/tests/FunctionalTest.php +++ /dev/null @@ -1,81 +0,0 @@ -boot(); - $container = $kernel->getContainer(); - - $discogsClient = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsClient::class, $discogsClient); - } - - public function testServiceWiringWithConfiguration(): void - { - $kernel = new CalliostroDiscogsTestingKernel([ - 'user_agent' => 'test', - ]); - $kernel->boot(); - $container = $kernel->getContainer(); - - $discogsClient = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsClient::class, $discogsClient); - - // Verify that the HTTP client is properly configured - // The user agent configuration is handled internally by the bundle - $httpClient = $discogsClient->getHttpClient(); - // @phpstan-ignore-next-line method.alreadyNarrowedType - $this->assertNotNull($httpClient); - } -} - -final class CalliostroDiscogsTestingKernel extends Kernel -{ - /** - * @var array - */ - private array $calliostroDiscogsConfig; - - /** - * @param array $calliostroDiscogsConfig - */ - public function __construct(array $calliostroDiscogsConfig = []) - { - $this->calliostroDiscogsConfig = $calliostroDiscogsConfig; - - parent::__construct('test', true); - } - - /** - * @return array - */ - public function registerBundles(): array - { - return [ - new CalliostroDiscogsBundle(), - ]; - } - - public function registerContainerConfiguration(LoaderInterface $loader): void - { - $loader->load(function (ContainerBuilder $container) { - $container->loadFromExtension('calliostro_discogs', $this->calliostroDiscogsConfig); - }); - } - - public function getCacheDir(): string - { - return $this->getProjectDir().'/var/cache/'.$this->environment.'/'.spl_object_hash($this); - } -} diff --git a/tests/HWIOauthTokenProviderTest.php b/tests/HWIOauthTokenProviderTest.php deleted file mode 100644 index 3f430f0..0000000 --- a/tests/HWIOauthTokenProviderTest.php +++ /dev/null @@ -1,68 +0,0 @@ -createMock(TokenStorageInterface::class); - $tokenStorage->method('getToken')->willReturn(null); - - $provider = new HWIOauthTokenProvider($tokenStorage); - - $this->assertEquals('', $provider->getToken()); - $this->assertEquals('', $provider->getTokenSecret()); - } - - public function testGetTokenWithTokenWithoutRawTokenMethod(): void - { - $mockToken = $this->createMock(TokenInterface::class); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage->method('getToken')->willReturn($mockToken); - - $provider = new HWIOauthTokenProvider($tokenStorage); - - $this->assertEquals('', $provider->getToken()); - $this->assertEquals('', $provider->getTokenSecret()); - } - - public function testGetTokenWithValidOAuthToken(): void - { - $oauthToken = new MockOAuthToken([ - 'oauth_token' => 'test_access_token', - 'oauth_token_secret' => 'test_access_token_secret', - ]); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage->method('getToken')->willReturn($oauthToken); - - $provider = new HWIOauthTokenProvider($tokenStorage); - - $this->assertEquals('test_access_token', $provider->getToken()); - $this->assertEquals('test_access_token_secret', $provider->getTokenSecret()); - } - - public function testGetTokenWithIncompleteRawToken(): void - { - // Test token with getRawToken but missing oauth_token_secret - $oauthToken = new MockOAuthToken([ - 'oauth_token' => 'test_access_token', - // oauth_token_secret is intentionally missing - ]); - - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage->method('getToken')->willReturn($oauthToken); - - $provider = new HWIOauthTokenProvider($tokenStorage); - - $this->assertEquals('test_access_token', $provider->getToken()); - $this->assertEquals('', $provider->getTokenSecret()); // Missing key returns an empty string - } -} diff --git a/tests/Integration/AuthenticatedApiIntegrationTest.php b/tests/Integration/AuthenticatedApiIntegrationTest.php new file mode 100644 index 0000000..bc96c07 --- /dev/null +++ b/tests/Integration/AuthenticatedApiIntegrationTest.php @@ -0,0 +1,199 @@ +createKernel([ + 'user_agent' => 'CalliostroDiscogsBundle/AuthTest', + 'consumer_key' => $this->consumerKey, + 'consumer_secret' => $this->consumerSecret, + ]); + $kernel->boot(); + $container = $kernel->getContainer(); + $client = $container->get('calliostro_discogs.discogs_client'); + \assert($client instanceof \Calliostro\Discogs\DiscogsClient); + + // All public endpoints should still work + $artist = $client->getArtist(artistId: 1); + $this->assertArrayHasKey('name', $artist); + + // Search should now work with consumer credentials + $searchResults = $client->search(q: 'Daft Punk', type: 'artist'); + $this->assertArrayHasKey('results', $searchResults); + $this->assertGreaterThan(0, \count($searchResults['results'])); + } + + /** + * Level 3: Personal Access Token - Your account access through Bundle. + */ + public function testLevel3PersonalAccessToken(): void + { + $kernel = $this->createKernel([ + 'user_agent' => 'CalliostroDiscogsBundle/PersonalTest', + 'personal_access_token' => $this->personalToken, + ]); + $kernel->boot(); + $container = $kernel->getContainer(); + $client = $container->get('calliostro_discogs.discogs_client'); + \assert($client instanceof \Calliostro\Discogs\DiscogsClient); + + // All previous functionality should work + $artist = $client->getArtist(artistId: 1); + $this->assertArrayHasKey('name', $artist); + + $searchResults = $client->search(q: 'Jazz', type: 'release'); + $this->assertArrayHasKey('results', $searchResults); + + // Test that we can successfully make authenticated requests + $this->assertNotEmpty($searchResults['results']); + } + + /** + * Test ultra-lightweight bundle behavior with authenticated requests. + * Bundle has no built-in throttling - rate limiting via Symfony component if needed. + */ + public function testUltraLightweightWithAuthentication(): void + { + $kernel = $this->createKernel([ + 'personal_access_token' => $this->personalToken, + ]); + $kernel->boot(); + $container = $kernel->getContainer(); + $client = $container->get('calliostro_discogs.discogs_client'); + \assert($client instanceof \Calliostro\Discogs\DiscogsClient); + + // Make several requests in quick succession + // No built-in throttling overhead + $startTime = microtime(true); + + for ($i = 0; $i < 3; ++$i) { + $artist = $client->getArtist(artistId: 1 + $i); + $this->assertArrayHasKey('name', $artist); + } + + $endTime = microtime(true); + $duration = $endTime - $startTime; + + // Ultra-lightweight bundle should complete quickly without throttling overhead + $this->assertLessThan(3.0, $duration, 'Ultra-lightweight bundle took too long'); + } + + /** + * Level 4: OAuth Tokens - Full account access (bypassing Bundle config). + * + * Note: OAuth is not configured in Bundle config but can be used directly + * via the underlying library factory methods for advanced use cases. + */ + public function testLevel4OAuthDirectLibraryUsage(): void + { + if (empty($this->oauthToken) || empty($this->oauthTokenSecret)) { + $this->markTestSkipped('OAuth tokens not available - requires DISCOGS_OAUTH_TOKEN and DISCOGS_OAUTH_TOKEN_SECRET'); + } + + // Create the OAuth client directly via the library (not Bundle config) + $client = \Calliostro\Discogs\DiscogsClientFactory::createWithOAuth( + $this->consumerKey, + $this->consumerSecret, + $this->oauthToken, + $this->oauthTokenSecret + ); + + // Test identity endpoint (OAuth-specific functionality) + try { + $identity = $client->getIdentity(); + $this->assertArrayHasKey('username', $identity); + $this->assertNotEmpty($identity['username']); + } catch (\Exception $e) { + $this->skipIfInvalidCredentials($e); + } + + // Test search with OAuth (should work like other auth methods) + try { + $searchResults = $client->search(q: 'Electronic', type: 'artist', perPage: 5); + $this->assertArrayHasKey('results', $searchResults); + $this->assertGreaterThan(0, \count($searchResults['results'])); + } catch (\Exception $e) { + $this->skipIfInvalidCredentials($e); + } + } + + /** + * Helper to skip test if credentials are invalid. + */ + private function skipIfInvalidCredentials(\Exception $e): void + { + if ($e instanceof \GuzzleHttp\Exception\ClientException && 401 === $e->getResponse()->getStatusCode()) { + $this->markTestSkipped('Invalid credentials provided - test requires valid Discogs API credentials'); + } + throw $e; + } + + /** + * Test error handling with different authentication levels through Bundle. + */ + public function testErrorHandlingAcrossAuthLevels(): void + { + // Test with consumer credentials + $kernel = $this->createKernel([ + 'consumer_key' => $this->consumerKey, + 'consumer_secret' => $this->consumerSecret, + ]); + $kernel->boot(); + $container = $kernel->getContainer(); + $client = $container->get('calliostro_discogs.discogs_client'); + \assert($client instanceof \Calliostro\Discogs\DiscogsClient); + + try { + $client->getArtist(artistId: 999999999); // Non-existent artist + $this->fail('Should have thrown exception for non-existent artist'); + } catch (\Exception $e) { + $this->assertStringContainsStringIgnoringCase('not found', $e->getMessage()); + } + } + + protected function setUp(): void + { + $this->consumerKey = getenv('DISCOGS_CONSUMER_KEY') ?: ''; + $this->consumerSecret = getenv('DISCOGS_CONSUMER_SECRET') ?: ''; + $this->personalToken = getenv('DISCOGS_PERSONAL_ACCESS_TOKEN') ?: ''; + $this->oauthToken = getenv('DISCOGS_OAUTH_TOKEN') ?: ''; + $this->oauthTokenSecret = getenv('DISCOGS_OAUTH_TOKEN_SECRET') ?: ''; + + if (empty($this->consumerKey) || empty($this->consumerSecret) || empty($this->personalToken)) { + $this->markTestSkipped('Authentication credentials not available'); + } + + parent::setUp(); + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000..52d1ac8 --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,80 @@ +boot(), getContainer() returns a compiled container + * (runtime container) which only has has()/get() methods, not hasDefinition(). + * The ContainerBuilder (build-time) gets compiled away. + * + * @param array $config + */ + protected function createKernel(array $config = []): TestKernel + { + return TestKernel::createForIntegration($config); + } + + /** + * Override PHPUnit's runTest to add automatic retry on rate limiting + * This uses reflection to access the private runTest method. + * + * @throws \ReflectionException If reflection operations fail + */ + protected function runTest(): mixed + { + $maxRetries = 2; + $attempt = 0; + + while ($attempt <= $maxRetries) { + try { + // Use reflection to call the private runTest method + $reflection = new \ReflectionClass(parent::class); + $method = $reflection->getMethod('runTest'); + /* @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + return $method->invoke($this); + } catch (ClientException $e) { + // Check if this is a rate limit error (429) + if ($e->getResponse() && 429 === $e->getResponse()->getStatusCode()) { + ++$attempt; + + if ($attempt > $maxRetries) { + // Skip test instead of failing CI + $this->markTestSkipped( + 'API rate limit exceeded. Skipping test to prevent CI failure. '. + 'This is expected behavior when multiple tests run quickly.' + ); + } + + // Exponential backoff: 5s, 10s (more aggressive) + $delay = 5 * $attempt; + sleep($delay); + continue; + } + + // Re-throw non-rate-limit exceptions + throw $e; + } + } + + return null; // This should never be reached, but satisfies PHPStan + } +} diff --git a/tests/Integration/Middleware/RateLimiterMiddlewareTest.php b/tests/Integration/Middleware/RateLimiterMiddlewareTest.php new file mode 100644 index 0000000..bb02b68 --- /dev/null +++ b/tests/Integration/Middleware/RateLimiterMiddlewareTest.php @@ -0,0 +1,146 @@ + 'test_limiter', + 'policy' => 'sliding_window', + 'limit' => 10, + 'interval' => '10 seconds', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'test_limiter'); + + // Create handler stack + $mockHandler = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], '{"success": true}'), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $handlerStack->push($middleware); + + $client = new Client(['handler' => $handlerStack]); + $response = $client->get('https://api.example.com/test'); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('{"success": true}', $response->getBody()->getContents()); + } + + public function testMiddlewareDoesNotInterfereWithServerRateLimit(): void + { + // Create a rate limiter with generous limits + $factory = new RateLimiterFactory([ + 'id' => 'test_limiter', + 'policy' => 'sliding_window', + 'limit' => 100, + 'interval' => '60 seconds', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'test_limiter'); + + // Mock a server-side 429 response + $mockHandler = new MockHandler([ + new Response(429, ['Retry-After' => '5'], '{"message": "Rate limit exceeded"}'), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $handlerStack->push($middleware); + + $client = new Client(['handler' => $handlerStack, 'http_errors' => false]); + $response = $client->get('https://api.example.com/rate-limited'); + + // Server-side rate limit should pass through unchanged + $this->assertEquals(429, $response->getStatusCode()); + $this->assertEquals('5', $response->getHeaderLine('Retry-After')); + $this->assertEquals('{"message": "Rate limit exceeded"}', $response->getBody()->getContents()); + } + + public function testMiddlewareUsesCorrectLimiterKey(): void + { + $factory = new RateLimiterFactory([ + 'id' => 'custom_key', + 'policy' => 'fixed_window', + 'limit' => 5, + 'interval' => '60 seconds', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'custom_key'); + + $mockHandler = new MockHandler([ + new Response(200, [], '{"result": "custom"}'), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $handlerStack->push($middleware); + + $client = new Client(['handler' => $handlerStack]); + $response = $client->get('https://api.example.com/custom'); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('{"result": "custom"}', $response->getBody()->getContents()); + } + + public function testMiddlewareHandlesStrictRateLimit(): void + { + // Create a very restrictive rate limiter for testing the limit behavior + $factory = new RateLimiterFactory([ + 'id' => 'strict_limiter', + 'policy' => 'fixed_window', + 'limit' => 1, + 'interval' => '1 second', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'strict_limiter'); + + $mockHandler = new MockHandler([ + new Response(200, [], '{"request": 1}'), + new Response(200, [], '{"request": 2}'), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $handlerStack->push($middleware); + + $client = new Client(['handler' => $handlerStack]); + + // The first request should go through immediately + $startTime = microtime(true); + $response1 = $client->get('https://api.example.com/test1'); + $this->assertEquals(200, $response1->getStatusCode()); + + // The second request might be delayed (but we won't test the actual delay in unit tests) + $response2 = $client->get('https://api.example.com/test2'); + $this->assertEquals(200, $response2->getStatusCode()); + + $endTime = microtime(true); + $duration = $endTime - $startTime; + + // Just verify both requests completed successfully + $this->assertGreaterThanOrEqual(0, $duration); + } + + protected function setUp(): void + { + parent::setUp(); + + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + } +} diff --git a/tests/Integration/PublicApiIntegrationTest.php b/tests/Integration/PublicApiIntegrationTest.php new file mode 100644 index 0000000..130581c --- /dev/null +++ b/tests/Integration/PublicApiIntegrationTest.php @@ -0,0 +1,131 @@ +client->getArtist(artistId: 139250); + $this->assertArrayHasKey('name', $artist); + + // Test release - Billie Eilish - Happier Than Ever (2021) + $release = $this->client->getRelease(releaseId: 19676596); + $this->assertArrayHasKey('title', $release); + $this->assertStringContainsString('Happier Than Ever', $release['title']); + + // Test master - Billie Eilish - Happier Than Ever (2021) + $master = $this->client->getMaster(masterId: 2234794); + $this->assertArrayHasKey('title', $master); + + // Test label + $label = $this->client->getLabel(labelId: 1); + $this->assertArrayHasKey('name', $label); + } + + /** + * Test Community Release Rating endpoint through Bundle. + */ + public function testCommunityReleaseRating(): void + { + $rating = $this->client->getCommunityReleaseRating(releaseId: 19676596); + + $this->assertArrayHasKey('rating', $rating); + $this->assertArrayHasKey('release_id', $rating); + $this->assertEquals(19676596, $rating['release_id']); + + $this->assertArrayHasKey('average', $rating['rating']); + $this->assertArrayHasKey('count', $rating['rating']); + } + + /** + * Test that collection stats are available in the full release endpoint. + */ + public function testCollectionStatsInReleaseEndpoint(): void + { + $release = $this->client->getRelease(releaseId: 19676596); + + $this->assertArrayHasKey('community', $release); + $this->assertArrayHasKey('have', $release['community']); + $this->assertArrayHasKey('want', $release['community']); + + $this->assertIsInt($release['community']['have']); + $this->assertIsInt($release['community']['want']); + $this->assertGreaterThan(0, $release['community']['have']); + $this->assertGreaterThan(0, $release['community']['want']); + } + + /** + * Test Bundle's basic functionality without rate limiting. + * The Bundle is ultra-lightweight and doesn't include built-in throttling. + * Rate limiting can be added via Symfony's rate-limiter component as needed. + */ + public function testBundleBasicFunctionality(): void + { + // Create a kernel with minimal configuration + $kernel = $this->createKernel([ + 'user_agent' => 'CalliostroDiscogsBundle/IntegrationTest', + ]); + $kernel->boot(); + $container = $kernel->getContainer(); + + // Verify the client works without any rate limiting + $client = $container->get('calliostro_discogs.discogs_client'); + \assert($client instanceof \Calliostro\Discogs\DiscogsClient); + + // Make requests - Bundle is ultra-lightweight with no built-in throttling + $responses = []; + for ($i = 0; $i < 2; ++$i) { + $responses[] = $client->getArtist(artistId: 1 + $i); + } + + // All requests should succeed + $this->assertCount(2, $responses); + foreach ($responses as $response) { + $this->assertArrayHasKey('name', $response); + } + + // Bundle is ultra-lightweight - no built-in throttling overhead + $this->addToAssertionCount(1); // Ultra-lightweight bundle allows rapid requests + } + + /** + * Test Bundle error handling for invalid IDs through real API. + */ + public function testBundleErrorHandling(): void + { + $this->expectException(\Exception::class); + $this->client->getArtist(artistId: 999999999); + } + + protected function setUp(): void + { + parent::setUp(); + + $kernel = $this->createKernel([ + 'user_agent' => 'CalliostroDiscogsBundle/IntegrationTest', + ]); + $kernel->boot(); + $container = $kernel->getContainer(); + + $client = $container->get('calliostro_discogs.discogs_client'); + \assert($client instanceof \Calliostro\Discogs\DiscogsClient); + $this->client = $client; + } +} diff --git a/tests/OAuthHandlerStackFactoryTest.php b/tests/OAuthHandlerStackFactoryTest.php deleted file mode 100644 index a27c4e4..0000000 --- a/tests/OAuthHandlerStackFactoryTest.php +++ /dev/null @@ -1,38 +0,0 @@ - 'test_key', - 'consumer_secret' => 'test_secret', - 'token' => 'test_token', - 'token_secret' => 'test_token_secret', - ]); - - $handlerStack = OAuthHandlerStackFactory::factory($oauth); - - /* @noinspection PhpConditionAlreadyCheckedInspection */ - $this->assertInstanceOf(HandlerStack::class, $handlerStack); - // Note: HandlerStack doesn't have a public hasHandler method - // The important thing is that the handler stack is created with the OAuth subscriber - } - - public function testFactoryWithoutOauth(): void - { - $handlerStack = OAuthHandlerStackFactory::factory(null); - - /* @noinspection PhpConditionAlreadyCheckedInspection */ - $this->assertInstanceOf(HandlerStack::class, $handlerStack); - // Note: HandlerStack doesn't have a public hasHandler method, so we can't test this directly - // The important thing is that the handler stack is created without error - } -} diff --git a/tests/OAuthSubscriberFactoryTest.php b/tests/OAuthSubscriberFactoryTest.php deleted file mode 100644 index 4e643f3..0000000 --- a/tests/OAuthSubscriberFactoryTest.php +++ /dev/null @@ -1,35 +0,0 @@ -assertInstanceOf(Oauth1::class, $oauth); - } -} diff --git a/tests/Unit/BundleEdgeCasesTest.php b/tests/Unit/BundleEdgeCasesTest.php new file mode 100644 index 0000000..43b327f --- /dev/null +++ b/tests/Unit/BundleEdgeCasesTest.php @@ -0,0 +1,212 @@ +createContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + // Empty config should work with defaults + $extension->load([], $container); + + $this->assertDefinitionExists($container, 'calliostro_discogs.discogs_client'); + } + + public function testExtensionHandlesNestedEmptyArrays(): void + { + $container = $this->createContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + $config = [[]]; + + $extension->load($config, $container); + + $this->assertDefinitionExists($container, 'calliostro_discogs.discogs_client'); + } + + public function testKernelHandlesCacheDirectoryCreation(): void + { + $kernel = TestKernel::createForFunctional(['user_agent' => 'CacheTest/1.0']); + + // Boot kernel - this should create the cache directory + $kernel->boot(); + + $cacheDir = $kernel->getCacheDir(); + $this->assertDirectoryExists($cacheDir); + + $kernel->cleanupCache(); + + // After cleanup, the cache directory should be removed + $this->assertDirectoryDoesNotExist($cacheDir); + } + + public function testKernelHandlesMultipleBootCalls(): void + { + $kernel = TestKernel::createForFunctional(['user_agent' => 'MultiBootTest/1.0']); + + // First boot + $kernel->boot(); + $container1 = $kernel->getContainer(); + + // Second boot (should not cause issues) + $kernel->boot(); + $container2 = $kernel->getContainer(); + + // Should return the same container + $this->assertSame($container1, $container2); + + $kernel->cleanupCache(); + } + + public function testBundleWithMixedConfiguration(): void + { + // Test with multiple configuration options + $config = [ + 'user_agent' => 'MixedConfig/1.0', + 'consumer_key' => 'test_key_12345678901234567890', + 'consumer_secret' => 'test_secret_12345678901234567890', + ]; + + $kernel = TestKernel::createForFunctional($config); + $kernel->boot(); + $container = $kernel->getContainer(); + + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(\Calliostro\Discogs\DiscogsClient::class, $client); + + $kernel->cleanupCache(); + } + + public function testBundleWithValidLongUserAgent(): void + { + // Use a valid user agent within the 200-character limit (exactly 200 chars) + $longUserAgent = str_repeat('A', 170).'/1.0+https://example.com'; // 170 + 30 = 200 chars + + $config = [ + 'user_agent' => $longUserAgent, + ]; + + $kernel = TestKernel::createForFunctional($config); + $kernel->boot(); + $container = $kernel->getContainer(); + + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(\Calliostro\Discogs\DiscogsClient::class, $client); + + $kernel->cleanupCache(); + } + + public function testBundleWithSpecialCharactersInCredentials(): void + { + $config = [ + 'consumer_key' => 'key_with_!@#$%^&*()_+-={}[]|\\:";\'<>?,./~`_valid_length', + 'consumer_secret' => 'secret_with_special_chars_รครถรผ_๐Ÿš€_valid_length_123', + 'personal_access_token' => 'token_with_unicode_ๆต‹่ฏ•_valid_length_123456789', + ]; + + $kernel = TestKernel::createForFunctional($config); + $kernel->boot(); + $container = $kernel->getContainer(); + + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(\Calliostro\Discogs\DiscogsClient::class, $client); + + $kernel->cleanupCache(); + } + + public function testBundleWithNumericStringCredentials(): void + { + $config = [ + 'consumer_key' => '1234567890abcdef_valid_length', + 'consumer_secret' => '9876543210fedcba_valid_length', + 'personal_access_token' => '555666777888999000_valid_length', + ]; + + $kernel = TestKernel::createForFunctional($config); + $kernel->boot(); + $container = $kernel->getContainer(); + + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(\Calliostro\Discogs\DiscogsClient::class, $client); + + $kernel->cleanupCache(); + } + + public function testMultipleKernelCleanupDoesNotFail(): void + { + $kernel = TestKernel::createForFunctional(['user_agent' => 'CleanupTest/1.0']); + $kernel->boot(); + + // First cleanup + $kernel->cleanupCache(); + + // Second cleanup should not fail + $kernel->cleanupCache(); + + // Third cleanup should not fail + $kernel->cleanupCache(); + + $this->addToAssertionCount(1); // If we get here, no exceptions were thrown + } + + public function testKernelWithNonExistentCacheDirectoryParent(): void + { + $kernel = TestKernel::createForFunctional(['user_agent' => 'DeepCache/1.0']); + $kernel->boot(); + + $cacheDir = $kernel->getCacheDir(); + + // Cache directory should be created even if parent directories don't exist + $this->assertDirectoryExists($cacheDir); + + $kernel->cleanupCache(); + } + + public function testBundleHandlesValidMinimalValues(): void + { + // Use the minimal valid configuration (no credentials = anonymous client) + $config = [ + 'user_agent' => 'TestApp/1.0', + ]; + + $kernel = TestKernel::createForFunctional($config); + $kernel->boot(); + $container = $kernel->getContainer(); + + // Should work with minimal valid config + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(\Calliostro\Discogs\DiscogsClient::class, $client); + + $kernel->cleanupCache(); + } + + public function testExtensionAlias(): void + { + $extension = new CalliostroDiscogsExtension(); + + // Should have the correct alias + $this->assertEquals('calliostro_discogs', $extension->getAlias()); + } + + public function testConfigurationTreeBuilderRootName(): void + { + $extension = new CalliostroDiscogsExtension(); + $config = $extension->getConfiguration([], new ContainerBuilder()); + + $this->assertInstanceOf(\Calliostro\DiscogsBundle\DependencyInjection\Configuration::class, $config); + + $treeBuilder = $config->getConfigTreeBuilder(); + $tree = $treeBuilder->buildTree(); + + $this->assertEquals('calliostro_discogs', $tree->getName()); + } +} diff --git a/tests/Unit/BundleIntegrationTest.php b/tests/Unit/BundleIntegrationTest.php new file mode 100644 index 0000000..a91abfd --- /dev/null +++ b/tests/Unit/BundleIntegrationTest.php @@ -0,0 +1,168 @@ +bootKernelAndGetContainer(); + + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); + } + + public function testBundleWithAllConfigurationOptions(): void + { + $config = [ + 'user_agent' => 'TestBundle/1.0', + 'consumer_key' => 'test_consumer_key', + 'consumer_secret' => 'test_consumer_secret', + 'personal_access_token' => 'test_personal_token', + ]; + + $container = $this->bootKernelAndGetContainer($config); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); + } + + public function testBundleWithAdvancedUserAgent(): void + { + $config = [ + 'user_agent' => 'AdvancedTestApp/2.0 +https://test.example.com', + ]; + $container = $this->bootKernelAndGetContainer($config); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); + } + + public function testBundleWithConsumerCredentialsOnly(): void + { + $config = ['consumer_key' => 'my_consumer_key', 'consumer_secret' => 'my_consumer_secret']; + $container = $this->bootKernelAndGetContainer($config); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); + } + + public function testBundleWithPersonalAccessTokenOnly(): void + { + $config = ['personal_access_token' => 'my_personal_access_token_123']; + $container = $this->bootKernelAndGetContainer($config); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); + } + + public function testBundleWithCustomUserAgent(): void + { + $config = ['user_agent' => 'MyCustomApp/2.1.0 +http://example.com']; + $container = $this->bootKernelAndGetContainer($config); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); + } + + public function testBundleServicesArePrivate(): void + { + $config = ['consumer_key' => 'test', 'consumer_secret' => 'test']; + $container = $this->bootKernelAndGetContainer($config); + + // The main service should be public for injection + $this->assertServiceExists($container, 'calliostro_discogs.discogs_client'); + + // Internal services should not be directly accessible in compiled container + // (This is expected behavior in Symfony - internal services are private) + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); + } + + public function testMultipleBundleInstancesIsolation(): void + { + $kernel1 = TestKernel::createForFunctional([ + 'user_agent' => 'App1/1.0', + ]); + + $kernel2 = TestKernel::createForFunctional([ + 'user_agent' => 'App2/2.0', + ]); + + $kernel1->boot(); + $kernel2->boot(); + + $client1 = $kernel1->getContainer()->get('calliostro_discogs.discogs_client'); + $client2 = $kernel2->getContainer()->get('calliostro_discogs.discogs_client'); + + $this->assertInstanceOf(DiscogsClient::class, $client1); + $this->assertInstanceOf(DiscogsClient::class, $client2); + + // Clients should be different instances + $this->assertNotSame($client1, $client2); + + $kernel1->cleanupCache(); + $kernel2->cleanupCache(); + } + + public function testBundleServiceDefinitionStructure(): void + { + $kernel = TestKernel::createForFunctional([ + 'consumer_key' => 'test_key', + 'consumer_secret' => 'test_secret', + ]); + + $kernel->boot(); + $container = $kernel->getContainer(); + + // Test that we can retrieve the main service + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsClient::class, $client); + + // Test service is singleton (same instance returned) + $client2 = $container->get('calliostro_discogs.discogs_client'); + $this->assertSame($client, $client2); + + $kernel->cleanupCache(); + } + + public function testBundleParameterHandling(): void + { + $config = [ + 'user_agent' => 'ParameterTest/1.0', + 'consumer_key' => 'param_key', + 'consumer_secret' => 'param_secret', + ]; + + $kernel = TestKernel::createForFunctional($config); + $kernel->boot(); + $container = $kernel->getContainer(); + + // Verify the client is created successfully with all parameters + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsClient::class, $client); + + $kernel->cleanupCache(); + } + + public function testBundleEnvironmentSeparation(): void + { + // Test that different environments can have different configurations + $prodConfig = [ + 'user_agent' => 'ProdApp/1.0', + ]; + + $testConfig = [ + 'user_agent' => 'TestApp/1.0', + ]; + + $prodKernel = new TestKernel($prodConfig, 'prod'); + $testKernel = new TestKernel($testConfig, 'test'); + + $prodKernel->boot(); + $testKernel->boot(); + + $prodClient = $prodKernel->getContainer()->get('calliostro_discogs.discogs_client'); + $testClient = $testKernel->getContainer()->get('calliostro_discogs.discogs_client'); + + $this->assertInstanceOf(DiscogsClient::class, $prodClient); + $this->assertInstanceOf(DiscogsClient::class, $testClient); + $this->assertNotSame($prodClient, $testClient); + + $prodKernel->cleanupCache(); + $testKernel->cleanupCache(); + } +} diff --git a/tests/Unit/CalliostroDiscogsBundleTest.php b/tests/Unit/CalliostroDiscogsBundleTest.php new file mode 100644 index 0000000..c5d784c --- /dev/null +++ b/tests/Unit/CalliostroDiscogsBundleTest.php @@ -0,0 +1,56 @@ +getPath(); + + // The bundle path should point to the root directory (parent of src) + $this->assertStringContainsString('discogs-bundle', $path); + $this->assertDirectoryExists($path); + $this->assertFileExists($path.'/src/CalliostroDiscogsBundle.php'); + } + + public function testGetContainerExtensionReturnsValidExtension(): void + { + $bundle = new CalliostroDiscogsBundle(); + $extension = $bundle->getContainerExtension(); + + /* @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(ExtensionInterface::class, $extension); + $this->assertInstanceOf(CalliostroDiscogsExtension::class, $extension); + } + + public function testGetContainerExtensionReturnsSameInstanceOnMultipleCalls(): void + { + $bundle = new CalliostroDiscogsBundle(); + + $extension1 = $bundle->getContainerExtension(); + $extension2 = $bundle->getContainerExtension(); + + // Should return the same instance (lazy initialization) + $this->assertSame($extension1, $extension2); + $this->assertEquals('calliostro_discogs', $extension1->getAlias()); + } + + public function testBundleIsProperSymfonyBundle(): void + { + $bundle = new CalliostroDiscogsBundle(); + + /* @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(CalliostroDiscogsBundle::class, $bundle); + /* @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(\Symfony\Component\HttpKernel\Bundle\Bundle::class, $bundle); + } +} diff --git a/tests/Unit/CalliostroDiscogsExtensionTest.php b/tests/Unit/CalliostroDiscogsExtensionTest.php new file mode 100644 index 0000000..9008e97 --- /dev/null +++ b/tests/Unit/CalliostroDiscogsExtensionTest.php @@ -0,0 +1,225 @@ +createContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + $extension->load([], $container); + + $this->assertDefinitionExists($container, 'calliostro_discogs.discogs_client'); + } + + public function testLoadWithRateLimiter(): void + { + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + + $container = $this->createContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + $config = [ + [ + 'rate_limiter' => 'my_rate_limiter_service', + ], + ]; + + $extension->load($config, $container); + + // Should create rate limiter middleware and handler stack + $this->assertDefinitionExists($container, 'calliostro_discogs.rate_limiter_middleware'); + $this->assertDefinitionExists($container, 'calliostro_discogs.rate_limiter_handler_stack'); + + // Verify the client is configured properly + $this->assertDefinitionExists($container, 'calliostro_discogs.discogs_client'); + } + + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function testLoadWithRateLimiterWhenComponentNotAvailable(): void + { + // This test runs in a separate process where we can mock the class_exists function + // by using PHP's namespace fallback behavior + + // Create a mock function in our namespace that overrides class_exists + eval(' + namespace Calliostro\DiscogsBundle\DependencyInjection; + function class_exists($className) { + if ($className === "Symfony\\\\Component\\\\RateLimiter\\\\RateLimiterFactory") { + return false; // Simulate missing component + } + return \\class_exists($className); + } + '); + + $container = $this->createContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + $config = [ + [ + 'rate_limiter' => 'my_rate_limiter_service', + ], + ]; + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('To use the rate_limiter configuration, you must install symfony/rate-limiter. Run: composer require symfony/rate-limiter'); + + $extension->load($config, $container); + } + + public function testLoadWithConsumerKeyAndSecretOnly(): void + { + $container = $this->createContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + $config = [ + [ + 'consumer_key' => 'test_key', + 'consumer_secret' => 'test_secret', + ], + ]; + + $extension->load($config, $container); + + // Our new architecture always uses the same factory method + $this->assertTrue($container->hasDefinition('calliostro_discogs.client_factory')); + $definition = $container->getDefinition('calliostro_discogs.discogs_client'); + $factory = $definition->getFactory(); + $this->assertEquals('createClient', $factory[1]); + $this->assertDefinitionArgumentCount($container, 'calliostro_discogs.discogs_client', 4); + // Check that consumer credentials are at the correct positions + $this->assertDefinitionArgumentEquals($container, 'calliostro_discogs.discogs_client', 1, 'test_key'); + $this->assertDefinitionArgumentEquals($container, 'calliostro_discogs.discogs_client', 2, 'test_secret'); + } + + public function testLoadWithoutRateLimiter(): void + { + $container = $this->createContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + $config = [[]]; // Empty configuration + + $extension->load($config, $container); + + // Our new architecture always uses the same factory method + $this->assertTrue($container->hasDefinition('calliostro_discogs.client_factory')); + $definition = $container->getDefinition('calliostro_discogs.discogs_client'); + $factory = $definition->getFactory(); + $this->assertEquals('createClient', $factory[1]); + } + + public function testLoadWithPersonalAccessToken(): void + { + $container = $this->createContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + $config = [ + [ + 'personal_access_token' => 'test_token_123', + ], + ]; + + $extension->load($config, $container); + + // Our new architecture always uses the same factory method + $this->assertTrue($container->hasDefinition('calliostro_discogs.client_factory')); + $definition = $container->getDefinition('calliostro_discogs.discogs_client'); + $factory = $definition->getFactory(); + $this->assertEquals('createClient', $factory[1]); + $this->assertDefinitionArgumentCount($container, 'calliostro_discogs.discogs_client', 4); + $this->assertDefinitionArgumentEquals($container, 'calliostro_discogs.discogs_client', 0, 'test_token_123'); + } + + public function testLoadWithCustomUserAgent(): void + { + $container = $this->createContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + $config = [ + [ + 'user_agent' => 'CustomAgent/1.0', + ], + ]; + + $extension->load($config, $container); + + // Our new architecture always uses the same factory method + $this->assertTrue($container->hasDefinition('calliostro_discogs.client_factory')); + $definition = $container->getDefinition('calliostro_discogs.discogs_client'); + $factory = $definition->getFactory(); + $this->assertEquals('createClient', $factory[1]); + $arguments = $definition->getArguments(); + $options = $arguments[3]; // Options are at index 3 + $this->assertIsArray($options); + $this->assertArrayHasKey('headers', $options); + $this->assertEquals('CustomAgent/1.0', $options['headers']['User-Agent']); + } + + public function testLoadWithPersonalAccessTokenAndUserAgent(): void + { + $container = $this->createContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + $config = [ + [ + 'personal_access_token' => 'test_token_123', + 'user_agent' => 'TestApp/1.0', + ], + ]; + + $extension->load($config, $container); + + // Verify our new factory approach + $this->assertTrue($container->hasDefinition('calliostro_discogs.client_factory')); + $this->assertDefinitionArgumentCount($container, 'calliostro_discogs.discogs_client', 4); + $this->assertDefinitionArgumentEquals($container, 'calliostro_discogs.discogs_client', 0, 'test_token_123'); + + $definition = $container->getDefinition('calliostro_discogs.discogs_client'); + $arguments = $definition->getArguments(); + $options = $arguments[3]; // Options are now at index 3 + $this->assertArrayHasKey('headers', $options); + $this->assertEquals('TestApp/1.0', $options['headers']['User-Agent']); + } + + public function testRateLimiterIntegration(): void + { + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + + $container = $this->createContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + $config = [ + [ + 'rate_limiter' => 'my_rate_limiter', + 'personal_access_token' => 'test_token', + ], + ]; + + $extension->load($config, $container); + + // Should create rate limiter services + $this->assertTrue($container->hasDefinition('calliostro_discogs.rate_limiter_handler_stack')); + $this->assertTrue($container->hasDefinition('calliostro_discogs.rate_limiter_middleware')); + + $definition = $container->getDefinition('calliostro_discogs.discogs_client'); + $arguments = $definition->getArguments(); + $options = $arguments[3] ?? []; // Fourth argument is now the options array + + // Should have handler option pointing to rate limiter stack + $this->assertArrayHasKey('handler', $options); + } +} diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php new file mode 100644 index 0000000..57383e3 --- /dev/null +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,159 @@ +processor->processConfiguration($this->configuration, $configs); + + $this->assertArrayNotHasKey('personal_access_token', $config); + $this->assertArrayNotHasKey('consumer_key', $config); + $this->assertArrayNotHasKey('consumer_secret', $config); + $this->assertNull($config['user_agent']); + $this->assertNull($config['rate_limiter']); + } + + public function testConfigurationWithUserAgent(): void + { + $configs = [ + [ + 'user_agent' => 'MyApp/1.0', + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + $this->assertEquals('MyApp/1.0', $config['user_agent']); + $this->assertArrayNotHasKey('throttle', $config); + } + + public function testConfigurationWithConsumerCredentials(): void + { + $configs = [ + [ + 'consumer_key' => 'test_key', + 'consumer_secret' => 'test_secret', + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + $this->assertEquals('test_key', $config['consumer_key']); + $this->assertEquals('test_secret', $config['consumer_secret']); + $this->assertArrayNotHasKey('personal_access_token', $config); + } + + public function testConfigurationWithPersonalAccessToken(): void + { + $configs = [ + [ + 'personal_access_token' => 'my_token_123', + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + $this->assertEquals('my_token_123', $config['personal_access_token']); + $this->assertArrayNotHasKey('consumer_key', $config); + $this->assertArrayNotHasKey('consumer_secret', $config); + } + + public function testRateLimiterBasicConfiguration(): void + { + $configs = [ + [ + 'rate_limiter' => 'my_rate_limiter_service', + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + $this->assertEquals('my_rate_limiter_service', $config['rate_limiter']); + $this->assertArrayNotHasKey('throttle', $config); + } + + public function testCompleteConfiguration(): void + { + $configs = [ + [ + 'user_agent' => 'TestApp/2.0', + 'consumer_key' => 'valid_consumer_key_12345', + 'consumer_secret' => 'valid_consumer_secret_12345', + 'personal_access_token' => 'BillieEilishToken2024_123456789', + 'rate_limiter' => 'my_rate_limiter', + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + $this->assertEquals('TestApp/2.0', $config['user_agent']); + $this->assertEquals('valid_consumer_key_12345', $config['consumer_key']); + $this->assertEquals('valid_consumer_secret_12345', $config['consumer_secret']); + $this->assertEquals('BillieEilishToken2024_123456789', $config['personal_access_token']); + $this->assertEquals('my_rate_limiter', $config['rate_limiter']); + } + + public function testMultipleConfigurationMerging(): void + { + $configs = [ + [ + 'user_agent' => 'FirstApp/1.0', + 'consumer_key' => 'first_key', + ], + [ + 'user_agent' => 'SecondApp/2.0', + 'consumer_secret' => 'second_secret', + 'rate_limiter' => 'my_rate_limiter', + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + // Second config should override first where present + $this->assertEquals('SecondApp/2.0', $config['user_agent']); + $this->assertEquals('first_key', $config['consumer_key']); // From first config + $this->assertEquals('second_secret', $config['consumer_secret']); // From second config + $this->assertEquals('my_rate_limiter', $config['rate_limiter']); // From second config + } + + public function testRateLimiterConfiguration(): void + { + $configs = [ + [ + 'rate_limiter' => 'my_rate_limiter_factory', + 'personal_access_token' => 'token123456789', // Must be at least 10 chars + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + $this->assertEquals('my_rate_limiter_factory', $config['rate_limiter']); + $this->assertEquals('token123456789', $config['personal_access_token']); + } + + public function testTreeBuilderReturnsCorrectRootName(): void + { + $treeBuilder = $this->configuration->getConfigTreeBuilder(); + + $this->assertEquals('calliostro_discogs', $treeBuilder->buildTree()->getName()); + } + + protected function setUp(): void + { + $this->configuration = new Configuration(); + $this->processor = new Processor(); + } +} diff --git a/tests/Unit/DependencyInjection/ConfigurationValidationTest.php b/tests/Unit/DependencyInjection/ConfigurationValidationTest.php new file mode 100644 index 0000000..026f161 --- /dev/null +++ b/tests/Unit/DependencyInjection/ConfigurationValidationTest.php @@ -0,0 +1,146 @@ + '', + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + $this->assertEquals('', $config['personal_access_token']); + } + + public function testWhitespaceOnlyPersonalAccessTokenNowAllowed(): void + { + // This should now pass (no longer fails at compile time) + $configs = [ + [ + 'personal_access_token' => ' ', + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + $this->assertEquals(' ', $config['personal_access_token']); + } + + public function testShortPersonalAccessTokenNowAllowed(): void + { + // This should now pass (validation moved to runtime) + $configs = [ + [ + 'personal_access_token' => 'short', + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + $this->assertEquals('short', $config['personal_access_token']); + } + + public function testEmptyConsumerKeyNowAllowed(): void + { + // This should now pass (no longer fails at compile time) + $configs = [ + [ + 'consumer_key' => '', + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + $this->assertEquals('', $config['consumer_key']); + } + + public function testEmptyConsumerSecretNowAllowed(): void + { + // This should now pass (no longer fails at compile time) + $configs = [ + [ + 'consumer_secret' => '', + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + $this->assertEquals('', $config['consumer_secret']); + } + + public function testTooLongUserAgentFails(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('User-Agent cannot be longer than 200 characters'); + + $configs = [ + [ + 'user_agent' => str_repeat('A', 201), + ], + ]; + + $this->processor->processConfiguration($this->configuration, $configs); + } + + public function testValidConfiguration(): void + { + $configs = [ + [ + 'personal_access_token' => 'BillieEilishFan2024Token123456789', + 'user_agent' => 'MyMusicApp/2.0 +https://example.com', + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + $this->assertEquals('BillieEilishFan2024Token123456789', $config['personal_access_token']); + $this->assertEquals('MyMusicApp/2.0 +https://example.com', $config['user_agent']); + } + + public function testEnvironmentVariableSyntaxAllowed(): void + { + // Test that environment variable syntax is now allowed + $configs = [ + [ + 'personal_access_token' => '%env(DISCOGS_PERSONAL_ACCESS_TOKEN)%', + 'consumer_key' => '%env(DISCOGS_CONSUMER_KEY)%', + 'consumer_secret' => '%env(DISCOGS_CONSUMER_SECRET)%', + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + $this->assertEquals('%env(DISCOGS_PERSONAL_ACCESS_TOKEN)%', $config['personal_access_token']); + $this->assertEquals('%env(DISCOGS_CONSUMER_KEY)%', $config['consumer_key']); + $this->assertEquals('%env(DISCOGS_CONSUMER_SECRET)%', $config['consumer_secret']); + } + + public function testArrayAsScalarValue(): void + { + $this->expectException(InvalidConfigurationException::class); + + $configs = [ + [ + 'personal_access_token' => ['invalid' => 'array'], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $configs); + } + + protected function setUp(): void + { + $this->configuration = new Configuration(); + $this->processor = new Processor(); + } +} diff --git a/tests/Unit/DependencyInjection/RuntimeValidationTest.php b/tests/Unit/DependencyInjection/RuntimeValidationTest.php new file mode 100644 index 0000000..3704fd1 --- /dev/null +++ b/tests/Unit/DependencyInjection/RuntimeValidationTest.php @@ -0,0 +1,102 @@ +factory->createClient('valid_token_123456', null, null, []); + $this->assertInstanceOf('Calliostro\\Discogs\\DiscogsClient', $client); + } + + public function testShortPersonalAccessTokenThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Personal access token must be at least 10 characters long'); + + $this->factory->createClient('short', null, null, []); + } + + public function testValidConsumerCredentialsCreateClient(): void + { + $client = $this->factory->createClient(null, 'consumer_key', 'consumer_secret', []); + $this->assertInstanceOf('Calliostro\\Discogs\\DiscogsClient', $client); + } + + public function testPartialConsumerCredentialsThrowException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Incomplete OAuth credentials provided'); + + $this->factory->createClient(null, 'consumer_key', null, []); + } + + public function testPartialConsumerCredentialsWithSecretOnlyThrowException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Incomplete OAuth credentials provided'); + + $this->factory->createClient(null, null, 'consumer_secret', []); + } + + public function testAnonymousClientCreation(): void + { + $client = $this->factory->createClient(null, null, null, []); + $this->assertInstanceOf('Calliostro\\Discogs\\DiscogsClient', $client); + } + + public function testEmptyStringsTreatedAsNull(): void + { + $client = $this->factory->createClient('', '', '', []); + $this->assertInstanceOf('Calliostro\\Discogs\\DiscogsClient', $client); + } + + public function testWhitespaceOnlyStringsTreatedAsEmpty(): void + { + $client = $this->factory->createClient(' ', ' ', ' ', []); + $this->assertInstanceOf('Calliostro\\Discogs\\DiscogsClient', $client); + } + + public function testPersonalAccessTokenTakesPrecedenceOverConsumerCredentials(): void + { + // When both are provided, personal access token should be used + $client = $this->factory->createClient('valid_token_123456', 'consumer_key', 'consumer_secret', []); + $this->assertInstanceOf('Calliostro\\Discogs\\DiscogsClient', $client); + } + + public function testShortPersonalAccessTokenWithValidConsumerCredentialsStillFails(): void + { + // Personal access token validation should happen first and fail + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Personal access token must be at least 10 characters long'); + + $this->factory->createClient('short', 'consumer_key', 'consumer_secret', []); + } + + public function testExceptionContainsHelpfulInstructions(): void + { + try { + $this->factory->createClient('short', null, null, []); + $this->fail('Expected exception was not thrown'); + } catch (\InvalidArgumentException $e) { + $message = $e->getMessage(); + $this->assertStringContainsString('Personal access token must be at least 10 characters long', $message); + $this->assertStringContainsString('To configure Discogs API credentials:', $message); + $this->assertStringContainsString('https://www.discogs.com/settings/developers', $message); + $this->assertStringContainsString('%env(DISCOGS_PERSONAL_ACCESS_TOKEN)%', $message); + } + } + + protected function setUp(): void + { + $this->factory = new DiscogsClientFactory(); + } +} diff --git a/tests/Unit/DiscogsClientMockTest.php b/tests/Unit/DiscogsClientMockTest.php new file mode 100644 index 0000000..7d99ae9 --- /dev/null +++ b/tests/Unit/DiscogsClientMockTest.php @@ -0,0 +1,199 @@ + 4470662, + 'name' => 'Billie Eilish', + 'profile' => 'American singer-songwriter born in 2001', + 'images' => [], + ]; + + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'application/json'], json_encode($expectedResponse) ?: '{}') + ); + + $result = $this->client->getArtist(artistId: 1); + + $this->assertEquals($expectedResponse, $result); + } + + public function testGetArtistNotFound(): void + { + $this->mockHandler->append( + new Response(404, ['Content-Type' => 'application/json'], '{"message": "Artist not found."}') + ); + + $this->expectException(\GuzzleHttp\Exception\ClientException::class); + + $this->client->getArtist(artistId: 999999999); + } + + public function testSearchWithResults(): void + { + $expectedResponse = [ + 'results' => [ + [ + 'id' => 30359313, + 'title' => 'Billie Eilish - Happier Than Ever', + 'type' => 'release', + ], + ], + 'pagination' => [ + 'page' => 1, + 'per_page' => 50, + 'items' => 1, + ], + ]; + + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'application/json'], json_encode($expectedResponse) ?: '{}') + ); + + $result = $this->client->search(q: 'Billie Eilish', type: 'release'); + + $this->assertEquals($expectedResponse, $result); + $this->assertCount(1, $result['results']); + } + + public function testSearchWithNoResults(): void + { + $expectedResponse = [ + 'results' => [], + 'pagination' => [ + 'page' => 1, + 'per_page' => 50, + 'items' => 0, + ], + ]; + + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'application/json'], json_encode($expectedResponse) ?: '{}') + ); + + $result = $this->client->search(q: 'NonexistentModernArtist2024'); + + $this->assertEquals($expectedResponse, $result); + $this->assertEmpty($result['results']); + } + + public function testRateLimitHandling(): void + { + // Mock a rate limit response + $this->mockHandler->append( + new Response(429, ['Retry-After' => '1'], '{"message": "You are making requests too quickly."}') + ); + + // Without rate limiter middleware, the client gets the 429 response directly + $client = new Client(['handler' => $this->handlerStack, 'http_errors' => false]); + $apiClient = new DiscogsClient($client); + + // This test verifies that rate limit responses are handled properly + $result = $apiClient->getArtist(artistId: '1'); + + // The client should return the 429 rate limit response + $this->assertEquals(['message' => 'You are making requests too quickly.'], $result); + } + + public function testMultipleRequestsWithMocking(): void + { + $responses = [ + ['id' => 4470662, 'name' => 'Billie Eilish'], + ['id' => 1039492, 'name' => 'Taylor Swift'], + ['id' => 2727177, 'name' => 'The Weeknd'], + ]; + + foreach ($responses as $response) { + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'application/json'], json_encode($response) ?: '{}') + ); + } + + $results = []; + for ($i = 1; $i <= 3; ++$i) { + $results[] = $this->client->getArtist(artistId: $i); + } + + $this->assertCount(3, $results); + $this->assertEquals('Billie Eilish', $results[0]['name']); + $this->assertEquals('Taylor Swift', $results[1]['name']); + $this->assertEquals('The Weeknd', $results[2]['name']); + } + + public function testInvalidJsonResponse(): void + { + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'application/json'], 'invalid json{') + ); + + // The actual exception thrown by the Discogs client is RuntimeException, not JsonException + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid JSON response'); + + $this->client->getArtist(artistId: 1); + } + + public function testServerError(): void + { + $this->mockHandler->append( + new Response(500, ['Content-Type' => 'application/json'], '{"message": "Internal Server Error"}') + ); + + $this->expectException(\GuzzleHttp\Exception\ServerException::class); + + $this->client->getArtist(artistId: 1); + } + + public function testClientWithCustomHeaders(): void + { + $customHeaders = [ + 'User-Agent' => 'MyTestApp/1.0', + 'Authorization' => 'Discogs token=test_token', + ]; + + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'application/json'], '{"id": 1, "name": "Test"}') + ); + + $customClient = new Client([ + 'handler' => $this->handlerStack, + 'headers' => $customHeaders, + ]); + + $apiClient = new DiscogsClient($customClient); + $result = $apiClient->getArtist(artistId: 1); + + $this->assertEquals(['id' => 1, 'name' => 'Test'], $result); + + // Verify the request was made (check last request) + $lastRequest = $this->mockHandler->getLastRequest(); + $this->assertNotNull($lastRequest); + } + + protected function setUp(): void + { + $this->mockHandler = new MockHandler(); + $this->handlerStack = HandlerStack::create($this->mockHandler); + $this->httpClient = new Client(['handler' => $this->handlerStack]); + + // Use reflection to create a DiscogsClient with our mocked HTTP client + $this->client = new DiscogsClient($this->httpClient); + } +} diff --git a/tests/Unit/FunctionalTest.php b/tests/Unit/FunctionalTest.php new file mode 100644 index 0000000..11ac823 --- /dev/null +++ b/tests/Unit/FunctionalTest.php @@ -0,0 +1,49 @@ +bootKernelAndGetContainer(); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); + } + + public function testServiceWiringWithConfiguration(): void + { + $container = $this->bootKernelAndGetContainer(['user_agent' => 'test']); + + $discogsClient = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsClient::class, $discogsClient); + + // Verify that the client is properly configured + // The user agent configuration is handled internally by the bundle + /* @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $discogsClient); + } + + public function testServiceWiringWithMinimalConfig(): void + { + $config = []; + $container = $this->bootKernelAndGetContainer($config); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); + } + + public function testServiceWiringWithConsumerCredentials(): void + { + $config = ['consumer_key' => 'test_key', 'consumer_secret' => 'test_secret']; + $container = $this->bootKernelAndGetContainer($config); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); + } + + public function testServiceWiringWithPersonalAccessToken(): void + { + $container = $this->bootKernelAndGetContainer(['personal_access_token' => 'test_token_123']); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); + } +} diff --git a/tests/Unit/Middleware/RateLimiterMiddlewareTest.php b/tests/Unit/Middleware/RateLimiterMiddlewareTest.php new file mode 100644 index 0000000..a429dc7 --- /dev/null +++ b/tests/Unit/Middleware/RateLimiterMiddlewareTest.php @@ -0,0 +1,371 @@ +markTestSkipped('symfony/rate-limiter is not installed'); + } + + // Create a real rate limiter factory with generous limits for testing + $factory = new RateLimiterFactory([ + 'id' => 'test_limiter', + 'policy' => 'sliding_window', + 'limit' => 10, + 'interval' => '10 seconds', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'test_limiter'); + + // Create handler stack + $mockHandler = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], '{"success": true}'), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $handlerStack->push($middleware); + + $client = new Client(['handler' => $handlerStack]); + $response = $client->get('https://api.discogs.com/'); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('{"success": true}', $response->getBody()->getContents()); + } + + public function testMiddlewareConstructorWithDefaults(): void + { + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + + $factory = new RateLimiterFactory([ + 'id' => 'test_limiter', + 'policy' => 'sliding_window', + 'limit' => 5, + 'interval' => '1 second', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory); + + $this->assertInstanceOf(RateLimiterMiddleware::class, $middleware); + } + + public function testMiddlewareConstructorWithCustomKey(): void + { + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + + $factory = new RateLimiterFactory([ + 'id' => 'custom_limiter', + 'policy' => 'sliding_window', + 'limit' => 5, + 'interval' => '1 second', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'custom_key'); + + $this->assertInstanceOf(RateLimiterMiddleware::class, $middleware); + } + + public function testMiddlewareIsCallable(): void + { + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + + $factory = new RateLimiterFactory([ + 'id' => 'test_limiter', + 'policy' => 'sliding_window', + 'limit' => 10, + 'interval' => '10 seconds', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'test_limiter'); + + // Test that the middleware is callable + $this->assertTrue(\is_callable($middleware)); + + // Test that calling it returns another callable + $handler = function () { return 'test'; }; + $wrappedHandler = $middleware($handler); + + $this->assertTrue(\is_callable($wrappedHandler)); + } + + public function testMiddlewareHandlesRateLimitExceeded(): void + { + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + + // Create a rate limiter with very strict limits + $factory = new RateLimiterFactory([ + 'id' => 'strict_limiter', + 'policy' => 'fixed_window', + 'limit' => 1, + 'interval' => '1 second', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'strict_limiter'); + + // Create mock responses + $mockHandler = new MockHandler([ + new Response(200, [], '{"first": true}'), + new Response(200, [], '{"second": true}'), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $handlerStack->push($middleware); + + $client = new Client(['handler' => $handlerStack]); + + // First request should go through immediately + $response1 = $client->get('https://api.discogs.com/'); + $this->assertEquals('{"first": true}', $response1->getBody()->getContents()); + + // Second request should be delayed but eventually go through + $startTime = microtime(true); + $response2 = $client->get('https://api.discogs.com/'); + $endTime = microtime(true); + + $this->assertEquals('{"second": true}', $response2->getBody()->getContents()); + + // Should have taken some time due to rate limiting (at least a few microseconds of delay) + $this->assertGreaterThanOrEqual(0, $endTime - $startTime); + } + + public function testMiddlewareWithMultipleRequests(): void + { + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + + // Create a rate limiter with reasonable limits + $factory = new RateLimiterFactory([ + 'id' => 'multi_limiter', + 'policy' => 'sliding_window', + 'limit' => 3, + 'interval' => '1 second', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'multi_limiter'); + + $mockHandler = new MockHandler([ + new Response(200, [], '{"request": 1}'), + new Response(200, [], '{"request": 2}'), + new Response(200, [], '{"request": 3}'), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $handlerStack->push($middleware); + + $client = new Client(['handler' => $handlerStack]); + + // All three requests should go through within the limit + $response1 = $client->get('https://api.discogs.com/'); + $response2 = $client->get('https://api.discogs.com/'); + $response3 = $client->get('https://api.discogs.com/'); + + $this->assertEquals('{"request": 1}', $response1->getBody()->getContents()); + $this->assertEquals('{"request": 2}', $response2->getBody()->getContents()); + $this->assertEquals('{"request": 3}', $response3->getBody()->getContents()); + } + + public function testMiddlewareWithTokenBucketPolicy(): void + { + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + + // Test with token bucket policy + $factory = new RateLimiterFactory([ + 'id' => 'token_bucket_limiter', + 'policy' => 'token_bucket', + 'limit' => 5, + 'rate' => ['interval' => '1 second', 'amount' => 2], + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'token_bucket_limiter'); + + $mockHandler = new MockHandler([ + new Response(200, [], '{"token_bucket": true}'), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $handlerStack->push($middleware); + + $client = new Client(['handler' => $handlerStack]); + $response = $client->get('https://api.discogs.com/'); + + $this->assertEquals('{"token_bucket": true}', $response->getBody()->getContents()); + } + + public function testMiddlewareWithFixedWindowPolicy(): void + { + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + + // Test with fixed window policy + $factory = new RateLimiterFactory([ + 'id' => 'fixed_window_limiter', + 'policy' => 'fixed_window', + 'limit' => 10, + 'interval' => '1 minute', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'fixed_window_limiter'); + + $mockHandler = new MockHandler([ + new Response(200, [], '{"fixed_window": true}'), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $handlerStack->push($middleware); + + $client = new Client(['handler' => $handlerStack]); + $response = $client->get('https://api.discogs.com/'); + + $this->assertEquals('{"fixed_window": true}', $response->getBody()->getContents()); + } + + public function testMiddlewareWithZeroWaitTime(): void + { + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + + // Create a rate limiter that will be hit, but with a wait time that should be 0 or negative + $factory = new RateLimiterFactory([ + 'id' => 'zero_wait_limiter', + 'policy' => 'fixed_window', + 'limit' => 1, + 'interval' => '1 second', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'zero_wait_limiter'); + + $mockHandler = new MockHandler([ + new Response(200, [], '{"first": true}'), + new Response(200, [], '{"second": true}'), + ]); + $handlerStack = HandlerStack::create($mockHandler); + $handlerStack->push($middleware); + + $client = new Client(['handler' => $handlerStack]); + + // First request + $response1 = $client->get('https://api.discogs.com/'); + $this->assertEquals('{"first": true}', $response1->getBody()->getContents()); + + // Wait for the window to reset, then make second request + sleep(1); + $response2 = $client->get('https://api.discogs.com/'); + $this->assertEquals('{"second": true}', $response2->getBody()->getContents()); + } + + public function testMiddlewarePreservesRequestAndOptions(): void + { + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + + $factory = new RateLimiterFactory([ + 'id' => 'preserve_test_limiter', + 'policy' => 'sliding_window', + 'limit' => 10, + 'interval' => '10 seconds', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'preserve_test_limiter'); + + // Test that the middleware preserves request and options + $calledHandler = null; + $wrappedHandler = $middleware(function ($request, $options) use (&$calledHandler) { + $calledHandler = [$request, $options]; + + return \GuzzleHttp\Promise\Create::promiseFor(new Response(200)); + }); + + $request = new \GuzzleHttp\Psr7\Request('GET', 'https://api.discogs.com/'); + $options = ['timeout' => 30, 'custom' => 'value']; + + $promise = $wrappedHandler($request, $options); + $promise->wait(); + + $this->assertNotNull($calledHandler); + $this->assertSame($request, $calledHandler[0]); + $this->assertSame($options, $calledHandler[1]); + } + + public function testMiddlewareReturnsPromise(): void + { + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + + $factory = new RateLimiterFactory([ + 'id' => 'promise_test_limiter', + 'policy' => 'sliding_window', + 'limit' => 10, + 'interval' => '10 seconds', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'promise_test_limiter'); + + $wrappedHandler = $middleware(function () { + return \GuzzleHttp\Promise\Create::promiseFor(new Response(200, [], '{"promise": true}')); + }); + + $request = new \GuzzleHttp\Psr7\Request('GET', 'https://api.discogs.com/'); + $promise = $wrappedHandler($request, []); + + $this->assertInstanceOf(\GuzzleHttp\Promise\PromiseInterface::class, $promise); + + $response = $promise->wait(); + $this->assertEquals('{"promise": true}', $response->getBody()->getContents()); + } + + public function testMiddlewareHandlesPromiseRejection(): void + { + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + + $factory = new RateLimiterFactory([ + 'id' => 'rejection_test_limiter', + 'policy' => 'sliding_window', + 'limit' => 10, + 'interval' => '10 seconds', + ], new InMemoryStorage()); + + $middleware = new RateLimiterMiddleware($factory, 'rejection_test_limiter'); + + $expectedException = new \Exception('Handler failed'); + $wrappedHandler = $middleware(function () use ($expectedException) { + return \GuzzleHttp\Promise\Create::rejectionFor($expectedException); + }); + + $request = new \GuzzleHttp\Psr7\Request('GET', 'https://api.discogs.com/'); + $promise = $wrappedHandler($request, []); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Handler failed'); + $promise->wait(); + } +} diff --git a/tests/MockOAuthToken.php b/tests/Unit/MockOAuthToken.php similarity index 90% rename from tests/MockOAuthToken.php rename to tests/Unit/MockOAuthToken.php index 94b5137..2cfa786 100644 --- a/tests/MockOAuthToken.php +++ b/tests/Unit/MockOAuthToken.php @@ -1,6 +1,6 @@ $rawTokenData */ - public function __construct(private array $rawTokenData = []) + public function __construct(private readonly array $rawTokenData = []) { } diff --git a/tests/Unit/UnitTestCase.php b/tests/Unit/UnitTestCase.php new file mode 100644 index 0000000..053a03e --- /dev/null +++ b/tests/Unit/UnitTestCase.php @@ -0,0 +1,130 @@ +assertServiceExists($container, $serviceId); + $service = $container->get($serviceId); + $this->assertInstanceOf($expectedClass, $service); + } + + /** + * Assert that a service exists in the container. + */ + protected function assertServiceExists(ContainerInterface $container, string $serviceId): void + { + $this->assertTrue($container->has($serviceId), "Service '{$serviceId}' should exist in container"); + } + + /** + * Assert container definition has expected factory. + * + * @param array $expectedFactory + */ + protected function assertDefinitionHasFactory(ContainerBuilder $container, string $serviceId, array $expectedFactory): void + { + $this->assertDefinitionExists($container, $serviceId); + $definition = $container->getDefinition($serviceId); + $this->assertEquals($expectedFactory, $definition->getFactory()); + } + + /** + * Assert container definition exists in a build-time container. + */ + protected function assertDefinitionExists(ContainerBuilder $container, string $serviceId): void + { + $this->assertTrue($container->hasDefinition($serviceId), "Definition '{$serviceId}' should exist in container"); + } + + /** + * Assert definition has the expected number of arguments. + */ + protected function assertDefinitionArgumentCount(ContainerBuilder $container, string $serviceId, int $expectedCount): void + { + $this->assertDefinitionExists($container, $serviceId); + $definition = $container->getDefinition($serviceId); + $this->assertCount($expectedCount, $definition->getArguments()); + } + + /** + * Assert definition argument has expected value. + */ + protected function assertDefinitionArgumentEquals(ContainerBuilder $container, string $serviceId, int $argumentIndex, $expectedValue): void + { + $this->assertDefinitionExists($container, $serviceId); + $definition = $container->getDefinition($serviceId); + $arguments = $definition->getArguments(); + $this->assertArrayHasKey($argumentIndex, $arguments); + $this->assertEquals($expectedValue, $arguments[$argumentIndex]); + } + + /** + * Boot a kernel and return its container, with automatic cleanup. + * + * @param array $config + */ + protected function bootKernelAndGetContainer(array $config = []): ContainerInterface + { + $kernel = $this->createTestKernel($config); + $kernel->boot(); + $this->kernels[] = $kernel; + + return $kernel->getContainer(); + } + + /** + * Create a test kernel with the given configuration. + * + * @param array $config + */ + protected function createTestKernel(array $config = []): TestKernel + { + return TestKernel::createForFunctional($config); + } + + /** + * Cleanup kernel after test. + */ + protected function cleanupKernel(TestKernel $kernel): void + { + $kernel->cleanupCache(); + } + + protected function tearDown(): void + { + foreach ($this->kernels as $kernel) { + $kernel->cleanupCache(); + } + $this->kernels = []; + parent::tearDown(); + } +}