From 09031f49ab866220c5e67ba67dd9c4e036fd6e31 Mon Sep 17 00:00:00 2001 From: calliostro Date: Fri, 12 Sep 2025 11:50:53 +0200 Subject: [PATCH 01/34] Update to v4.0.0-beta.1 with breaking changes - Upgrade to calliostro/php-discogs-api v4.0.0-beta.1 (breaking changes) - Implement Symfony 6.4+ configuration with modern best practices - Add comprehensive integration test system with real API testing - Modernize bundle configuration validation and error handling - Clean up README structure and remove outdated upgrade notices - Complete rewrite for PHP 8.1+ and modern Symfony ecosystem --- .github/phpstan/generic-baseline.neon | 5 - .github/workflows/ci.yml | 14 +- CHANGELOG.md | 42 ++- INTEGRATION_TESTS.md | 160 +++++++++++ README.md | 262 +++++++++++------ UPGRADE.md | 264 +++++++++++++++--- composer.json | 21 +- phpstan-baseline.neon | 5 - phpstan-symfony74.neon | 8 - phpstan/generic-baseline.neon | 5 +- phpunit.xml.dist | 8 +- src/CalliostroDiscogsBundle.php | 21 ++ .../CalliostroDiscogsExtension.php | 87 +++--- src/DependencyInjection/Configuration.php | 77 +++-- src/HWIOauthTokenProvider.php | 38 --- src/OAuthHandlerStackFactory.php | 20 -- src/OAuthSubscriberFactory.php | 21 -- src/OAuthTokenProviderInterface.php | 10 - src/Resources/config/oauth.xml | 22 -- src/Resources/config/services.xml | 17 +- src/ThrottleHandlerStackFactory.php | 32 ++- tests/Fixtures/TestKernel.php | 133 +++++++++ tests/FunctionalTest.php | 81 ------ tests/HWIOauthTokenProviderTest.php | 68 ----- .../AuthenticatedApiIntegrationTest.php | 211 ++++++++++++++ tests/Integration/IntegrationTestCase.php | 80 ++++++ .../Integration/PublicApiIntegrationTest.php | 146 ++++++++++ tests/OAuthHandlerStackFactoryTest.php | 38 --- tests/OAuthSubscriberFactoryTest.php | 35 --- tests/Unit/BundleEdgeCasesTest.php | 239 ++++++++++++++++ tests/Unit/BundleIntegrationTest.php | 247 ++++++++++++++++ .../CalliostroDiscogsExtensionTest.php | 98 +++++-- .../DependencyInjection/ConfigurationTest.php | 193 +++++++++++++ .../ConfigurationValidationTest.php | 215 ++++++++++++++ tests/Unit/DiscogsApiClientMockTest.php | 203 ++++++++++++++ tests/Unit/FunctionalTest.php | 105 +++++++ tests/{ => Unit}/MockOAuthToken.php | 4 +- .../Unit/ThrottleHandlerStackFactoryTest.php | 102 +++++++ 38 files changed, 2722 insertions(+), 615 deletions(-) delete mode 100644 .github/phpstan/generic-baseline.neon create mode 100644 INTEGRATION_TESTS.md delete mode 100644 phpstan-baseline.neon delete mode 100644 phpstan-symfony74.neon delete mode 100644 src/HWIOauthTokenProvider.php delete mode 100644 src/OAuthHandlerStackFactory.php delete mode 100644 src/OAuthSubscriberFactory.php delete mode 100644 src/OAuthTokenProviderInterface.php delete mode 100644 src/Resources/config/oauth.xml create mode 100644 tests/Fixtures/TestKernel.php delete mode 100644 tests/FunctionalTest.php delete mode 100644 tests/HWIOauthTokenProviderTest.php create mode 100644 tests/Integration/AuthenticatedApiIntegrationTest.php create mode 100644 tests/Integration/IntegrationTestCase.php create mode 100644 tests/Integration/PublicApiIntegrationTest.php delete mode 100644 tests/OAuthHandlerStackFactoryTest.php delete mode 100644 tests/OAuthSubscriberFactoryTest.php create mode 100644 tests/Unit/BundleEdgeCasesTest.php create mode 100644 tests/Unit/BundleIntegrationTest.php rename tests/{ => Unit}/CalliostroDiscogsExtensionTest.php (52%) create mode 100644 tests/Unit/DependencyInjection/ConfigurationTest.php create mode 100644 tests/Unit/DependencyInjection/ConfigurationValidationTest.php create mode 100644 tests/Unit/DiscogsApiClientMockTest.php create mode 100644 tests/Unit/FunctionalTest.php rename tests/{ => Unit}/MockOAuthToken.php (93%) create mode 100644 tests/Unit/ThrottleHandlerStackFactoryTest.php 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..41c0017 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: @@ -31,7 +31,7 @@ jobs: allowed-to-fail: false - php: '8.4' allowed-to-fail: false - + # Symfony 6.4 LTS compatibility - php: '8.2' symfony-version: '^6.4' @@ -42,7 +42,7 @@ jobs: - php: '8.4' symfony-version: '^6.4' allowed-to-fail: false - + # Symfony 7.x current versions - php: '8.2' symfony-version: '^7.0' @@ -164,6 +164,14 @@ jobs: - name: Run tests run: ./vendor/bin/simple-phpunit -v + - name: Run integration tests (optional) + env: + DISCOGS_CONSUMER_KEY: ${{ secrets.DISCOGS_CONSUMER_KEY }} + DISCOGS_CONSUMER_SECRET: ${{ secrets.DISCOGS_CONSUMER_SECRET }} + DISCOGS_PERSONAL_ACCESS_TOKEN: ${{ secrets.DISCOGS_PERSONAL_ACCESS_TOKEN }} + run: composer test-integration + continue-on-error: true + code-quality: runs-on: ubuntu-latest name: Code Quality Checks diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b997a6..1c672ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ 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-beta.1] โ€“ 2025-09-12 + +### ๐Ÿš€ 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 + +- **Modern PHP 8.1+ Architecture** with full type safety and modern features +- **Personal Access Token Support** for simple authentication +- **Built-in OAuth 1.0a** with no external dependencies +- **All 60 Discogs API Methods** with consistent verb-first naming +- **Symfony 6.4 | 7.x | 8.x Support** with future compatibility +- **Zero Configuration Mode** for public API access +- **Comprehensive Test Suite** with unit and integration tests +- **Modern Bundle Structure** following all Symfony best practices +- **Professional Documentation** with clear examples and setup guides +- **Robust Configuration Validation** with meaningful error messages +- **Modern Music References** throughout documentation and examples + +### Changed + +- **Complete API Integration** now based on `calliostro/php-discogs-api` v4.0.0-beta.1 +- **Service Naming** follows modern Symfony conventions with proper aliases +- **Configuration Structure** simplified and more intuitive +- **Method Names** use consistent verb-first patterns (e.g., `listArtistReleases()`) +- **Code Standards** fully compliant with @Symfony and @Symfony:risky rules +- **Error Handling** improved with better exceptions and validation +- **Performance** optimized for modern PHP versions + +### Removed + +- **Legacy Dependencies** โ€“ No more Guzzle Services or external OAuth libraries +- **Backward Compatibility** โ€“ This is a fresh start, not an upgrade +- **Complex Configuration** โ€“ Simplified to essential options only + +--- + +## 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 +59,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 --- diff --git a/INTEGRATION_TESTS.md b/INTEGRATION_TESTS.md new file mode 100644 index 0000000..73e6615 --- /dev/null +++ b/INTEGRATION_TESTS.md @@ -0,0 +1,160 @@ +# Integration Test Setup + +## Test Strategy + +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) + +## Running Tests + +```bash +# Unit tests only (CI default - fast & reliable) +composer test + +# Integration tests only (manual - requires API access) +composer test-integration + +# All tests together (local development) +composer test-all +``` + +## 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: + +Navigate to: **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) | +| `DISCOGS_OAUTH_TOKEN` | OAuth access token (optional) | [OAuth Flow](https://www.discogs.com/developers/#page:authentication,header:authentication-oauth-flow) | +| `DISCOGS_OAUTH_TOKEN_SECRET` | OAuth token secret (optional) | [OAuth Flow](https://www.discogs.com/developers/#page:authentication,header:authentication-oauth-flow) | + +## Local Development + +### Quick Start + +```bash +# Run public tests only (no credentials needed) +vendor/bin/phpunit tests/Integration/PublicApiIntegrationTest.php + +# Run authentication tests (requires env vars below) +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) + +## Getting Credentials + +### Environment Variables Setup + +For authenticated tests, set these environment variables: + +```bash +# PowerShell +$env:DISCOGS_CONSUMER_KEY="your-consumer-key" +$env:DISCOGS_CONSUMER_SECRET="your-consumer-secret" +$env:DISCOGS_PERSONAL_ACCESS_TOKEN="your-personal-access-token" + +# Optional: OAuth tokens for complete OAuth testing +$env:DISCOGS_OAUTH_TOKEN="your-oauth-token" +$env:DISCOGS_OAUTH_TOKEN_SECRET="your-oauth-token-secret" + +# CMD +set DISCOGS_CONSUMER_KEY=your-consumer-key +set DISCOGS_CONSUMER_SECRET=your-consumer-secret +set DISCOGS_PERSONAL_ACCESS_TOKEN=your-personal-access-token +set DISCOGS_OAUTH_TOKEN=your-oauth-token +set DISCOGS_OAUTH_TOKEN_SECRET=your-oauth-token-secret +``` + +### 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 + +## Example GitHub Actions Workflow + +```yaml +name: Tests + +on: [push, pull_request] + +jobs: + tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run unit tests + run: composer test-unit + + - name: Run public integration tests + run: composer test-integration-public + + - name: Run authenticated integration tests + run: composer test-integration + env: + DISCOGS_CONSUMER_KEY: ${{ secrets.DISCOGS_CONSUMER_KEY }} + DISCOGS_CONSUMER_SECRET: ${{ secrets.DISCOGS_CONSUMER_SECRET }} + DISCOGS_PERSONAL_ACCESS_TOKEN: ${{ secrets.DISCOGS_PERSONAL_ACCESS_TOKEN }} +``` + +## Bundle-Specific Integration Tests + +These integration tests focus on: + +1. **Bundle Integration**: Test that the Bundle correctly configures the Discogs API client +2. **Symfony Integration**: Verify that services are properly wired +3. **Configuration**: Ensure Bundle configuration is correctly applied +4. **Error Handling**: Test how the Bundle handles API errors +5. **Rate Limiting**: Verify that rate limiting works properly + +## Additional Safety Notes + +- โš ๏ธ Rate limiting may occur with many tests (Discogs API limits) +- ๐Ÿ”„ Tests use exponential backoff retry logic +- ๐Ÿ“Š Bundle throttling is tested (reactive approach) diff --git a/README.md b/README.md index bf44f81..fa28b0e 100644 --- a/README.md +++ b/README.md @@ -1,155 +1,246 @@ -# ๐ŸŽต 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: Rate limiting (default values shown) + # throttle: + # enabled: true + # microseconds: 1000000 ``` -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(['id' => $id]); + $releases = $client->listArtistReleases(['id' => $id, 'per_page' => 5]); return new JsonResponse([ - 'name' => $artist['name'], + 'artist' => $artist['name'], 'profile' => $artist['profile'] ?? null, + 'releases' => $releases['releases'], ]); } } ``` -## โš™๏ธ Configuration +### Collection and Wantlist -Create `config/packages/calliostro_discogs.yaml`: +```php +// Requires Personal Access Token +$collection = $client->listCollectionItems(['username' => 'your-username']); +$wantlist = $client->getUserWantlist(['username' => 'your-username']); -```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->addToCollection([ + 'folder_id' => 1, + 'release_id' => 30359313, // Billie Eilish - Happier Than Ever +]); + +$client->addToWantlist(['release_id' => 28409710]); // Taylor Swift - Midnights ``` -### ๐Ÿ” Authentication +### Search and Discovery -#### Basic Authentication (Recommended) +```php +$results = $client->search([ + 'q' => 'Billie Eilish', + 'type' => 'artist', +]); + +$releases = $client->listArtistReleases(['id' => '4470662']); // Billie Eilish +$release = $client->getRelease(['id' => '30359313']); // Happier Than Ever +$master = $client->getMaster(['id' => '2835729']); // Midnights master +$label = $client->getLabel(['id' => '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()` 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 +- **Future-Ready** โ€“ PHP 8.5 and Symfony 8.0 compatible (beta/dev testing) +- **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(['id' => $artistId]); + $releases = $this->client->listArtistReleases([ + 'id' => $artistId, + 'per_page' => 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([ + 'folder_id' => 1, // "Uncategorized" folder + 'release_id' => $releaseId, + ]); } } ``` -## ๐Ÿ“– Documentation +## ๐Ÿงช Testing + +```bash +# Run unit tests (default, fast) +composer test + +# Integration tests with real API (requires credentials) +composer test-integration + +# Code analysis & style +composer analyse +composer cs-fix +``` + +See [INTEGRATION_TESTS.md](INTEGRATION_TESTS.md) for API test setup. + +## ๐Ÿ“– API Documentation Reference + +For complete API documentation including all available parameters, visit the [Discogs API Documentation](https://www.discogs.com/developers/). + +### Popular Methods + +#### Database Methods -- **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) +- `search($params)` โ€“ Search the Discogs database +- `getArtist($params)` โ€“ Get artist information +- `listArtistReleases($params)` โ€“ Get artist's releases +- `getRelease($params)` โ€“ Get release information +- `getUserReleaseRating($params)` โ€“ Get user's rating for a release (auth required) +- `getMaster($params)` โ€“ Get master release information +- `getLabel($params)` โ€“ Get label information + +#### Collection Methods + +- `listCollectionFolders($params)` โ€“ Get user's collection folders +- `listCollectionItems($params)` โ€“ Get user's collection items +- `addToCollection($params)` โ€“ Add release to a collection (auth required) +- `updateCollectionItem($params)` โ€“ Update collection item (auth required) +- `removeFromCollection($params)` โ€“ Remove from a collection (auth required) +- `getCollectionValue($params)` โ€“ Get collection value estimation + +#### User Methods + +- `getUser($params)` โ€“ Get user profile information +- `getIdentity($params)` โ€“ Get current user identity (auth required) +- `listUserSubmissions($params)` โ€“ Get user's submissions +- `listUserContributions($params)` โ€“ Get user's contributions +- `getUserWantlist($params)` โ€“ Get user's wantlist +- `getUserInventory($params)` โ€“ Get user's marketplace inventory ## ๐Ÿค Contributing 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 +3. Write tests for your changes: + - Add unit tests for new functionality + - Update integration tests if needed + - Ensure all tests pass: `composer test` +4. Follow code standards: `composer analyse && composer cs-fix` +5. Commit your changes (`git commit -m 'Add amazing feature'`) +6. Push to the branch (`git push origin feature/amazing-feature`) +7. Open a Pull Request Please ensure your code follows Symfony coding standards and includes tests. @@ -160,6 +251,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..bb2863e 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,77 +1,259 @@ # Upgrade Guide -## Upgrading from 3.0.x to 3.1.0 +## ๐Ÿš€ v4.0.0 โ€“ Complete Rewrite -### ๐Ÿ”ง System Requirements +**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. -**Before upgrading, ensure your system meets the new requirements:** +### ๐Ÿ“ˆ Migration from v2.x -- **PHP 8.1+** (previously 7.3+) -- **Symfony 6.4+ or 7.x** (previously 5.x+) +**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: -### ๐Ÿ“ฆ Installation +#### โšก Why Upgrade? + +- **๐Ÿ“ฆ 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 + +#### ๐Ÿ”„ Quick Migration Steps + +1. **Update Composer** โ€“ `composer require calliostro/discogs-bundle:^4.0` +2. **Update Type Hints** โ€“ Change `DiscogsClient` โ†’ `DiscogsApiClient` +3. **Update Service References** โ€“ Change service alias if using container directly +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 New in v4.0.0 + +- **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 + +### ๐Ÿ“‹ System Requirements + +- **PHP**: 8.1+ +- **Symfony**: 6.4+ | 7.x | 8.x +- **calliostro/php-discogs-api**: v4.0.0-beta.1+ + +### ๐Ÿ“ฆ Fresh Installation ```bash -composer require calliostro/discogs-bundle:^3.1 +composer require calliostro/discogs-bundle:^4.0 +``` + +### ๐Ÿš€ Quick Start + +#### 1. Configure the Bundle + +```yaml +# config/packages/calliostro_discogs.yaml +calliostro_discogs: + # Personal Access Token (recommended) + personal_access_token: '%env(DISCOGS_PERSONAL_ACCESS_TOKEN)%' + + # Optional: Consumer credentials for OAuth + # consumer_key: '%env(DISCOGS_CONSUMER_KEY)%' + # consumer_secret: '%env(DISCOGS_CONSUMER_SECRET)%' + + # Optional: User-Agent and throttling + # user_agent: 'MyApp/1.0 +https://myapp.com' + # throttle: + # enabled: true + # microseconds: 1000000 ``` -### โœ… What's Included +#### 2. Use in Your Controllers/Services + +```php +getArtist(['id' => $id]); + $releases = $client->listArtistReleases(['id' => $id, 'per_page' => 5]); -```bash -# Update your PHP version first -php --version # Should show 8.1 or higher + return new JsonResponse([ + 'artist' => $artist['name'], + 'releases' => $releases['releases'] + ]); + } +} ``` -#### If you're using Symfony < 6.4 +### ๐Ÿ”‘ Authentication Options -```bash -# Check your Symfony version -composer show symfony/framework-bundle | grep versions +#### Personal Access Token (Recommended) + +Get your token from [Discogs Developer Settings](https://www.discogs.com/settings/developers): -# Upgrade Symfony first -composer require symfony/framework-bundle:^6.4 +```yaml +calliostro_discogs: + personal_access_token: 'your-personal-access-token' ``` -### ๐Ÿ” Testing Your Upgrade +#### OAuth Consumer Credentials + +For applications requiring OAuth authentication: + +```yaml +calliostro_discogs: + consumer_key: 'your-consumer-key' + consumer_secret: 'your-consumer-secret' +``` -After upgrading, test your Discogs integration: +#### Anonymous Access + +For public data only (rate limited): + +```yaml +calliostro_discogs: + user_agent: 'MyApp/1.0 +https://myapp.com' +``` + +### ๏ฟฝ Key Migration Changes (v2.x โ†’ v4.0) + +#### 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 +{ + // ... +} + +// v4.0 +use Calliostro\Discogs\DiscogsApiClient; + +public function show(DiscogsApiClient $discogs): Response +{ + // ... +} ``` -### ๐Ÿ’ก Configuration Improvements +#### Service Container Changes -Your existing configuration continues to work, but you can now benefit from: +```php +// v2.x - Old service alias +$discogsClient = $container->get('Discogs\DiscogsClient'); + +// v4.0 - New service alias +$discogsClient = $container->get('Calliostro\Discogs\DiscogsApiClient'); +``` -#### 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 | +| `getArtistReleases(['id' => $id])` | `listArtistReleases(['id' => $id])` | Consistent verb-first | +| `getCollectionFolders([...])` | `listCollectionFolders([...])` | Consistent verb-first | +| `getUserWants([...])` | `getUserWantlist([...])` | Clearer naming | +| `getInventory([...])` | `getUserInventory([...])` | More specific | ---- +**Note:** Most methods stay the same! Parameters and return values are identical. + +### ๏ฟฝ๐Ÿ“– API Methods + +All 60 Discogs API endpoints are available with consistent verb-first naming: + +**Popular Methods:** + +- `getArtist(['id' => $id])` โ€“ Get artist information +- `listArtistReleases(['id' => $id])` โ€“ Get artist's releases +- `getRelease(['id' => $id])` โ€“ Get release information +- `search(['q' => 'query'])` โ€“ Search the database +- `listCollectionItems(['username' => $username])` โ€“ Get user's collection +- `addToCollection(['folder_id' => 1, 'release_id' => $id])` โ€“ Add to a collection +- `getUserWantlist(['username' => $username])` โ€“ Get user's wantlist + +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\DiscogsApiClient;` +- [ ] **Update type hints**: Find/replace `DiscogsClient` โ†’ `DiscogsApiClient` +- [ ] **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 -**Need Help?** +These commands help you find code that might need updating: + +```bash +# Find old type hints and imports +grep -r "DiscogsClient" /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" +``` + +### ๐Ÿงช Testing + +```bash +# Run tests +composer test + +# Run integration tests (requires API credentials) +composer test-integration + +# Code analysis +composer analyse +composer cs-fix +``` + +### ๐Ÿ“š Documentation + +- **Bundle Documentation**: [README.md](README.md) +- **API Documentation**: [Discogs API Docs](https://www.discogs.com/developers/) +- **Integration Tests**: [INTEGRATION_TESTS.md](INTEGRATION_TESTS.md) + +### ๐Ÿ†˜ 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..7677c57 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,7 +13,8 @@ "web-api", "audio", "vinyl", - "php8" + "php8", + "lightweight" ], "homepage": "https://github.com/calliostro/discogs-bundle", "license": "MIT", @@ -29,7 +30,7 @@ ], "require": { "php": "^8.1", - "calliostro/php-discogs-api": "^2.1", + "calliostro/php-discogs-api": "v4.0.0-beta.1", "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", @@ -51,16 +52,18 @@ } }, "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-unit": "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" }, "minimum-stability": "stable", - "prefer-stable": true, - "suggest": { - "hwi/HWIOAuthBundle": "Enable OAuth support using HWIOAuthBundle" - } + "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 index 6aa2dbf..aab4991 100644 --- a/phpstan/generic-baseline.neon +++ b/phpstan/generic-baseline.neon @@ -1,5 +1,2 @@ parameters: - ignoreErrors: - - - identifier: missingType.generics - path: ../src/DependencyInjection/Configuration.php + ignoreErrors: [] 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..fa57be4 100644 --- a/src/CalliostroDiscogsBundle.php +++ b/src/CalliostroDiscogsBundle.php @@ -2,8 +2,29 @@ namespace Calliostro\DiscogsBundle; +use Calliostro\DiscogsBundle\DependencyInjection\CalliostroDiscogsExtension; +use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; use Symfony\Component\HttpKernel\Bundle\Bundle; final class CalliostroDiscogsBundle extends Bundle { + public function getPath(): string + { + return \dirname(__DIR__); + } + + public function getContainerExtension(): ExtensionInterface + { + if (null === $this->extension) { + $this->extension = new CalliostroDiscogsExtension(); + } + + // The parent method can return false, but we guarantee to return an extension + $extension = $this->extension; + if (!$extension instanceof ExtensionInterface) { + throw new \LogicException('Extension must implement ExtensionInterface'); + } + + return $extension; + } } diff --git a/src/DependencyInjection/CalliostroDiscogsExtension.php b/src/DependencyInjection/CalliostroDiscogsExtension.php index 59ddadb..b329b96 100644 --- a/src/DependencyInjection/CalliostroDiscogsExtension.php +++ b/src/DependencyInjection/CalliostroDiscogsExtension.php @@ -15,6 +15,11 @@ */ final class CalliostroDiscogsExtension extends Extension { + public function getAlias(): string + { + return 'calliostro_discogs'; + } + /** * @throws \Exception When the XML service configuration file cannot be loaded */ @@ -26,62 +31,64 @@ public function load(array $configs, ContainerBuilder $container): void $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('services.xml'); - $params = [ - 'headers' => ['User-Agent' => $config['user_agent']], - ]; - - $this->configureThrottling($container, $config, $params); - $this->configureOAuth($container, $config, $params, $loader); - - $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); - $clientDefinition->replaceArgument(0, $params); + // Configure client based on authentication method + $this->configureClient($container, $config); } /** * @param array $config - * @param array $params */ - private function configureThrottling(ContainerBuilder $container, array $config, array &$params): void + private function configureClient(ContainerBuilder $container, array $config): void { - if (!$config['throttle']['enabled']) { - return; - } - - $throttleDefinition = $container->getDefinition('calliostro_discogs.throttle_subscriber'); - $throttleDefinition->replaceArgument(0, $config['throttle']['microseconds']); - - $throttleHandlerDefinition = $container->getDefinition('calliostro_discogs.throttle_handler_stack'); - $throttleHandlerDefinition->replaceArgument(0, new Reference('calliostro_discogs.throttle_subscriber')); + $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); - $params['handler'] = new Reference('calliostro_discogs.throttle_handler_stack'); + if (!empty($config['personal_access_token'])) { + // Personal Access Token authentication (recommended for personal use) + $clientDefinition->setFactory(['Calliostro\Discogs\ClientFactory', 'createWithPersonalAccessToken']); + $clientDefinition->setArguments([ + $config['personal_access_token'], + $this->getClientOptions($container, $config), + ]); + } elseif (!empty($config['consumer_key']) && !empty($config['consumer_secret'])) { + // Consumer credentials authentication + $clientDefinition->setFactory(['Calliostro\Discogs\ClientFactory', 'createWithConsumerCredentials']); + $clientDefinition->setArguments([ + $config['consumer_key'], + $config['consumer_secret'], + $this->getClientOptions($container, $config), + ]); + } else { + // Anonymous client (rate-limited) + $clientDefinition->setFactory(['Calliostro\Discogs\ClientFactory', 'create']); + $clientDefinition->setArguments([ + $this->getClientOptions($container, $config), + ]); + } } /** * @param array $config - * @param array $params * - * @throws \Exception When OAuth service configuration file cannot be loaded + * @return array */ - private function configureOAuth(ContainerBuilder $container, array $config, array &$params, Loader\XmlFileLoader $loader): void + private function getClientOptions(ContainerBuilder $container, array $config): array { - 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']); + $options = []; - $oauthHandlerDefinition = $container->getDefinition('calliostro_discogs.oauth_handler_stack'); - $oauthHandlerDefinition->replaceArgument(0, new Reference('calliostro_discogs.subscriber.oauth')); + // Only set the User-Agent header if explicitly configured + if (!empty($config['user_agent'])) { + $options['headers'] = [ + 'User-Agent' => $config['user_agent'], + ]; + } - $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'], - ); + // Add throttling handler if enabled + if ($config['throttle']['enabled']) { + $throttleHandlerDefinition = $container->getDefinition('calliostro_discogs.throttle_handler_stack'); + $throttleHandlerDefinition->replaceArgument(0, (int) $config['throttle']['microseconds']); + $options['handler'] = new Reference('calliostro_discogs.throttle_handler_stack'); } + + return $options; } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 6c9c311..6894e11 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -20,62 +20,61 @@ public function getConfigTreeBuilder(): TreeBuilder // @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)') + ->scalarNode('personal_access_token') + ->info('Your personal access token (recommended - get from https://www.discogs.com/settings/developers)') + ->validate() + ->ifTrue(fn ($v) => \is_string($v) && '' === trim($v)) + ->thenInvalid('Personal access token cannot be empty') + ->end() + ->validate() + ->ifTrue(fn ($v) => \is_string($v) && '' !== trim($v) && \strlen(trim($v)) < 10) + ->thenInvalid('Personal access token must be at least 10 characters') + ->end() ->end() ->scalarNode('consumer_key') - ->info('Your consumer key (recommended)') + ->info('Your consumer key (alternative for OAuth applications)') + ->validate() + ->ifTrue(fn ($v) => \is_string($v) && '' === trim($v)) + ->thenInvalid('Consumer key cannot be empty') + ->end() ->end() ->scalarNode('consumer_secret') - ->info('Your consumer secret (recommended)') + ->info('Your consumer secret (alternative for OAuth applications)') + ->validate() + ->ifTrue(fn ($v) => \is_string($v) && '' === trim($v)) + ->thenInvalid('Consumer secret cannot be empty') + ->end() + ->end() + ->scalarNode('user_agent') + ->defaultNull() + ->info('HTTP User-Agent header for API requests (optional)') + ->validate() + ->ifTrue(fn ($v) => \is_string($v) && \strlen($v) > 200) + ->thenInvalid('User-Agent cannot be longer than 200 characters') + ->end() ->end() ->arrayNode('throttle') ->addDefaultsIfNotSet() ->children() ->booleanNode('enabled') ->defaultTrue() - ->info('If activated, a new attempt is made later when the rate limit is reached') + ->info('Rate limiting - retries HTTP 429 with exponential backoff') ->end() ->integerNode('microseconds') ->defaultValue(1000000) - ->info( - 'Number of milliseconds to wait until the next attempt when the rate limit is reached', - ) + ->info('Number of microseconds to wait until the next attempt when rate limit is reached') + ->validate() + ->ifTrue(fn ($v) => $v < 0) + ->thenInvalid('Throttle microseconds must be a positive integer') + ->end() + ->validate() + ->ifTrue(fn ($v) => $v > 60000000) // 60 seconds max + ->thenInvalid('Throttle microseconds cannot exceed 60 seconds (60000000 microseconds)') + ->end() ->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() - ->end() - ->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', - ) ->end() ; 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/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.xml b/src/Resources/config/services.xml index 79befd1..1922585 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -5,18 +5,19 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd"> - - - - - - + + + + + + - + null - + + diff --git a/src/ThrottleHandlerStackFactory.php b/src/ThrottleHandlerStackFactory.php index 31219a1..beb74c5 100644 --- a/src/ThrottleHandlerStackFactory.php +++ b/src/ThrottleHandlerStackFactory.php @@ -2,18 +2,42 @@ namespace Calliostro\DiscogsBundle; -use Discogs\Subscriber\ThrottleSubscriber; use GuzzleHttp\HandlerStack; use GuzzleHttp\Middleware; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +/** + * Legacy throttling implementation using Guzzle retry middleware. + * + * Works by retrying requests on HTTP 429 (rate limit) with exponential backoff. + * For more advanced rate limiting, consider implementing the application layer. + */ final class ThrottleHandlerStackFactory { - public static function factory(?ThrottleSubscriber $subscriber): HandlerStack + /** + * Create a handler stack with retry middleware for rate limiting. + */ + public static function factory(?int $microseconds = null): HandlerStack { $handler = HandlerStack::create(); - if ($subscriber) { - $handler->push(Middleware::retry($subscriber->decider(), $subscriber->delay()), 'throttle'); + if (null !== $microseconds) { + // Simple retry-based rate limiting + // Retries up to 3 times on HTTP 429 with exponential backoff + $handler->push( + Middleware::retry( + function (int $retries, RequestInterface $request, ?ResponseInterface $response = null): bool { + // Retry on rate limit (429) up to 3 times + return $retries < 3 && $response && 429 === $response->getStatusCode(); + }, + function (int $retries) use ($microseconds): int { + // Exponential backoff delay + return (int) (($microseconds / 1000000) * 2 ** $retries * 1000); + } + ), + 'throttle' + ); } return $handler; diff --git a/tests/Fixtures/TestKernel.php b/tests/Fixtures/TestKernel.php new file mode 100644 index 0000000..935633e --- /dev/null +++ b/tests/Fixtures/TestKernel.php @@ -0,0 +1,133 @@ + + */ + 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); + } + + /** + * @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 getCacheDir(): string + { + return $this->getProjectDir().'/var/cache/'. + $this->environment.'/'. + md5(serialize($this->calliostroDiscogsConfig)).'/'. + spl_object_hash($this); + } + + public function getLogDir(): string + { + return $this->getProjectDir().'/var/log/'.$this->environment; + } + + /** + * Helper method to create a kernel for specific test scenarios. + */ + public static function createForIntegration(array $config = []): self + { + return new self($config, 'integration_test'); + } + + /** + * Helper method to create a kernel for unit test scenarios. + */ + public static function createForUnit(array $config = []): self + { + return new self($config, 'unit_test'); + } + + /** + * Helper method to create a kernel for functional test scenarios. + */ + public static function createForFunctional(array $config = []): self + { + return new self($config, 'functional_test'); + } + + /** + * Cleanup method to remove the test cache after test execution. + */ + public function cleanupCache(): void + { + $cacheDir = $this->getCacheDir(); + if (is_dir($cacheDir)) { + $this->removeDirectory($cacheDir); + } + } + + /** + * 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..9653eb2 --- /dev/null +++ b/tests/Integration/AuthenticatedApiIntegrationTest.php @@ -0,0 +1,211 @@ +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(); + } + + /** + * Helper to skip test if credentials are invalid. + */ + private function skipIfInvalidCredentials(\Exception $e): void + { + if ($e instanceof \GuzzleHttp\Exception\ClientException + && $e->getResponse() + && 401 === $e->getResponse()->getStatusCode()) { + $this->markTestSkipped('Invalid credentials provided - test requires valid Discogs API credentials'); + } + throw $e; + } + + /** + * Level 2: Consumer Credentials - Search enabled through Bundle. + */ + public function testLevel2ConsumerCredentials(): void + { + $kernel = $this->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'); + + // All public endpoints should still work + $artist = $client->getArtist(['id' => '1']); + $this->assertIsArray($artist); + $this->assertArrayHasKey('name', $artist); + + // Search should now work with consumer credentials + $searchResults = $client->search(['q' => 'Daft Punk', 'type' => 'artist']); + $this->assertIsArray($searchResults); + $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'); + + // All previous functionality should work + $artist = $client->getArtist(['id' => '1']); + $this->assertIsArray($artist); + + $searchResults = $client->search(['q' => 'Jazz', 'type' => 'release']); + $this->assertIsArray($searchResults); + $this->assertArrayHasKey('results', $searchResults); + + // Test that we can successfully make authenticated requests + $this->assertIsArray($searchResults); + $this->assertNotEmpty($searchResults['results']); + } + + /** + * Test rate limiting behavior with authenticated requests through Bundle. + */ + public function testRateLimitingWithAuthentication(): void + { + $kernel = $this->createKernel([ + 'personal_access_token' => $this->personalToken, + 'throttle' => [ + 'enabled' => true, + 'microseconds' => 500000, // 0.5 seconds for authenticated requests + ], + ]); + $kernel->boot(); + $container = $kernel->getContainer(); + $client = $container->get('calliostro_discogs.discogs_client'); + + // Make several requests in quick succession + // Authenticated requests have higher rate limits + $startTime = microtime(true); + + for ($i = 0; $i < 3; ++$i) { + $artist = $client->getArtist(['id' => (string) (1 + $i)]); + $this->assertIsArray($artist); + $this->assertArrayHasKey('name', $artist); + } + + $endTime = microtime(true); + $duration = $endTime - $startTime; + + // With authentication, this should complete quickly (< 3 seconds) + $this->assertLessThan(3.0, $duration, 'Authenticated requests took too long - possible rate limiting issue'); + } + + /** + * 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\ClientFactory::createWithOAuth( + $this->consumerKey, + $this->consumerSecret, + $this->oauthToken, + $this->oauthTokenSecret + ); + + // Test identity endpoint (OAuth-specific functionality) + try { + $identity = $client->getIdentity(); + $this->assertIsArray($identity); + $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', 'per_page' => 5]); + $this->assertIsArray($searchResults); + $this->assertArrayHasKey('results', $searchResults); + $this->assertGreaterThan(0, \count($searchResults['results'])); + } catch (\Exception $e) { + $this->skipIfInvalidCredentials($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, + 'throttle' => [ + 'enabled' => true, + 'microseconds' => 1000000, // 1 second for consumer credential requests + ], + ]); + $kernel->boot(); + $container = $kernel->getContainer(); + $client = $container->get('calliostro_discogs.discogs_client'); + + try { + $client->getArtist(['id' => '999999999']); // Non-existent artist + $this->fail('Should have thrown exception for non-existent artist'); + } catch (\Exception $e) { + $this->assertStringContainsStringIgnoringCase('not found', $e->getMessage()); + } + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000..fbafc8e --- /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/PublicApiIntegrationTest.php b/tests/Integration/PublicApiIntegrationTest.php new file mode 100644 index 0000000..07cbe61 --- /dev/null +++ b/tests/Integration/PublicApiIntegrationTest.php @@ -0,0 +1,146 @@ +createKernel([ + 'user_agent' => 'CalliostroDiscogsBundle/IntegrationTest', + 'throttle' => [ + 'enabled' => true, + 'microseconds' => 1000000, // 1 second between requests + ], + ]); + $kernel->boot(); + $container = $kernel->getContainer(); + + $this->client = $container->get('calliostro_discogs.discogs_client'); + } + + /** + * Test basic database methods that should always work through Bundle. + */ + public function testBasicDatabaseMethods(): void + { + // Test artist (using ID from original tests) + $artist = $this->client->getArtist(['id' => '139250']); + $this->assertIsArray($artist); + $this->assertArrayHasKey('name', $artist); + + // Test release - Billie Eilish - Happier Than Ever (2021) + $release = $this->client->getRelease(['id' => '19676596']); + $this->assertIsArray($release); + $this->assertArrayHasKey('title', $release); + $this->assertStringContainsString('Happier Than Ever', $release['title']); + + // Test master - Abbey Road + $master = $this->client->getMaster(['id' => '18512']); + $this->assertIsArray($master); + $this->assertArrayHasKey('title', $master); + + // Test label + $label = $this->client->getLabel(['id' => '1']); + $this->assertIsArray($label); + $this->assertArrayHasKey('name', $label); + } + + /** + * Test Community Release Rating endpoint through Bundle. + */ + public function testCommunityReleaseRating(): void + { + $rating = $this->client->getCommunityReleaseRating(['release_id' => '19676596']); + + $this->assertIsArray($rating); + $this->assertArrayHasKey('rating', $rating); + $this->assertArrayHasKey('release_id', $rating); + $this->assertEquals(19676596, $rating['release_id']); + + $this->assertIsArray($rating['rating']); + $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(['id' => '19676596']); + + $this->assertIsArray($release); + $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 reactive throttling functionality. + * The Bundle uses reactive throttling - it retries on HTTP 429 with exponential backoff. + * This test verifies the configuration works by making multiple rapid requests. + */ + public function testBundleReactiveThrottling(): void + { + // Create a kernel with throttling explicitly enabled + $kernel = $this->createKernel([ + 'user_agent' => 'CalliostroDiscogsBundle/IntegrationTest', + 'throttle' => [ + 'enabled' => true, + 'microseconds' => 1000000, // 1 second + ], + ]); + $kernel->boot(); + $container = $kernel->getContainer(); + + // Note: After boot, the container is compiled - no hasDefinition() method + // But we can verify the client works with throttling configured + $client = $container->get('calliostro_discogs.discogs_client'); + + // Make requests rapidly - Bundle should handle any rate limits gracefully + $responses = []; + for ($i = 0; $i < 2; ++$i) { + $responses[] = $client->getArtist(['id' => (string) (1 + $i)]); + } + + // All requests should succeed - Bundle's reactive throttling handles 429 errors + $this->assertCount(2, $responses); + foreach ($responses as $response) { + $this->assertIsArray($response); + $this->assertArrayHasKey('name', $response); + } + + // The Bundle's throttling configuration ensures requests succeed + // even if the API returns rate limit errors (429) + $this->assertTrue(true, 'Bundle throttling allows rapid requests to succeed'); + } + + /** + * Test Bundle error handling for invalid IDs through real API. + */ + public function testBundleErrorHandling(): void + { + $this->expectException(\Exception::class); + $this->client->getArtist(['id' => '999999999']); + } +} 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..bcf7177 --- /dev/null +++ b/tests/Unit/BundleEdgeCasesTest.php @@ -0,0 +1,239 @@ +load([], $container); + + $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); + } + + public function testExtensionHandlesNestedEmptyArrays(): void + { + $container = new ContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + $config = [ + [ + 'throttle' => [], + ], + ]; + + $extension->load($config, $container); + + $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); + $this->assertTrue($container->hasDefinition('calliostro_discogs.throttle_handler_stack')); + } + + 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 testBundleWithValidThrottleMicroseconds(): void + { + // Test with valid positive microseconds value + $config = [ + 'throttle' => [ + 'enabled' => true, + 'microseconds' => 500000, // 0.5 seconds - valid + ], + ]; + + $kernel = TestKernel::createForFunctional($config); + $kernel->boot(); + $container = $kernel->getContainer(); + + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertNotNull($client); + + $kernel->cleanupCache(); + } + + public function testBundleWithZeroMicroseconds(): void + { + $config = [ + 'throttle' => [ + 'enabled' => true, + 'microseconds' => 0, + ], + ]; + + $kernel = TestKernel::createForFunctional($config); + $kernel->boot(); + $container = $kernel->getContainer(); + + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertNotNull($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->assertNotNull($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->assertNotNull($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->assertNotNull($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->assertTrue(true); // 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->assertNotNull($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->assertNotNull($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..4dcaf89 --- /dev/null +++ b/tests/Unit/BundleIntegrationTest.php @@ -0,0 +1,247 @@ +boot(); + $container = $kernel->getContainer(); + + // Verify core service is available + $this->assertTrue($container->has('calliostro_discogs.discogs_client')); + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsApiClient::class, $client); + + $kernel->cleanupCache(); + } + + 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', + 'throttle' => [ + 'enabled' => true, + 'microseconds' => 750000, + ], + ]; + + $kernel = TestKernel::createForFunctional($config); + $kernel->boot(); + $container = $kernel->getContainer(); + + // Verify services are properly configured + $this->assertTrue($container->has('calliostro_discogs.discogs_client')); + // Note: throttle_handler_stack is private and not available in a compiled container + + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsApiClient::class, $client); + + $kernel->cleanupCache(); + } + + public function testBundleWithThrottleDisabled(): void + { + $config = [ + 'throttle' => [ + 'enabled' => false, + ], + ]; + + $kernel = TestKernel::createForFunctional($config); + $kernel->boot(); + $container = $kernel->getContainer(); + + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsApiClient::class, $client); + + $kernel->cleanupCache(); + } + + public function testBundleWithConsumerCredentialsOnly(): void + { + $config = [ + 'consumer_key' => 'my_consumer_key', + 'consumer_secret' => 'my_consumer_secret', + ]; + + $kernel = TestKernel::createForFunctional($config); + $kernel->boot(); + $container = $kernel->getContainer(); + + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsApiClient::class, $client); + + $kernel->cleanupCache(); + } + + public function testBundleWithPersonalAccessTokenOnly(): void + { + $config = [ + 'personal_access_token' => 'my_personal_access_token_123', + ]; + + $kernel = TestKernel::createForFunctional($config); + $kernel->boot(); + $container = $kernel->getContainer(); + + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsApiClient::class, $client); + + $kernel->cleanupCache(); + } + + public function testBundleWithCustomUserAgent(): void + { + $config = [ + 'user_agent' => 'MyCustomApp/2.1.0 +http://example.com', + ]; + + $kernel = TestKernel::createForFunctional($config); + $kernel->boot(); + $container = $kernel->getContainer(); + + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsApiClient::class, $client); + + $kernel->cleanupCache(); + } + + public function testBundleServicesArePrivate(): void + { + $kernel = TestKernel::createForFunctional([ + 'consumer_key' => 'test', + 'consumer_secret' => 'test', + ]); + $kernel->boot(); + $container = $kernel->getContainer(); + + // The main service should be public for injection + $this->assertTrue($container->has('calliostro_discogs.discogs_client')); + + // Internal services should not be directly accessible in compiled container + // (This is expected behavior in Symfony - internal services are private) + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsApiClient::class, $client); + + $kernel->cleanupCache(); + } + + public function testMultipleBundleInstancesIsolation(): void + { + $kernel1 = TestKernel::createForFunctional([ + 'user_agent' => 'App1/1.0', + 'throttle' => ['enabled' => true], + ]); + + $kernel2 = TestKernel::createForFunctional([ + 'user_agent' => 'App2/2.0', + 'throttle' => ['enabled' => false], + ]); + + $kernel1->boot(); + $kernel2->boot(); + + $client1 = $kernel1->getContainer()->get('calliostro_discogs.discogs_client'); + $client2 = $kernel2->getContainer()->get('calliostro_discogs.discogs_client'); + + $this->assertInstanceOf(DiscogsApiClient::class, $client1); + $this->assertInstanceOf(DiscogsApiClient::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', + 'throttle' => [ + 'enabled' => true, + 'microseconds' => 500000, + ], + ]); + + $kernel->boot(); + $container = $kernel->getContainer(); + + // Test that we can retrieve the main service + $client = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsApiClient::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', + 'throttle' => [ + 'enabled' => true, + 'microseconds' => 2000000, // 2 seconds + ], + ]; + + $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(DiscogsApiClient::class, $client); + + $kernel->cleanupCache(); + } + + public function testBundleEnvironmentSeparation(): void + { + // Test that different environments can have different configurations + $prodConfig = [ + 'user_agent' => 'ProdApp/1.0', + 'throttle' => ['enabled' => true, 'microseconds' => 1000000], + ]; + + $testConfig = [ + 'user_agent' => 'TestApp/1.0', + 'throttle' => ['enabled' => false], + ]; + + $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(DiscogsApiClient::class, $prodClient); + $this->assertInstanceOf(DiscogsApiClient::class, $testClient); + $this->assertNotSame($prodClient, $testClient); + + $prodKernel->cleanupCache(); + $testKernel->cleanupCache(); + } +} diff --git a/tests/CalliostroDiscogsExtensionTest.php b/tests/Unit/CalliostroDiscogsExtensionTest.php similarity index 52% rename from tests/CalliostroDiscogsExtensionTest.php rename to tests/Unit/CalliostroDiscogsExtensionTest.php index a8de27a..dbd6587 100644 --- a/tests/CalliostroDiscogsExtensionTest.php +++ b/tests/Unit/CalliostroDiscogsExtensionTest.php @@ -1,6 +1,6 @@ load($config, $container); - $this->assertTrue($container->hasDefinition('calliostro_discogs.throttle_subscriber')); + // In v4.0.0, throttling is handled differently - no separate subscriber $this->assertTrue($container->hasDefinition('calliostro_discogs.throttle_handler_stack')); + + // Verify the client is configured properly + $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); } - public function testLoadWithOAuthEnabled(): void + public function testLoadWithConsumerKeyAndSecretOnly(): 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', ], @@ -56,57 +55,66 @@ public function testLoadWithOAuthEnabled(): void $extension->load($config, $container); - $this->assertTrue($container->hasDefinition('calliostro_discogs.subscriber.oauth')); - $this->assertTrue($container->hasDefinition('calliostro_discogs.oauth_handler_stack')); + $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); + + // Check that the client is configured with proper factory method and arguments + $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); + $factory = $clientDefinition->getFactory(); + $this->assertEquals(['Calliostro\Discogs\ClientFactory', 'createWithConsumerCredentials'], $factory); + + // Check that the consumer key and secret are passed as arguments + $arguments = $clientDefinition->getArguments(); + $this->assertCount(3, $arguments); + $this->assertEquals('test_key', $arguments[0]); + $this->assertEquals('test_secret', $arguments[1]); } - public function testLoadWithConsumerKeyAndSecretOnly(): void + public function testLoadWithThrottleDisabled(): void { $container = new ContainerBuilder(); $extension = new CalliostroDiscogsExtension(); $config = [ [ - 'consumer_key' => 'test_key', - 'consumer_secret' => 'test_secret', + 'throttle' => [ + 'enabled' => false, + ], ], ]; $extension->load($config, $container); $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); - - // Check that the Authorization header is set correctly + // When the throttle is disabled, the basic client factory should be used $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'] - ); + $factory = $clientDefinition->getFactory(); + $this->assertEquals(['Calliostro\Discogs\ClientFactory', 'create'], $factory); } - public function testLoadWithThrottleDisabled(): void + public function testLoadWithPersonalAccessToken(): void { $container = new ContainerBuilder(); $extension = new CalliostroDiscogsExtension(); $config = [ [ - 'throttle' => [ - 'enabled' => false, - ], + 'personal_access_token' => 'test_token_123', ], ]; $extension->load($config, $container); $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); - // When the throttle is disabled, no throttle handler should be configured + + // Check that the client is configured with a personal access token factory $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); + $factory = $clientDefinition->getFactory(); + $this->assertEquals(['Calliostro\Discogs\ClientFactory', 'createWithPersonalAccessToken'], $factory); + + // Check that the token is passed as the first argument $arguments = $clientDefinition->getArguments(); - $this->assertArrayNotHasKey('handler', $arguments[0]); + $this->assertCount(2, $arguments); + $this->assertEquals('test_token_123', $arguments[0]); } public function testLoadWithCustomUserAgent(): void @@ -123,9 +131,43 @@ public function testLoadWithCustomUserAgent(): void $extension->load($config, $container); $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); + $factory = $clientDefinition->getFactory(); + $this->assertEquals(['Calliostro\Discogs\ClientFactory', 'create'], $factory); + $arguments = $clientDefinition->getArguments(); + $this->assertIsArray($arguments[0]); $this->assertArrayHasKey('headers', $arguments[0]); - $this->assertArrayHasKey('User-Agent', $arguments[0]['headers']); $this->assertEquals('CustomAgent/1.0', $arguments[0]['headers']['User-Agent']); } + + public function testLoadWithPersonalAccessTokenAndUserAgent(): void + { + $container = new ContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + $config = [ + [ + 'personal_access_token' => 'test_token_123', + 'user_agent' => 'TestApp/1.0', + ], + ]; + + $extension->load($config, $container); + + $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); + $factory = $clientDefinition->getFactory(); + $this->assertEquals(['Calliostro\Discogs\ClientFactory', 'createWithPersonalAccessToken'], $factory); + + // Check that arguments include token and options + $arguments = $clientDefinition->getArguments(); + $this->assertCount(2, $arguments); // Token + options + $this->assertEquals('test_token_123', $arguments[0]); + $this->assertIsArray($arguments[1]); // Options + + // Check that user_agent is embedded in options headers + $options = $arguments[1]; + $this->assertArrayHasKey('headers', $options); + $this->assertArrayHasKey('User-Agent', $options['headers']); + $this->assertEquals('TestApp/1.0', $options['headers']['User-Agent']); + } } diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php new file mode 100644 index 0000000..5eee660 --- /dev/null +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,193 @@ +configuration = new Configuration(); + $this->processor = new Processor(); + } + + public function testEmptyConfiguration(): void + { + $config = $this->processor->processConfiguration($this->configuration, []); + + $expected = [ + 'user_agent' => null, + 'throttle' => [ + 'enabled' => true, + 'microseconds' => 1000000, + ], + ]; + + $this->assertEquals($expected, $config); + } + + 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->assertTrue($config['throttle']['enabled']); + $this->assertEquals(1000000, $config['throttle']['microseconds']); + } + + 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 testThrottleConfiguration(): void + { + $configs = [ + [ + 'throttle' => [ + 'enabled' => false, + 'microseconds' => 500000, + ], + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + $this->assertFalse($config['throttle']['enabled']); + $this->assertEquals(500000, $config['throttle']['microseconds']); + } + + public function testThrottleEnabledOnlyConfiguration(): void + { + $configs = [ + [ + 'throttle' => [ + 'enabled' => false, + ], + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + $this->assertFalse($config['throttle']['enabled']); + $this->assertEquals(1000000, $config['throttle']['microseconds']); // Default value + } + + public function testThrottleMicrosecondsOnlyConfiguration(): void + { + $configs = [ + [ + 'throttle' => [ + 'microseconds' => 250000, + ], + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + $this->assertTrue($config['throttle']['enabled']); // Default value + $this->assertEquals(250000, $config['throttle']['microseconds']); + } + + 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', + 'throttle' => [ + 'enabled' => true, + 'microseconds' => 750000, + ], + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + $expected = [ + 'user_agent' => 'TestApp/2.0', + 'consumer_key' => 'valid_consumer_key_12345', + 'consumer_secret' => 'valid_consumer_secret_12345', + 'personal_access_token' => 'BillieEilishToken2024_123456789', + 'throttle' => [ + 'enabled' => true, + 'microseconds' => 750000, + ], + ]; + + $this->assertEquals($expected, $config); + } + + public function testConfigurationMerging(): void + { + $configs = [ + [ + 'user_agent' => 'FirstApp/1.0', + 'consumer_key' => 'first_key', + ], + [ + 'user_agent' => 'SecondApp/2.0', + 'consumer_secret' => 'second_secret', + 'throttle' => [ + 'enabled' => false, + ], + ], + ]; + + $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->assertFalse($config['throttle']['enabled']); // From second config + $this->assertEquals(1000000, $config['throttle']['microseconds']); // Default value + } + + public function testTreeBuilderReturnsCorrectRootName(): void + { + $treeBuilder = $this->configuration->getConfigTreeBuilder(); + + $this->assertEquals('calliostro_discogs', $treeBuilder->buildTree()->getName()); + } +} diff --git a/tests/Unit/DependencyInjection/ConfigurationValidationTest.php b/tests/Unit/DependencyInjection/ConfigurationValidationTest.php new file mode 100644 index 0000000..00b7331 --- /dev/null +++ b/tests/Unit/DependencyInjection/ConfigurationValidationTest.php @@ -0,0 +1,215 @@ +configuration = new Configuration(); + $this->processor = new Processor(); + } + + public function testEmptyPersonalAccessTokenFails(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Personal access token cannot be empty'); + + $configs = [ + [ + 'personal_access_token' => '', + ], + ]; + + $this->processor->processConfiguration($this->configuration, $configs); + } + + public function testWhitespaceOnlyPersonalAccessTokenFails(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Personal access token cannot be empty'); + + $configs = [ + [ + 'personal_access_token' => ' ', + ], + ]; + + $this->processor->processConfiguration($this->configuration, $configs); + } + + public function testShortPersonalAccessTokenFails(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Personal access token must be at least 10 characters'); + + $configs = [ + [ + 'personal_access_token' => 'short', + ], + ]; + + $this->processor->processConfiguration($this->configuration, $configs); + } + + public function testEmptyConsumerKeyFails(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Consumer key cannot be empty'); + + $configs = [ + [ + 'consumer_key' => '', + ], + ]; + + $this->processor->processConfiguration($this->configuration, $configs); + } + + public function testEmptyConsumerSecretFails(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Consumer secret cannot be empty'); + + $configs = [ + [ + 'consumer_secret' => '', + ], + ]; + + $this->processor->processConfiguration($this->configuration, $configs); + } + + public function testNegativeThrottleMicrosecondsFails(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Throttle microseconds must be a positive integer'); + + $configs = [ + [ + 'throttle' => [ + 'microseconds' => -1000, + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $configs); + } + + public function testExcessiveThrottleMicrosecondsFails(): void + { + $this->expectException(InvalidConfigurationException::class); + $this->expectExceptionMessage('Throttle microseconds cannot exceed 60 seconds'); + + $configs = [ + [ + 'throttle' => [ + 'microseconds' => 70000000, // 70 seconds + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $configs); + } + + 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', + 'throttle' => [ + 'enabled' => true, + 'microseconds' => 500000, // 0.5 seconds + ], + ], + ]; + + $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']); + $this->assertTrue($config['throttle']['enabled']); + $this->assertEquals(500000, $config['throttle']['microseconds']); + } + + public function testZeroThrottleMicrosecondsIsValid(): void + { + $configs = [ + [ + 'throttle' => [ + 'microseconds' => 0, + ], + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + $this->assertEquals(0, $config['throttle']['microseconds']); + } + + public function testMaximumThrottleMicrosecondsIsValid(): void + { + $configs = [ + [ + 'throttle' => [ + 'microseconds' => 60000000, // Exactly 60 seconds + ], + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + $this->assertEquals(60000000, $config['throttle']['microseconds']); + } + + public function testInvalidBooleanThrottleEnabled(): void + { + $this->expectException(InvalidConfigurationException::class); + + $configs = [ + [ + 'throttle' => [ + 'enabled' => 'not_a_boolean', + ], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $configs); + } + + public function testArrayAsScalarValue(): void + { + $this->expectException(InvalidConfigurationException::class); + + $configs = [ + [ + 'personal_access_token' => ['invalid' => 'array'], + ], + ]; + + $this->processor->processConfiguration($this->configuration, $configs); + } +} diff --git a/tests/Unit/DiscogsApiClientMockTest.php b/tests/Unit/DiscogsApiClientMockTest.php new file mode 100644 index 0000000..3777703 --- /dev/null +++ b/tests/Unit/DiscogsApiClientMockTest.php @@ -0,0 +1,203 @@ +mockHandler = new MockHandler(); + $this->handlerStack = HandlerStack::create($this->mockHandler); + $this->httpClient = new Client(['handler' => $this->handlerStack]); + + // Use reflection to create a DiscogsApiClient with our mocked HTTP client + $this->client = new DiscogsApiClient($this->httpClient); + } + + public function testGetArtistSuccess(): void + { + $expectedResponse = [ + 'id' => 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(['id' => '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(['id' => '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 + { + // First request gets rate limited, second succeeds + $this->mockHandler->append( + new Response(429, ['Retry-After' => '1'], '{"message": "You are making requests too quickly."}'), + new Response(200, ['Content-Type' => 'application/json'], '{"id": 1, "name": "Test Artist"}') + ); + + // Use the throttle handler stack for this test + $throttleStack = ThrottleHandlerStackFactory::factory(100000); // 0.1 seconds for fast testing + $throttleStack->setHandler($this->mockHandler); + $throttleClient = new Client(['handler' => $throttleStack, 'http_errors' => false]); + $apiClient = new DiscogsApiClient($throttleClient); + + $result = $apiClient->getArtist(['id' => '1']); + + $this->assertEquals(['id' => 1, 'name' => 'Test Artist'], $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(['id' => (string) $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(['id' => '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(['id' => '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 DiscogsApiClient($customClient); + $result = $apiClient->getArtist(['id' => '1']); + + $this->assertEquals(['id' => 1, 'name' => 'Test'], $result); + + // Verify the request was made (check last request) + $lastRequest = $this->mockHandler->getLastRequest(); + $this->assertNotNull($lastRequest); + } +} diff --git a/tests/Unit/FunctionalTest.php b/tests/Unit/FunctionalTest.php new file mode 100644 index 0000000..7311a05 --- /dev/null +++ b/tests/Unit/FunctionalTest.php @@ -0,0 +1,105 @@ +kernel)) { + $this->kernel->cleanupCache(); + } + parent::tearDown(); + } + + public function testServiceWiring(): void + { + $this->kernel = TestKernel::createForFunctional(); + $this->kernel->boot(); + $container = $this->kernel->getContainer(); + + $discogsClient = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsApiClient::class, $discogsClient); + } + + public function testServiceWiringWithConfiguration(): void + { + $this->kernel = TestKernel::createForFunctional([ + 'user_agent' => 'test', + ]); + $this->kernel->boot(); + $container = $this->kernel->getContainer(); + + $discogsClient = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsApiClient::class, $discogsClient); + + // Verify that the client is properly configured + // The user agent configuration is handled internally by the bundle + $this->assertNotNull($discogsClient); + } + + public function testServiceWiringWithThrottleDisabled(): void + { + $this->kernel = TestKernel::createForFunctional([ + 'throttle' => [ + 'enabled' => false, + ], + ]); + $this->kernel->boot(); + $container = $this->kernel->getContainer(); + + $discogsClient = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsApiClient::class, $discogsClient); + } + + public function testServiceWiringWithConsumerCredentials(): void + { + $this->kernel = TestKernel::createForFunctional([ + 'consumer_key' => 'test_key', + 'consumer_secret' => 'test_secret', + ]); + $this->kernel->boot(); + $container = $this->kernel->getContainer(); + + $discogsClient = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsApiClient::class, $discogsClient); + } + + public function testServiceWiringWithPersonalAccessToken(): void + { + $this->kernel = TestKernel::createForFunctional([ + 'personal_access_token' => 'test_token_123', + ]); + $this->kernel->boot(); + $container = $this->kernel->getContainer(); + + $discogsClient = $container->get('calliostro_discogs.discogs_client'); + $this->assertInstanceOf(DiscogsApiClient::class, $discogsClient); + } + + public function testMultipleKernelInstancesIsolation(): void + { + // Test that multiple kernel instances don't interfere + $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(DiscogsApiClient::class, $client1); + $this->assertInstanceOf(DiscogsApiClient::class, $client2); + $this->assertNotSame($client1, $client2); + + $kernel1->cleanupCache(); + $kernel2->cleanupCache(); + } +} diff --git a/tests/MockOAuthToken.php b/tests/Unit/MockOAuthToken.php similarity index 93% rename from tests/MockOAuthToken.php rename to tests/Unit/MockOAuthToken.php index 94b5137..9c0f179 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/ThrottleHandlerStackFactoryTest.php b/tests/Unit/ThrottleHandlerStackFactoryTest.php new file mode 100644 index 0000000..33e27e9 --- /dev/null +++ b/tests/Unit/ThrottleHandlerStackFactoryTest.php @@ -0,0 +1,102 @@ +assertInstanceOf(HandlerStack::class, $stack); + + // Test that it works without retry middleware + $client = new Client(['handler' => $stack]); + $mock = new MockHandler([new Response(200, [], 'test')]); + $stack->setHandler($mock); + + $response = $client->get('http://example.com'); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testFactoryWithMicroseconds(): void + { + $microseconds = 1000000; // 1 second + $stack = ThrottleHandlerStackFactory::factory($microseconds); + + /* @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(HandlerStack::class, $stack); + + // Test successful request (no retry needed) + $mock = new MockHandler([new Response(200, [], 'success')]); + $stack->setHandler($mock); + $client = new Client(['handler' => $stack]); + + $response = $client->get('http://example.com'); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testRetryOn429Response(): void + { + $microseconds = 100000; // 0.1 seconds for fast testing + $stack = ThrottleHandlerStackFactory::factory($microseconds); + + // Mock: First request returns 429, second returns 200 + $mock = new MockHandler([ + new Response(429, [], 'Rate Limited'), + new Response(200, [], 'Success after retry'), + ]); + $stack->setHandler($mock); + $client = new Client(['handler' => $stack, 'http_errors' => false]); + + $response = $client->get('http://example.com'); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('Success after retry', (string) $response->getBody()); + } + + public function testMaxRetriesExceeded(): void + { + $microseconds = 100000; // 0.1 seconds for fast testing + $stack = ThrottleHandlerStackFactory::factory($microseconds); + + // Mock: All requests return 429 (exceeds max retries) + $mock = new MockHandler([ + new Response(429, [], 'Rate Limited'), + new Response(429, [], 'Rate Limited'), + new Response(429, [], 'Rate Limited'), + new Response(429, [], 'Rate Limited'), // The final attempt also fails + ]); + $stack->setHandler($mock); + $client = new Client(['handler' => $stack, 'http_errors' => false]); + + $response = $client->get('http://example.com'); + $this->assertEquals(429, $response->getStatusCode()); + } + + public function testNoRetryOnOtherErrors(): void + { + $microseconds = 100000; + $stack = ThrottleHandlerStackFactory::factory($microseconds); + + // Mock: 500 error should not be retried + $mock = new MockHandler([ + new Response(500, [], 'Internal Server Error'), + ]); + $stack->setHandler($mock); + $client = new Client(['handler' => $stack, 'http_errors' => false]); + + $response = $client->get('http://example.com'); + $this->assertEquals(500, $response->getStatusCode()); + } +} From 42d0529c9ea5464aa636f969b09bcd8bd451a34b Mon Sep 17 00:00:00 2001 From: calliostro Date: Fri, 12 Sep 2025 11:57:10 +0200 Subject: [PATCH 02/34] Temporarily relax composer validation for beta versions - Allow exact version constraints for beta/alpha/rc versions - Use --no-check-version flag instead of --strict for pre-release branches - Maintain strict validation for stable releases --- .github/workflows/ci.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41c0017..5737bae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,7 +148,12 @@ 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 @@ -203,7 +208,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 From 43fd0989a98634e9ec050f8c2561116e2908b3fc Mon Sep 17 00:00:00 2001 From: calliostro Date: Fri, 12 Sep 2025 12:09:55 +0200 Subject: [PATCH 03/34] Fix PHPStan baseline for Symfony 7.4/8.0 compatibility --- .github/workflows/ci.yml | 28 +++++----------------------- phpstan/generic-baseline.neon | 5 ++++- 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5737bae..52bb255 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: @@ -31,7 +31,7 @@ jobs: allowed-to-fail: false - php: '8.4' allowed-to-fail: false - + # Symfony 6.4 LTS compatibility - php: '8.2' symfony-version: '^6.4' @@ -42,7 +42,7 @@ jobs: - php: '8.4' symfony-version: '^6.4' allowed-to-fail: false - + # Symfony 7.x current versions - php: '8.2' symfony-version: '^7.0' @@ -148,12 +148,7 @@ jobs: run: composer update --prefer-dist --no-interaction --no-progress - name: Validate composer.json and composer.lock - run: | - if [[ "${{ github.ref }}" == *"beta"* || "${{ github.ref }}" == *"alpha"* || "${{ github.ref }}" == *"rc"* ]]; then - composer validate --no-check-version - else - composer validate --strict - fi + run: composer validate --strict - name: Run code style check run: composer cs @@ -169,14 +164,6 @@ jobs: - name: Run tests run: ./vendor/bin/simple-phpunit -v - - name: Run integration tests (optional) - env: - DISCOGS_CONSUMER_KEY: ${{ secrets.DISCOGS_CONSUMER_KEY }} - DISCOGS_CONSUMER_SECRET: ${{ secrets.DISCOGS_CONSUMER_SECRET }} - DISCOGS_PERSONAL_ACCESS_TOKEN: ${{ secrets.DISCOGS_PERSONAL_ACCESS_TOKEN }} - run: composer test-integration - continue-on-error: true - code-quality: runs-on: ubuntu-latest name: Code Quality Checks @@ -208,12 +195,7 @@ jobs: run: composer install --prefer-dist --no-interaction --no-progress - name: Validate composer.json and composer.lock - run: | - if [[ "${{ github.ref }}" == *"beta"* || "${{ github.ref }}" == *"alpha"* || "${{ github.ref }}" == *"rc"* ]]; then - composer validate --no-check-version - else - composer validate --strict - fi + run: composer validate --strict coverage: runs-on: ubuntu-latest diff --git a/phpstan/generic-baseline.neon b/phpstan/generic-baseline.neon index aab4991..adb4d29 100644 --- a/phpstan/generic-baseline.neon +++ b/phpstan/generic-baseline.neon @@ -1,2 +1,5 @@ parameters: - ignoreErrors: [] + ignoreErrors: + - + identifier: missingType.generics + path: ../src/DependencyInjection/Configuration.php From b417295cac34598859a49764739e5abdc743505d Mon Sep 17 00:00:00 2001 From: calliostro Date: Fri, 12 Sep 2025 12:18:25 +0200 Subject: [PATCH 04/34] Fix CI composer validation for beta branches --- .github/workflows/ci.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52bb255..d1e5842 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,7 +148,12 @@ 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 @@ -195,7 +200,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 From 5f79c8d4c1de4dbc65dd0b74e7aa81f31b4b158f Mon Sep 17 00:00:00 2001 From: calliostro Date: Fri, 12 Sep 2025 12:27:26 +0200 Subject: [PATCH 05/34] Clean up integration test documentation - Remove optional OAuth token references from environment setup - Simplify credential requirements to essential secrets only - Focus on the three secrets actually used in CI pipeline --- INTEGRATION_TESTS.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/INTEGRATION_TESTS.md b/INTEGRATION_TESTS.md index 73e6615..dbd9808 100644 --- a/INTEGRATION_TESTS.md +++ b/INTEGRATION_TESTS.md @@ -46,13 +46,11 @@ To enable authenticated integration tests in CI/CD, add these secrets to your Gi Navigate to: **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) | -| `DISCOGS_OAUTH_TOKEN` | OAuth access token (optional) | [OAuth Flow](https://www.discogs.com/developers/#page:authentication,header:authentication-oauth-flow) | -| `DISCOGS_OAUTH_TOKEN_SECRET` | OAuth token secret (optional) | [OAuth Flow](https://www.discogs.com/developers/#page:authentication,header:authentication-oauth-flow) | +| 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 @@ -88,16 +86,10 @@ $env:DISCOGS_CONSUMER_KEY="your-consumer-key" $env:DISCOGS_CONSUMER_SECRET="your-consumer-secret" $env:DISCOGS_PERSONAL_ACCESS_TOKEN="your-personal-access-token" -# Optional: OAuth tokens for complete OAuth testing -$env:DISCOGS_OAUTH_TOKEN="your-oauth-token" -$env:DISCOGS_OAUTH_TOKEN_SECRET="your-oauth-token-secret" - # CMD set DISCOGS_CONSUMER_KEY=your-consumer-key set DISCOGS_CONSUMER_SECRET=your-consumer-secret set DISCOGS_PERSONAL_ACCESS_TOKEN=your-personal-access-token -set DISCOGS_OAUTH_TOKEN=your-oauth-token -set DISCOGS_OAUTH_TOKEN_SECRET=your-oauth-token-secret ``` ### Getting Credentials From ffcf4c94b2169fc4659b0b15a19a93c8270b0267 Mon Sep 17 00:00:00 2001 From: calliostro Date: Fri, 12 Sep 2025 12:31:44 +0200 Subject: [PATCH 06/34] Remove outdated author information from composer.json --- composer.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/composer.json b/composer.json index 7677c57..57b9fd2 100644 --- a/composer.json +++ b/composer.json @@ -19,10 +19,6 @@ "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" From 7b4997e02541545aa58ec697da58a1f8342108f9 Mon Sep 17 00:00:00 2001 From: calliostro Date: Fri, 12 Sep 2025 12:35:56 +0200 Subject: [PATCH 07/34] Update changelog and README for consistency and clarity --- CHANGELOG.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c672ec..326439f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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-beta.1] โ€“ 2025-09-12 +## [4.0.0-beta.1](https://github.com/calliostro/discogs-bundle/releases/tag/v4.0.0-beta.1) โ€“ 2025-09-12 ### ๐Ÿš€ Complete Rewrite โ€” Fresh Start diff --git a/README.md b/README.md index fa28b0e..5119b50 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ๐ŸŽต Discogs Client Bundle for Symfony โ€“ Complete Music Database Access +# โšก 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) From 37ef50951325904a522f2206c302e2f0606ce913 Mon Sep 17 00:00:00 2001 From: calliostro Date: Sun, 14 Sep 2025 00:14:27 +0200 Subject: [PATCH 08/34] Complete v4.0 redesign: Modern architecture and Symfony Rate Limiter integration - Implement all 60 Discogs API methods with named parameters - Integrate Symfony Rate Limiter component - Remove legacy throttle system and array parameters --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 25 +- DEVELOPMENT.md | 154 ++++++++++++ INTEGRATION_TESTS.md | 152 ------------ README.md | 148 +++++------- UPGRADE.md | 225 +++++------------- composer.json | 12 +- src/CalliostroDiscogsBundle.php | 10 +- .../CalliostroDiscogsExtension.php | 64 +++-- src/DependencyInjection/Configuration.php | 104 ++++---- src/Middleware/RateLimiterMiddleware.php | 49 ++++ src/Resources/config/services.xml | 18 +- src/ThrottleHandlerStackFactory.php | 45 ---- tests/Fixtures/TestKernel.php | 70 +++--- .../AuthenticatedApiIntegrationTest.php | 102 ++++---- tests/Integration/IntegrationTestCase.php | 4 +- .../Middleware/RateLimiterMiddlewareTest.php | 146 ++++++++++++ .../Integration/PublicApiIntegrationTest.php | 85 +++---- tests/Unit/BundleEdgeCasesTest.php | 69 ++---- tests/Unit/BundleIntegrationTest.php | 141 +++-------- tests/Unit/CalliostroDiscogsBundleTest.php | 56 +++++ tests/Unit/CalliostroDiscogsExtensionTest.php | 181 ++++++++------ .../DependencyInjection/ConfigurationTest.php | 122 ++++------ .../ConfigurationValidationTest.php | 95 +------- ...MockTest.php => DiscogsClientMockTest.php} | 78 +++--- tests/Unit/FunctionalTest.php | 94 ++------ .../Unit/ThrottleHandlerStackFactoryTest.php | 102 -------- tests/Unit/UnitTestCase.php | 130 ++++++++++ 28 files changed, 1177 insertions(+), 1308 deletions(-) create mode 100644 DEVELOPMENT.md delete mode 100644 INTEGRATION_TESTS.md create mode 100644 src/Middleware/RateLimiterMiddleware.php delete mode 100644 src/ThrottleHandlerStackFactory.php create mode 100644 tests/Integration/Middleware/RateLimiterMiddlewareTest.php create mode 100644 tests/Unit/CalliostroDiscogsBundleTest.php rename tests/Unit/{DiscogsApiClientMockTest.php => DiscogsClientMockTest.php} (72%) delete mode 100644 tests/Unit/ThrottleHandlerStackFactoryTest.php create mode 100644 tests/Unit/UnitTestCase.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1e5842..99a63f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,8 +160,8 @@ jobs: - name: Run static analysis run: | - if [[ "${{ matrix.symfony-version }}" == "^7.4" || "${{ matrix.symfony-version }}" == "^8.0" ]]; then - composer analyse-generics + if [[ "${{ 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 + composer analyse-legacy else composer analyse fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 326439f..e670a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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-beta.1](https://github.com/calliostro/discogs-bundle/releases/tag/v4.0.0-beta.1) โ€“ 2025-09-12 +## [4.0.0-beta.2](https://github.com/calliostro/discogs-bundle/releases/tag/v4.0.0-beta.2) โ€“ 2025-09-14 ### ๐Ÿš€ Complete Rewrite โ€” Fresh Start @@ -13,33 +13,40 @@ This version represents a complete architectural rewrite. v4.0.0 is essentially ### Added -- **Modern PHP 8.1+ Architecture** with full type safety and modern features - **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 -- **All 60 Discogs API Methods** with consistent verb-first naming +- **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 -- **Zero Configuration Mode** for public API access +- **Modern PHP 8.1+ Architecture** with full type safety and modern features - **Comprehensive Test Suite** with unit and integration tests -- **Modern Bundle Structure** following all Symfony best practices - **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 -- **Complete API Integration** now based on `calliostro/php-discogs-api` v4.0.0-beta.1 -- **Service Naming** follows modern Symfony conventions with proper aliases - **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()`) -- **Code Standards** fully compliant with @Symfony and @Symfony:risky rules +- **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` v4.0.0-beta.2 +- **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 -- **Complex Configuration** โ€“ Simplified to essential options only --- diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..6166690 --- /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+ +composer analyse + +# Static analysis without baseline (Symfony < 7.4) +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` or `composer analyse-legacy && composer cs` for Symfony < 7.3) +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/INTEGRATION_TESTS.md b/INTEGRATION_TESTS.md deleted file mode 100644 index dbd9808..0000000 --- a/INTEGRATION_TESTS.md +++ /dev/null @@ -1,152 +0,0 @@ -# Integration Test Setup - -## Test Strategy - -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) - -## Running Tests - -```bash -# Unit tests only (CI default - fast & reliable) -composer test - -# Integration tests only (manual - requires API access) -composer test-integration - -# All tests together (local development) -composer test-all -``` - -## 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: - -Navigate to: **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 - -### Quick Start - -```bash -# Run public tests only (no credentials needed) -vendor/bin/phpunit tests/Integration/PublicApiIntegrationTest.php - -# Run authentication tests (requires env vars below) -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) - -## Getting Credentials - -### Environment Variables Setup - -For authenticated tests, set these environment variables: - -```bash -# PowerShell -$env:DISCOGS_CONSUMER_KEY="your-consumer-key" -$env:DISCOGS_CONSUMER_SECRET="your-consumer-secret" -$env:DISCOGS_PERSONAL_ACCESS_TOKEN="your-personal-access-token" - -# CMD -set DISCOGS_CONSUMER_KEY=your-consumer-key -set DISCOGS_CONSUMER_SECRET=your-consumer-secret -set DISCOGS_PERSONAL_ACCESS_TOKEN=your-personal-access-token -``` - -### 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 - -## Example GitHub Actions Workflow - -```yaml -name: Tests - -on: [push, pull_request] - -jobs: - tests: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 8.1 - - - name: Install dependencies - run: composer install --prefer-dist --no-progress - - - name: Run unit tests - run: composer test-unit - - - name: Run public integration tests - run: composer test-integration-public - - - name: Run authenticated integration tests - run: composer test-integration - env: - DISCOGS_CONSUMER_KEY: ${{ secrets.DISCOGS_CONSUMER_KEY }} - DISCOGS_CONSUMER_SECRET: ${{ secrets.DISCOGS_CONSUMER_SECRET }} - DISCOGS_PERSONAL_ACCESS_TOKEN: ${{ secrets.DISCOGS_PERSONAL_ACCESS_TOKEN }} -``` - -## Bundle-Specific Integration Tests - -These integration tests focus on: - -1. **Bundle Integration**: Test that the Bundle correctly configures the Discogs API client -2. **Symfony Integration**: Verify that services are properly wired -3. **Configuration**: Ensure Bundle configuration is correctly applied -4. **Error Handling**: Test how the Bundle handles API errors -5. **Rate Limiting**: Verify that rate limiting works properly - -## Additional Safety Notes - -- โš ๏ธ Rate limiting may occur with many tests (Discogs API limits) -- ๐Ÿ”„ Tests use exponential backoff retry logic -- ๐Ÿ“Š Bundle throttling is tested (reactive approach) diff --git a/README.md b/README.md index 5119b50..2193215 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,8 @@ calliostro_discogs: # Optional: HTTP User-Agent header for API requests # user_agent: 'MyApp/1.0 +https://myapp.com' - # Optional: Rate limiting (default values shown) - # throttle: - # enabled: true - # microseconds: 1000000 + # Optional: Professional rate limiting (requires symfony/rate-limiter) + # rate_limiter: discogs_api # Your configured RateLimiterFactory service ``` **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. @@ -59,15 +57,15 @@ calliostro_discogs: namespace App\Controller; -use Calliostro\Discogs\DiscogsApiClient; +use Calliostro\Discogs\DiscogsClient; use Symfony\Component\HttpFoundation\JsonResponse; class MusicController { - public function artistInfo(string $id, DiscogsApiClient $client): JsonResponse + public function artistInfo(string $id, DiscogsClient $client): JsonResponse { - $artist = $client->getArtist(['id' => $id]); - $releases = $client->listArtistReleases(['id' => $id, 'per_page' => 5]); + $artist = $client->getArtist(artistId: $id); + $releases = $client->listArtistReleases(artistId: $id, perPage: 5); return new JsonResponse([ 'artist' => $artist['name'], @@ -82,36 +80,37 @@ class MusicController ```php // Requires Personal Access Token -$collection = $client->listCollectionItems(['username' => 'your-username']); -$wantlist = $client->getUserWantlist(['username' => 'your-username']); +$collection = $client->listCollectionItems(username: 'your-username', folderId: 0); +$wantlist = $client->getUserWantlist(username: 'your-username'); -$client->addToCollection([ - 'folder_id' => 1, - 'release_id' => 30359313, // Billie Eilish - Happier Than Ever -]); +$client->addToCollection( + username: 'your-username', + folderId: 1, + releaseId: 30359313 // Billie Eilish - Happier Than Ever +); -$client->addToWantlist(['release_id' => 28409710]); // Taylor Swift - Midnights +$client->addToWantlist(username: 'your-username', releaseId: 28409710); // Taylor Swift - Midnights ``` ### Search and Discovery ```php -$results = $client->search([ - 'q' => 'Billie Eilish', - 'type' => 'artist', -]); - -$releases = $client->listArtistReleases(['id' => '4470662']); // Billie Eilish -$release = $client->getRelease(['id' => '30359313']); // Happier Than Ever -$master = $client->getMaster(['id' => '2835729']); // Midnights master -$label = $client->getLabel(['id' => '12677']); // Interscope Records +$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 ``` ## โœจ Key Features - **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()` maps to `/artists/{id}`, no abstractions +- **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 - **Future-Ready** โ€“ PHP 8.5 and Symfony 8.0 compatible (beta/dev testing) @@ -145,22 +144,22 @@ $label = $client->getLabel(['id' => '12677']); // Interscope Records namespace App\Service; -use Calliostro\Discogs\DiscogsApiClient; +use Calliostro\Discogs\DiscogsClient; class MusicService { public function __construct( - private readonly DiscogsApiClient $client + private readonly DiscogsClient $client ) { } public function getArtistWithReleases(int $artistId): array { - $artist = $this->client->getArtist(['id' => $artistId]); - $releases = $this->client->listArtistReleases([ - 'id' => $artistId, - 'per_page' => 10, - ]); + $artist = $this->client->getArtist(artistId: $artistId); + $releases = $this->client->listArtistReleases( + artistId: $artistId, + perPage: 10 + ); return [ 'artist' => $artist, @@ -171,78 +170,51 @@ class MusicService public function addToMyCollection(int $releaseId): void { // Requires Personal Access Token - $this->client->addToCollection([ - 'folder_id' => 1, // "Uncategorized" folder - 'release_id' => $releaseId, - ]); + $this->client->addToCollection( + username: 'your-username', // Replace with actual username + folderId: 1, // "Uncategorized" folder + releaseId: $releaseId + ); } } ``` -## ๐Ÿงช Testing - -```bash -# Run unit tests (default, fast) -composer test +## โšก Rate Limiting (Optional) -# Integration tests with real API (requires credentials) -composer test-integration +For high-volume applications, use the powerful [symfony/rate-limiter](https://symfony.com/doc/current/rate_limiter.html) component: -# Code analysis & style -composer analyse -composer cs-fix +```bash +composer require symfony/rate-limiter ``` -See [INTEGRATION_TESTS.md](INTEGRATION_TESTS.md) for API test setup. - -## ๐Ÿ“– API Documentation Reference - -For complete API documentation including all available parameters, visit the [Discogs API Documentation](https://www.discogs.com/developers/). - -### Popular Methods +### 1. Configure Rate Limiter -#### Database Methods - -- `search($params)` โ€“ Search the Discogs database -- `getArtist($params)` โ€“ Get artist information -- `listArtistReleases($params)` โ€“ Get artist's releases -- `getRelease($params)` โ€“ Get release information -- `getUserReleaseRating($params)` โ€“ Get user's rating for a release (auth required) -- `getMaster($params)` โ€“ Get master release information -- `getLabel($params)` โ€“ Get label information +```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' +``` -#### Collection Methods +### 2. Configure Bundle -- `listCollectionFolders($params)` โ€“ Get user's collection folders -- `listCollectionItems($params)` โ€“ Get user's collection items -- `addToCollection($params)` โ€“ Add release to a collection (auth required) -- `updateCollectionItem($params)` โ€“ Update collection item (auth required) -- `removeFromCollection($params)` โ€“ Remove from a collection (auth required) -- `getCollectionValue($params)` โ€“ Get collection value estimation +```yaml +# config/packages/calliostro_discogs.yaml +calliostro_discogs: + personal_access_token: '%env(DISCOGS_PERSONAL_ACCESS_TOKEN)%' + rate_limiter: discogs_api +``` -#### User Methods +**Choose your rate limit based on your authentication:** -- `getUser($params)` โ€“ Get user profile information -- `getIdentity($params)` โ€“ Get current user identity (auth required) -- `listUserSubmissions($params)` โ€“ Get user's submissions -- `listUserContributions($params)` โ€“ Get user's contributions -- `getUserWantlist($params)` โ€“ Get user's wantlist -- `getUserInventory($params)` โ€“ Get user's marketplace inventory +- **Anonymous access:** Use 25/min (as shown above) +- **Authenticated only:** Change limit to 60 for maximum performance ## ๐Ÿค Contributing -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Write tests for your changes: - - Add unit tests for new functionality - - Update integration tests if needed - - Ensure all tests pass: `composer test` -4. Follow code standards: `composer analyse && composer cs-fix` -5. Commit your changes (`git commit -m 'Add amazing feature'`) -6. Push to the branch (`git push origin feature/amazing-feature`) -7. Open a Pull Request - -Please ensure your code follows Symfony coding standards and includes tests. +Contributions are welcome! Please see [DEVELOPMENT.md](DEVELOPMENT.md) for detailed setup instructions, testing guide, and development workflow. ## ๐Ÿ“„ License diff --git a/UPGRADE.md b/UPGRADE.md index bb2863e..dd36fed 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,132 +1,72 @@ -# Upgrade Guide - -## ๐Ÿš€ v4.0.0 โ€“ Complete Rewrite +# ๐Ÿš€ Upgrade Guide โ€“ v4.0.0 Complete Rewrite **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. -### ๐Ÿ“ˆ Migration from v2.x +## ๐Ÿ“ˆ Migration from v2.x **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: -#### โšก Why Upgrade? +### Why Upgrade? -- **๐Ÿ“ฆ 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 +- **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 -#### ๐Ÿ”„ Quick Migration Steps +### Quick Migration Steps -1. **Update Composer** โ€“ `composer require calliostro/discogs-bundle:^4.0` -2. **Update Type Hints** โ€“ Change `DiscogsClient` โ†’ `DiscogsApiClient` -3. **Update Service References** โ€“ Change service alias if using container directly -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! +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 New in v4.0.0 +## ๐ŸŽฏ What's New in v4.0.0 -- **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 +- **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 -### ๐Ÿ“‹ System Requirements +## ๐Ÿ“‹ System Requirements - **PHP**: 8.1+ - **Symfony**: 6.4+ | 7.x | 8.x - **calliostro/php-discogs-api**: v4.0.0-beta.1+ -### ๐Ÿ“ฆ Fresh Installation +## ๐Ÿ“ฆ Fresh Installation ```bash composer require calliostro/discogs-bundle:^4.0 ``` -### ๐Ÿš€ Quick Start - -#### 1. Configure the Bundle +## ๐Ÿš€ Quick Migration Overview -```yaml -# config/packages/calliostro_discogs.yaml -calliostro_discogs: - # Personal Access Token (recommended) - personal_access_token: '%env(DISCOGS_PERSONAL_ACCESS_TOKEN)%' - - # Optional: Consumer credentials for OAuth - # consumer_key: '%env(DISCOGS_CONSUMER_KEY)%' - # consumer_secret: '%env(DISCOGS_CONSUMER_SECRET)%' - - # Optional: User-Agent and throttling - # user_agent: 'MyApp/1.0 +https://myapp.com' - # throttle: - # enabled: true - # microseconds: 1000000 -``` - -#### 2. Use in Your Controllers/Services +The key difference: **v4.0 uses named parameters instead of arrays** ```php -getArtist(['id' => $id]); - $releases = $client->listArtistReleases(['id' => $id, 'per_page' => 5]); - - return new JsonResponse([ - 'artist' => $artist['name'], - 'releases' => $releases['releases'] - ]); - } -} -``` - -### ๐Ÿ”‘ Authentication Options - -#### Personal Access Token (Recommended) - -Get your token from [Discogs Developer Settings](https://www.discogs.com/settings/developers): - -```yaml -calliostro_discogs: - personal_access_token: 'your-personal-access-token' -``` - -#### OAuth Consumer Credentials +// v4.0 - Modern approach with named parameters +$artist = $client->getArtist(artistId: $id); +$releases = $client->listArtistReleases(artistId: $id, perPage: 5); -For applications requiring OAuth authentication: - -```yaml -calliostro_discogs: - consumer_key: 'your-consumer-key' - consumer_secret: 'your-consumer-secret' +// v2.x - Old array-based parameters (no longer supported) +// $artist = $client->getArtist(['id' => $id]); +// $releases = $client->getArtistReleases(['id' => $id, 'per_page' => 5]); ``` -#### Anonymous Access +**Configuration is now simpler:** Use Personal Access Token instead of complex OAuth setup. -For public data only (rate limited): +See [README.md](README.md) for complete setup and usage documentation. -```yaml -calliostro_discogs: - user_agent: 'MyApp/1.0 +https://myapp.com' -``` +## ๐Ÿ”„ Key Migration Changes (v2.x โ†’ v4.0) -### ๏ฟฝ Key Migration Changes (v2.x โ†’ v4.0) - -#### Type Hints & Imports +### Type Hints & Imports ```php // v2.x @@ -138,25 +78,15 @@ public function show(DiscogsClient $discogs): Response } // v4.0 -use Calliostro\Discogs\DiscogsApiClient; +use Calliostro\Discogs\DiscogsClient; -public function show(DiscogsApiClient $discogs): Response +public function show(DiscogsClient $discogs): Response { // ... } ``` -#### Service Container Changes - -```php -// v2.x - Old service alias -$discogsClient = $container->get('Discogs\DiscogsClient'); - -// v4.0 - New service alias -$discogsClient = $container->get('Calliostro\Discogs\DiscogsApiClient'); -``` - -#### Configuration Changes +### Configuration Changes ```yaml # v2.x - Complex OAuth setup @@ -172,55 +102,45 @@ calliostro_discogs: personal_access_token: '%env(DISCOGS_PERSONAL_ACCESS_TOKEN)%' ``` -#### Most Common Method Changes +### Most Common Method Changes -| v2.x Method | v4.0 Method | Notes | -|-------------------------------------|-------------------------------------|-----------------------| -| `getProfile(['username' => $user])` | `getUser(['username' => $user])` | Clearer naming | -| `getArtistReleases(['id' => $id])` | `listArtistReleases(['id' => $id])` | Consistent verb-first | -| `getCollectionFolders([...])` | `listCollectionFolders([...])` | Consistent verb-first | -| `getUserWants([...])` | `getUserWantlist([...])` | Clearer naming | -| `getInventory([...])` | `getUserInventory([...])` | More specific | +| 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 Methods - -All 60 Discogs API endpoints are available with consistent verb-first naming: +## ๐Ÿ“– API Changes -**Popular Methods:** - -- `getArtist(['id' => $id])` โ€“ Get artist information -- `listArtistReleases(['id' => $id])` โ€“ Get artist's releases -- `getRelease(['id' => $id])` โ€“ Get release information -- `search(['q' => 'query'])` โ€“ Search the database -- `listCollectionItems(['username' => $username])` โ€“ Get user's collection -- `addToCollection(['folder_id' => 1, 'release_id' => $id])` โ€“ Add to a collection -- `getUserWantlist(['username' => $username])` โ€“ Get user's wantlist +**All 60 Discogs API endpoints** are now available with **consistent verb-first naming** and **named parameters**. See [README.md](README.md) for complete API documentation. -### โœ… Migration Checklist (v2.x โ†’ v4.0) +## โœ… 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\DiscogsApiClient;` -- [ ] **Update type hints**: Find/replace `DiscogsClient` โ†’ `DiscogsApiClient` -- [ ] **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! +- **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 +## ๐Ÿ› ๏ธ Find & Replace Commands These commands help you find code that might need updating: ```bash # Find old type hints and imports -grep -r "DiscogsClient" /path/to/your/project --exclude-dir=vendor +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 @@ -229,27 +149,12 @@ grep -r "getProfile\|getArtistReleases\|getCollectionFolders\|getUserWants\|getI grep -r "oauth:" /path/to/your/config --include="*.yaml" ``` -### ๐Ÿงช Testing - -```bash -# Run tests -composer test - -# Run integration tests (requires API credentials) -composer test-integration - -# Code analysis -composer analyse -composer cs-fix -``` - -### ๐Ÿ“š Documentation +## ๐Ÿ“š Documentation - **Bundle Documentation**: [README.md](README.md) - **API Documentation**: [Discogs API Docs](https://www.discogs.com/developers/) -- **Integration Tests**: [INTEGRATION_TESTS.md](INTEGRATION_TESTS.md) -### ๐Ÿ†˜ Need Help? +## ๐Ÿ†˜ 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) diff --git a/composer.json b/composer.json index 57b9fd2..435a7fd 100644 --- a/composer.json +++ b/composer.json @@ -26,16 +26,20 @@ ], "require": { "php": "^8.1", - "calliostro/php-discogs-api": "v4.0.0-beta.1", + "calliostro/php-discogs-api": "v4.0.0-beta.2", "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" }, + "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" }, "autoload": { "psr-4": { @@ -56,8 +60,8 @@ "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/generic-baseline.neon", + "analyse-legacy": "vendor/bin/phpstan analyse src --level=8" }, "minimum-stability": "stable", "prefer-stable": true diff --git a/src/CalliostroDiscogsBundle.php b/src/CalliostroDiscogsBundle.php index fa57be4..cc5b188 100644 --- a/src/CalliostroDiscogsBundle.php +++ b/src/CalliostroDiscogsBundle.php @@ -1,5 +1,7 @@ extension = new CalliostroDiscogsExtension(); } - // The parent method can return false, but we guarantee to return an extension - $extension = $this->extension; - if (!$extension instanceof ExtensionInterface) { - throw new \LogicException('Extension must implement ExtensionInterface'); - } + \assert($this->extension instanceof ExtensionInterface); - return $extension; + return $this->extension; } } diff --git a/src/DependencyInjection/CalliostroDiscogsExtension.php b/src/DependencyInjection/CalliostroDiscogsExtension.php index b329b96..0ba4008 100644 --- a/src/DependencyInjection/CalliostroDiscogsExtension.php +++ b/src/DependencyInjection/CalliostroDiscogsExtension.php @@ -1,5 +1,7 @@ setFactory(['Calliostro\Discogs\ClientFactory', 'createWithPersonalAccessToken']); + $clientDefinition->setFactory(['Calliostro\\Discogs\\DiscogsClientFactory', 'createWithPersonalAccessToken']); $clientDefinition->setArguments([ $config['personal_access_token'], $this->getClientOptions($container, $config), ]); } elseif (!empty($config['consumer_key']) && !empty($config['consumer_secret'])) { // Consumer credentials authentication - $clientDefinition->setFactory(['Calliostro\Discogs\ClientFactory', 'createWithConsumerCredentials']); + $clientDefinition->setFactory(['Calliostro\\Discogs\\DiscogsClientFactory', 'createWithConsumerCredentials']); $clientDefinition->setArguments([ $config['consumer_key'], $config['consumer_secret'], @@ -59,7 +56,7 @@ private function configureClient(ContainerBuilder $container, array $config): vo ]); } else { // Anonymous client (rate-limited) - $clientDefinition->setFactory(['Calliostro\Discogs\ClientFactory', 'create']); + $clientDefinition->setFactory(['Calliostro\\Discogs\\DiscogsClientFactory', 'create']); $clientDefinition->setArguments([ $this->getClientOptions($container, $config), ]); @@ -77,18 +74,53 @@ private function getClientOptions(ContainerBuilder $container, array $config): a // Only set the User-Agent header if explicitly configured if (!empty($config['user_agent'])) { - $options['headers'] = [ - 'User-Agent' => $config['user_agent'], - ]; + $options['headers'] = ['User-Agent' => $config['user_agent']]; } - // Add throttling handler if enabled - if ($config['throttle']['enabled']) { - $throttleHandlerDefinition = $container->getDefinition('calliostro_discogs.throttle_handler_stack'); - $throttleHandlerDefinition->replaceArgument(0, (int) $config['throttle']['microseconds']); - $options['handler'] = new Reference('calliostro_discogs.throttle_handler_stack'); + // Configure rate limiting if requested + if (!empty($config['rate_limiter'])) { + $this->configureSymfonyRateLimiter($container, $config['rate_limiter'], $options); } return $options; } + + /** + * Configure Symfony Rate Limiter integration. + * + * @param array &$options + */ + private function configureSymfonyRateLimiter(ContainerBuilder $container, string $rateLimiterService, array &$options): void + { + // 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'); + } + + /** + * 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 6894e11..90ef5dd 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -1,15 +1,12 @@ children() - ->scalarNode('personal_access_token') - ->info('Your personal access token (recommended - get from https://www.discogs.com/settings/developers)') - ->validate() - ->ifTrue(fn ($v) => \is_string($v) && '' === trim($v)) - ->thenInvalid('Personal access token cannot be empty') - ->end() - ->validate() - ->ifTrue(fn ($v) => \is_string($v) && '' !== trim($v) && \strlen(trim($v)) < 10) - ->thenInvalid('Personal access token must be at least 10 characters') - ->end() - ->end() - ->scalarNode('consumer_key') - ->info('Your consumer key (alternative for OAuth applications)') - ->validate() - ->ifTrue(fn ($v) => \is_string($v) && '' === trim($v)) - ->thenInvalid('Consumer key cannot be empty') - ->end() - ->end() - ->scalarNode('consumer_secret') - ->info('Your consumer secret (alternative for OAuth applications)') - ->validate() - ->ifTrue(fn ($v) => \is_string($v) && '' === trim($v)) - ->thenInvalid('Consumer secret cannot be empty') - ->end() - ->end() - ->scalarNode('user_agent') - ->defaultNull() - ->info('HTTP User-Agent header for API requests (optional)') - ->validate() - ->ifTrue(fn ($v) => \is_string($v) && \strlen($v) > 200) - ->thenInvalid('User-Agent cannot be longer than 200 characters') - ->end() - ->end() - ->arrayNode('throttle') - ->addDefaultsIfNotSet() - ->children() - ->booleanNode('enabled') - ->defaultTrue() - ->info('Rate limiting - retries HTTP 429 with exponential backoff') - ->end() - ->integerNode('microseconds') - ->defaultValue(1000000) - ->info('Number of microseconds to wait until the next attempt when rate limit is reached') - ->validate() - ->ifTrue(fn ($v) => $v < 0) - ->thenInvalid('Throttle microseconds must be a positive integer') - ->end() - ->validate() - ->ifTrue(fn ($v) => $v > 60000000) // 60 seconds max - ->thenInvalid('Throttle microseconds cannot exceed 60 seconds (60000000 microseconds)') - ->end() - ->end() - ->end() - ->end() - + ->scalarNode('personal_access_token') + ->info('Your personal access token (recommended - get from https://www.discogs.com/settings/developers)') + ->validate() + ->ifTrue(fn ($v) => \is_string($v) && '' === trim($v)) + ->thenInvalid('Personal access token cannot be empty') + ->end() + ->validate() + ->ifTrue(fn ($v) => \is_string($v) && '' !== trim($v) && \strlen(trim($v)) < 10) + ->thenInvalid('Personal access token must be at least 10 characters') + ->end() + ->end() + ->scalarNode('consumer_key') + ->info('Your consumer key (alternative for OAuth applications)') + ->validate() + ->ifTrue(fn ($v) => \is_string($v) && '' === trim($v)) + ->thenInvalid('Consumer key cannot be empty') + ->end() + ->end() + ->scalarNode('consumer_secret') + ->info('Your consumer secret (alternative for OAuth applications)') + ->validate() + ->ifTrue(fn ($v) => \is_string($v) && '' === trim($v)) + ->thenInvalid('Consumer secret cannot be empty') + ->end() + ->end() + ->scalarNode('user_agent') + ->defaultNull() + ->info('HTTP User-Agent header for API requests (optional)') + ->validate() + ->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/Middleware/RateLimiterMiddleware.php b/src/Middleware/RateLimiterMiddleware.php new file mode 100644 index 0000000..ae4e6a0 --- /dev/null +++ b/src/Middleware/RateLimiterMiddleware.php @@ -0,0 +1,49 @@ +rateLimiterFactory->create($this->limiterKey); + + // Try to consume from rate limiter + $limit = $rateLimiter->consume(1); + + if (!$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/Resources/config/services.xml b/src/Resources/config/services.xml index 1922585..c8a632b 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -1,23 +1,17 @@ - - - - - - - - - - null + + + - + diff --git a/src/ThrottleHandlerStackFactory.php b/src/ThrottleHandlerStackFactory.php deleted file mode 100644 index beb74c5..0000000 --- a/src/ThrottleHandlerStackFactory.php +++ /dev/null @@ -1,45 +0,0 @@ -push( - Middleware::retry( - function (int $retries, RequestInterface $request, ?ResponseInterface $response = null): bool { - // Retry on rate limit (429) up to 3 times - return $retries < 3 && $response && 429 === $response->getStatusCode(); - }, - function (int $retries) use ($microseconds): int { - // Exponential backoff delay - return (int) (($microseconds / 1000000) * 2 ** $retries * 1000); - } - ), - 'throttle' - ); - } - - return $handler; - } -} diff --git a/tests/Fixtures/TestKernel.php b/tests/Fixtures/TestKernel.php index 935633e..32fabc7 100644 --- a/tests/Fixtures/TestKernel.php +++ b/tests/Fixtures/TestKernel.php @@ -38,6 +38,36 @@ public function __construct( 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 */ @@ -66,43 +96,11 @@ public function registerContainerConfiguration(LoaderInterface $loader): void }); } - public function getCacheDir(): string - { - return $this->getProjectDir().'/var/cache/'. - $this->environment.'/'. - md5(serialize($this->calliostroDiscogsConfig)).'/'. - spl_object_hash($this); - } - public function getLogDir(): string { return $this->getProjectDir().'/var/log/'.$this->environment; } - /** - * Helper method to create a kernel for specific test scenarios. - */ - public static function createForIntegration(array $config = []): self - { - return new self($config, 'integration_test'); - } - - /** - * Helper method to create a kernel for unit test scenarios. - */ - public static function createForUnit(array $config = []): self - { - return new self($config, 'unit_test'); - } - - /** - * Helper method to create a kernel for functional test scenarios. - */ - public static function createForFunctional(array $config = []): self - { - return new self($config, 'functional_test'); - } - /** * Cleanup method to remove the test cache after test execution. */ @@ -114,6 +112,14 @@ public function cleanupCache(): void } } + 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. */ diff --git a/tests/Integration/AuthenticatedApiIntegrationTest.php b/tests/Integration/AuthenticatedApiIntegrationTest.php index 9653eb2..bc96c07 100644 --- a/tests/Integration/AuthenticatedApiIntegrationTest.php +++ b/tests/Integration/AuthenticatedApiIntegrationTest.php @@ -29,34 +29,6 @@ final class AuthenticatedApiIntegrationTest extends IntegrationTestCase private string $oauthToken; private string $oauthTokenSecret; - 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(); - } - - /** - * Helper to skip test if credentials are invalid. - */ - private function skipIfInvalidCredentials(\Exception $e): void - { - if ($e instanceof \GuzzleHttp\Exception\ClientException - && $e->getResponse() - && 401 === $e->getResponse()->getStatusCode()) { - $this->markTestSkipped('Invalid credentials provided - test requires valid Discogs API credentials'); - } - throw $e; - } - /** * Level 2: Consumer Credentials - Search enabled through Bundle. */ @@ -70,15 +42,14 @@ public function testLevel2ConsumerCredentials(): void $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(['id' => '1']); - $this->assertIsArray($artist); + $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->assertIsArray($searchResults); + $searchResults = $client->search(q: 'Daft Punk', type: 'artist'); $this->assertArrayHasKey('results', $searchResults); $this->assertGreaterThan(0, \count($searchResults['results'])); } @@ -95,51 +66,47 @@ public function testLevel3PersonalAccessToken(): void $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(['id' => '1']); - $this->assertIsArray($artist); + $artist = $client->getArtist(artistId: 1); + $this->assertArrayHasKey('name', $artist); - $searchResults = $client->search(['q' => 'Jazz', 'type' => 'release']); - $this->assertIsArray($searchResults); + $searchResults = $client->search(q: 'Jazz', type: 'release'); $this->assertArrayHasKey('results', $searchResults); // Test that we can successfully make authenticated requests - $this->assertIsArray($searchResults); $this->assertNotEmpty($searchResults['results']); } /** - * Test rate limiting behavior with authenticated requests through Bundle. + * Test ultra-lightweight bundle behavior with authenticated requests. + * Bundle has no built-in throttling - rate limiting via Symfony component if needed. */ - public function testRateLimitingWithAuthentication(): void + public function testUltraLightweightWithAuthentication(): void { $kernel = $this->createKernel([ 'personal_access_token' => $this->personalToken, - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 500000, // 0.5 seconds for authenticated requests - ], ]); $kernel->boot(); $container = $kernel->getContainer(); $client = $container->get('calliostro_discogs.discogs_client'); + \assert($client instanceof \Calliostro\Discogs\DiscogsClient); // Make several requests in quick succession - // Authenticated requests have higher rate limits + // No built-in throttling overhead $startTime = microtime(true); for ($i = 0; $i < 3; ++$i) { - $artist = $client->getArtist(['id' => (string) (1 + $i)]); - $this->assertIsArray($artist); + $artist = $client->getArtist(artistId: 1 + $i); $this->assertArrayHasKey('name', $artist); } $endTime = microtime(true); $duration = $endTime - $startTime; - // With authentication, this should complete quickly (< 3 seconds) - $this->assertLessThan(3.0, $duration, 'Authenticated requests took too long - possible rate limiting issue'); + // Ultra-lightweight bundle should complete quickly without throttling overhead + $this->assertLessThan(3.0, $duration, 'Ultra-lightweight bundle took too long'); } /** @@ -155,7 +122,7 @@ public function testLevel4OAuthDirectLibraryUsage(): void } // Create the OAuth client directly via the library (not Bundle config) - $client = \Calliostro\Discogs\ClientFactory::createWithOAuth( + $client = \Calliostro\Discogs\DiscogsClientFactory::createWithOAuth( $this->consumerKey, $this->consumerSecret, $this->oauthToken, @@ -165,7 +132,6 @@ public function testLevel4OAuthDirectLibraryUsage(): void // Test identity endpoint (OAuth-specific functionality) try { $identity = $client->getIdentity(); - $this->assertIsArray($identity); $this->assertArrayHasKey('username', $identity); $this->assertNotEmpty($identity['username']); } catch (\Exception $e) { @@ -174,8 +140,7 @@ public function testLevel4OAuthDirectLibraryUsage(): void // Test search with OAuth (should work like other auth methods) try { - $searchResults = $client->search(['q' => 'Electronic', 'type' => 'artist', 'per_page' => 5]); - $this->assertIsArray($searchResults); + $searchResults = $client->search(q: 'Electronic', type: 'artist', perPage: 5); $this->assertArrayHasKey('results', $searchResults); $this->assertGreaterThan(0, \count($searchResults['results'])); } catch (\Exception $e) { @@ -183,6 +148,17 @@ public function testLevel4OAuthDirectLibraryUsage(): void } } + /** + * 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. */ @@ -192,20 +168,32 @@ public function testErrorHandlingAcrossAuthLevels(): void $kernel = $this->createKernel([ 'consumer_key' => $this->consumerKey, 'consumer_secret' => $this->consumerSecret, - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 1000000, // 1 second for consumer credential requests - ], ]); $kernel->boot(); $container = $kernel->getContainer(); $client = $container->get('calliostro_discogs.discogs_client'); + \assert($client instanceof \Calliostro\Discogs\DiscogsClient); try { - $client->getArtist(['id' => '999999999']); // Non-existent artist + $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 index fbafc8e..52d1ac8 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -2,7 +2,7 @@ namespace Calliostro\DiscogsBundle\Tests\Integration; -use Calliostro\Discogs\DiscogsApiClient; +use Calliostro\Discogs\DiscogsClient; use Calliostro\DiscogsBundle\Tests\Fixtures\TestKernel; use GuzzleHttp\Exception\ClientException; use PHPUnit\Framework\TestCase; @@ -15,7 +15,7 @@ */ abstract class IntegrationTestCase extends TestCase { - protected DiscogsApiClient $client; + protected DiscogsClient $client; /** * Create a test kernel with the given configuration. 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 index 07cbe61..130581c 100644 --- a/tests/Integration/PublicApiIntegrationTest.php +++ b/tests/Integration/PublicApiIntegrationTest.php @@ -16,47 +16,26 @@ */ final class PublicApiIntegrationTest extends IntegrationTestCase { - protected function setUp(): void - { - parent::setUp(); - - $kernel = $this->createKernel([ - 'user_agent' => 'CalliostroDiscogsBundle/IntegrationTest', - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 1000000, // 1 second between requests - ], - ]); - $kernel->boot(); - $container = $kernel->getContainer(); - - $this->client = $container->get('calliostro_discogs.discogs_client'); - } - /** * Test basic database methods that should always work through Bundle. */ public function testBasicDatabaseMethods(): void { // Test artist (using ID from original tests) - $artist = $this->client->getArtist(['id' => '139250']); - $this->assertIsArray($artist); + $artist = $this->client->getArtist(artistId: 139250); $this->assertArrayHasKey('name', $artist); // Test release - Billie Eilish - Happier Than Ever (2021) - $release = $this->client->getRelease(['id' => '19676596']); - $this->assertIsArray($release); + $release = $this->client->getRelease(releaseId: 19676596); $this->assertArrayHasKey('title', $release); $this->assertStringContainsString('Happier Than Ever', $release['title']); - // Test master - Abbey Road - $master = $this->client->getMaster(['id' => '18512']); - $this->assertIsArray($master); + // Test master - Billie Eilish - Happier Than Ever (2021) + $master = $this->client->getMaster(masterId: 2234794); $this->assertArrayHasKey('title', $master); // Test label - $label = $this->client->getLabel(['id' => '1']); - $this->assertIsArray($label); + $label = $this->client->getLabel(labelId: 1); $this->assertArrayHasKey('name', $label); } @@ -65,14 +44,12 @@ public function testBasicDatabaseMethods(): void */ public function testCommunityReleaseRating(): void { - $rating = $this->client->getCommunityReleaseRating(['release_id' => '19676596']); + $rating = $this->client->getCommunityReleaseRating(releaseId: 19676596); - $this->assertIsArray($rating); $this->assertArrayHasKey('rating', $rating); $this->assertArrayHasKey('release_id', $rating); $this->assertEquals(19676596, $rating['release_id']); - $this->assertIsArray($rating['rating']); $this->assertArrayHasKey('average', $rating['rating']); $this->assertArrayHasKey('count', $rating['rating']); } @@ -82,9 +59,8 @@ public function testCommunityReleaseRating(): void */ public function testCollectionStatsInReleaseEndpoint(): void { - $release = $this->client->getRelease(['id' => '19676596']); + $release = $this->client->getRelease(releaseId: 19676596); - $this->assertIsArray($release); $this->assertArrayHasKey('community', $release); $this->assertArrayHasKey('have', $release['community']); $this->assertArrayHasKey('want', $release['community']); @@ -96,43 +72,37 @@ public function testCollectionStatsInReleaseEndpoint(): void } /** - * Test Bundle's reactive throttling functionality. - * The Bundle uses reactive throttling - it retries on HTTP 429 with exponential backoff. - * This test verifies the configuration works by making multiple rapid requests. + * 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 testBundleReactiveThrottling(): void + public function testBundleBasicFunctionality(): void { - // Create a kernel with throttling explicitly enabled + // Create a kernel with minimal configuration $kernel = $this->createKernel([ 'user_agent' => 'CalliostroDiscogsBundle/IntegrationTest', - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 1000000, // 1 second - ], ]); $kernel->boot(); $container = $kernel->getContainer(); - // Note: After boot, the container is compiled - no hasDefinition() method - // But we can verify the client works with throttling configured + // Verify the client works without any rate limiting $client = $container->get('calliostro_discogs.discogs_client'); + \assert($client instanceof \Calliostro\Discogs\DiscogsClient); - // Make requests rapidly - Bundle should handle any rate limits gracefully + // Make requests - Bundle is ultra-lightweight with no built-in throttling $responses = []; for ($i = 0; $i < 2; ++$i) { - $responses[] = $client->getArtist(['id' => (string) (1 + $i)]); + $responses[] = $client->getArtist(artistId: 1 + $i); } - // All requests should succeed - Bundle's reactive throttling handles 429 errors + // All requests should succeed $this->assertCount(2, $responses); foreach ($responses as $response) { - $this->assertIsArray($response); $this->assertArrayHasKey('name', $response); } - // The Bundle's throttling configuration ensures requests succeed - // even if the API returns rate limit errors (429) - $this->assertTrue(true, 'Bundle throttling allows rapid requests to succeed'); + // Bundle is ultra-lightweight - no built-in throttling overhead + $this->addToAssertionCount(1); // Ultra-lightweight bundle allows rapid requests } /** @@ -141,6 +111,21 @@ public function testBundleReactiveThrottling(): void public function testBundleErrorHandling(): void { $this->expectException(\Exception::class); - $this->client->getArtist(['id' => '999999999']); + $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/Unit/BundleEdgeCasesTest.php b/tests/Unit/BundleEdgeCasesTest.php index bcf7177..43b327f 100644 --- a/tests/Unit/BundleEdgeCasesTest.php +++ b/tests/Unit/BundleEdgeCasesTest.php @@ -1,43 +1,36 @@ createContainerBuilder(); $extension = new CalliostroDiscogsExtension(); // Empty config should work with defaults $extension->load([], $container); - $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); + $this->assertDefinitionExists($container, 'calliostro_discogs.discogs_client'); } public function testExtensionHandlesNestedEmptyArrays(): void { - $container = new ContainerBuilder(); + $container = $this->createContainerBuilder(); $extension = new CalliostroDiscogsExtension(); - $config = [ - [ - 'throttle' => [], - ], - ]; + $config = [[]]; $extension->load($config, $container); - $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); - $this->assertTrue($container->hasDefinition('calliostro_discogs.throttle_handler_stack')); + $this->assertDefinitionExists($container, 'calliostro_discogs.discogs_client'); } public function testKernelHandlesCacheDirectoryCreation(): void @@ -74,33 +67,13 @@ public function testKernelHandlesMultipleBootCalls(): void $kernel->cleanupCache(); } - public function testBundleWithValidThrottleMicroseconds(): void - { - // Test with valid positive microseconds value - $config = [ - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 500000, // 0.5 seconds - valid - ], - ]; - - $kernel = TestKernel::createForFunctional($config); - $kernel->boot(); - $container = $kernel->getContainer(); - - $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertNotNull($client); - - $kernel->cleanupCache(); - } - - public function testBundleWithZeroMicroseconds(): void + public function testBundleWithMixedConfiguration(): void { + // Test with multiple configuration options $config = [ - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 0, - ], + 'user_agent' => 'MixedConfig/1.0', + 'consumer_key' => 'test_key_12345678901234567890', + 'consumer_secret' => 'test_secret_12345678901234567890', ]; $kernel = TestKernel::createForFunctional($config); @@ -108,14 +81,14 @@ public function testBundleWithZeroMicroseconds(): void $container = $kernel->getContainer(); $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertNotNull($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) + // 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 = [ @@ -127,7 +100,7 @@ public function testBundleWithValidLongUserAgent(): void $container = $kernel->getContainer(); $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertNotNull($client); + $this->assertInstanceOf(\Calliostro\Discogs\DiscogsClient::class, $client); $kernel->cleanupCache(); } @@ -145,7 +118,7 @@ public function testBundleWithSpecialCharactersInCredentials(): void $container = $kernel->getContainer(); $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertNotNull($client); + $this->assertInstanceOf(\Calliostro\Discogs\DiscogsClient::class, $client); $kernel->cleanupCache(); } @@ -163,7 +136,7 @@ public function testBundleWithNumericStringCredentials(): void $container = $kernel->getContainer(); $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertNotNull($client); + $this->assertInstanceOf(\Calliostro\Discogs\DiscogsClient::class, $client); $kernel->cleanupCache(); } @@ -182,7 +155,7 @@ public function testMultipleKernelCleanupDoesNotFail(): void // Third cleanup should not fail $kernel->cleanupCache(); - $this->assertTrue(true); // If we get here, no exceptions were thrown + $this->addToAssertionCount(1); // If we get here, no exceptions were thrown } public function testKernelWithNonExistentCacheDirectoryParent(): void @@ -211,7 +184,7 @@ public function testBundleHandlesValidMinimalValues(): void // Should work with minimal valid config $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertNotNull($client); + $this->assertInstanceOf(\Calliostro\Discogs\DiscogsClient::class, $client); $kernel->cleanupCache(); } @@ -229,7 +202,7 @@ public function testConfigurationTreeBuilderRootName(): void $extension = new CalliostroDiscogsExtension(); $config = $extension->getConfiguration([], new ContainerBuilder()); - $this->assertNotNull($config); + $this->assertInstanceOf(\Calliostro\DiscogsBundle\DependencyInjection\Configuration::class, $config); $treeBuilder = $config->getConfigTreeBuilder(); $tree = $treeBuilder->buildTree(); diff --git a/tests/Unit/BundleIntegrationTest.php b/tests/Unit/BundleIntegrationTest.php index 4dcaf89..a91abfd 100644 --- a/tests/Unit/BundleIntegrationTest.php +++ b/tests/Unit/BundleIntegrationTest.php @@ -1,28 +1,19 @@ boot(); - $container = $kernel->getContainer(); - - // Verify core service is available - $this->assertTrue($container->has('calliostro_discogs.discogs_client')); - $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $client); + $container = $this->bootKernelAndGetContainer(); - $kernel->cleanupCache(); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); } public function testBundleWithAllConfigurationOptions(): void @@ -32,123 +23,63 @@ public function testBundleWithAllConfigurationOptions(): void 'consumer_key' => 'test_consumer_key', 'consumer_secret' => 'test_consumer_secret', 'personal_access_token' => 'test_personal_token', - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 750000, - ], ]; - $kernel = TestKernel::createForFunctional($config); - $kernel->boot(); - $container = $kernel->getContainer(); - - // Verify services are properly configured - $this->assertTrue($container->has('calliostro_discogs.discogs_client')); - // Note: throttle_handler_stack is private and not available in a compiled container - - $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $client); - - $kernel->cleanupCache(); + $container = $this->bootKernelAndGetContainer($config); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); } - public function testBundleWithThrottleDisabled(): void + public function testBundleWithAdvancedUserAgent(): void { $config = [ - 'throttle' => [ - 'enabled' => false, - ], + 'user_agent' => 'AdvancedTestApp/2.0 +https://test.example.com', ]; - - $kernel = TestKernel::createForFunctional($config); - $kernel->boot(); - $container = $kernel->getContainer(); - - $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $client); - - $kernel->cleanupCache(); + $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', - ]; - - $kernel = TestKernel::createForFunctional($config); - $kernel->boot(); - $container = $kernel->getContainer(); - - $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $client); - - $kernel->cleanupCache(); + $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', - ]; - - $kernel = TestKernel::createForFunctional($config); - $kernel->boot(); - $container = $kernel->getContainer(); - - $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $client); - - $kernel->cleanupCache(); + $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', - ]; - - $kernel = TestKernel::createForFunctional($config); - $kernel->boot(); - $container = $kernel->getContainer(); - - $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $client); - - $kernel->cleanupCache(); + $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 { - $kernel = TestKernel::createForFunctional([ - 'consumer_key' => 'test', - 'consumer_secret' => 'test', - ]); - $kernel->boot(); - $container = $kernel->getContainer(); + $config = ['consumer_key' => 'test', 'consumer_secret' => 'test']; + $container = $this->bootKernelAndGetContainer($config); // The main service should be public for injection - $this->assertTrue($container->has('calliostro_discogs.discogs_client')); + $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) - $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $client); - - $kernel->cleanupCache(); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); } public function testMultipleBundleInstancesIsolation(): void { $kernel1 = TestKernel::createForFunctional([ 'user_agent' => 'App1/1.0', - 'throttle' => ['enabled' => true], ]); $kernel2 = TestKernel::createForFunctional([ 'user_agent' => 'App2/2.0', - 'throttle' => ['enabled' => false], ]); $kernel1->boot(); @@ -157,8 +88,8 @@ public function testMultipleBundleInstancesIsolation(): void $client1 = $kernel1->getContainer()->get('calliostro_discogs.discogs_client'); $client2 = $kernel2->getContainer()->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $client1); - $this->assertInstanceOf(DiscogsApiClient::class, $client2); + $this->assertInstanceOf(DiscogsClient::class, $client1); + $this->assertInstanceOf(DiscogsClient::class, $client2); // Clients should be different instances $this->assertNotSame($client1, $client2); @@ -172,10 +103,6 @@ public function testBundleServiceDefinitionStructure(): void $kernel = TestKernel::createForFunctional([ 'consumer_key' => 'test_key', 'consumer_secret' => 'test_secret', - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 500000, - ], ]); $kernel->boot(); @@ -183,7 +110,7 @@ public function testBundleServiceDefinitionStructure(): void // Test that we can retrieve the main service $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $client); + $this->assertInstanceOf(DiscogsClient::class, $client); // Test service is singleton (same instance returned) $client2 = $container->get('calliostro_discogs.discogs_client'); @@ -198,10 +125,6 @@ public function testBundleParameterHandling(): void 'user_agent' => 'ParameterTest/1.0', 'consumer_key' => 'param_key', 'consumer_secret' => 'param_secret', - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 2000000, // 2 seconds - ], ]; $kernel = TestKernel::createForFunctional($config); @@ -210,7 +133,7 @@ public function testBundleParameterHandling(): void // Verify the client is created successfully with all parameters $client = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $client); + $this->assertInstanceOf(DiscogsClient::class, $client); $kernel->cleanupCache(); } @@ -220,12 +143,10 @@ public function testBundleEnvironmentSeparation(): void // Test that different environments can have different configurations $prodConfig = [ 'user_agent' => 'ProdApp/1.0', - 'throttle' => ['enabled' => true, 'microseconds' => 1000000], ]; $testConfig = [ 'user_agent' => 'TestApp/1.0', - 'throttle' => ['enabled' => false], ]; $prodKernel = new TestKernel($prodConfig, 'prod'); @@ -237,8 +158,8 @@ public function testBundleEnvironmentSeparation(): void $prodClient = $prodKernel->getContainer()->get('calliostro_discogs.discogs_client'); $testClient = $testKernel->getContainer()->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $prodClient); - $this->assertInstanceOf(DiscogsApiClient::class, $testClient); + $this->assertInstanceOf(DiscogsClient::class, $prodClient); + $this->assertInstanceOf(DiscogsClient::class, $testClient); $this->assertNotSame($prodClient, $testClient); $prodKernel->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 index dbd6587..8a53983 100644 --- a/tests/Unit/CalliostroDiscogsExtensionTest.php +++ b/tests/Unit/CalliostroDiscogsExtensionTest.php @@ -1,99 +1,122 @@ createContainerBuilder(); $extension = new CalliostroDiscogsExtension(); $extension->load([], $container); - $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); + $this->assertDefinitionExists($container, 'calliostro_discogs.discogs_client'); } - public function testLoadWithThrottleEnabled(): void + public function testLoadWithRateLimiter(): void { - $container = new ContainerBuilder(); + if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { + $this->markTestSkipped('symfony/rate-limiter is not installed'); + } + + $container = $this->createContainerBuilder(); $extension = new CalliostroDiscogsExtension(); $config = [ [ - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 500000, - ], + 'rate_limiter' => 'my_rate_limiter_service', ], ]; $extension->load($config, $container); - // In v4.0.0, throttling is handled differently - no separate subscriber - $this->assertTrue($container->hasDefinition('calliostro_discogs.throttle_handler_stack')); + // 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->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); + $this->assertDefinitionExists($container, 'calliostro_discogs.discogs_client'); } - public function testLoadWithConsumerKeyAndSecretOnly(): void + /** + * @runInSeparateProcess + * + * @preserveGlobalState disabled + */ + public function testLoadWithRateLimiterWhenComponentNotAvailable(): void { - $container = new ContainerBuilder(); + // 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 = [ [ - 'consumer_key' => 'test_key', - 'consumer_secret' => 'test_secret', + 'rate_limiter' => 'my_rate_limiter_service', ], ]; - $extension->load($config, $container); - - $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); - - // Check that the client is configured with proper factory method and arguments - $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); - $factory = $clientDefinition->getFactory(); - $this->assertEquals(['Calliostro\Discogs\ClientFactory', 'createWithConsumerCredentials'], $factory); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('To use the rate_limiter configuration, you must install symfony/rate-limiter. Run: composer require symfony/rate-limiter'); - // Check that the consumer key and secret are passed as arguments - $arguments = $clientDefinition->getArguments(); - $this->assertCount(3, $arguments); - $this->assertEquals('test_key', $arguments[0]); - $this->assertEquals('test_secret', $arguments[1]); + $extension->load($config, $container); } - public function testLoadWithThrottleDisabled(): void + public function testLoadWithConsumerKeyAndSecretOnly(): void { - $container = new ContainerBuilder(); + $container = $this->createContainerBuilder(); $extension = new CalliostroDiscogsExtension(); $config = [ [ - 'throttle' => [ - 'enabled' => false, - ], + 'consumer_key' => 'test_key', + 'consumer_secret' => 'test_secret', ], ]; $extension->load($config, $container); - $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); - // When the throttle is disabled, the basic client factory should be used - $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); - $factory = $clientDefinition->getFactory(); - $this->assertEquals(['Calliostro\Discogs\ClientFactory', 'create'], $factory); + $this->assertDefinitionHasFactory($container, 'calliostro_discogs.discogs_client', + ['Calliostro\Discogs\DiscogsClientFactory', 'createWithConsumerCredentials']); + $this->assertDefinitionArgumentCount($container, 'calliostro_discogs.discogs_client', 3); + $this->assertDefinitionArgumentEquals($container, 'calliostro_discogs.discogs_client', 0, 'test_key'); + $this->assertDefinitionArgumentEquals($container, 'calliostro_discogs.discogs_client', 1, 'test_secret'); + } + + public function testLoadWithoutRateLimiter(): void + { + $container = $this->createContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + $config = [[]]; // Empty configuration + + $extension->load($config, $container); + + // When no rate limiter is configured, the basic client factory should be used + $this->assertDefinitionHasFactory($container, 'calliostro_discogs.discogs_client', + ['Calliostro\Discogs\DiscogsClientFactory', 'create']); } public function testLoadWithPersonalAccessToken(): void { - $container = new ContainerBuilder(); + $container = $this->createContainerBuilder(); $extension = new CalliostroDiscogsExtension(); $config = [ @@ -104,22 +127,15 @@ public function testLoadWithPersonalAccessToken(): void $extension->load($config, $container); - $this->assertTrue($container->hasDefinition('calliostro_discogs.discogs_client')); - - // Check that the client is configured with a personal access token factory - $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); - $factory = $clientDefinition->getFactory(); - $this->assertEquals(['Calliostro\Discogs\ClientFactory', 'createWithPersonalAccessToken'], $factory); - - // Check that the token is passed as the first argument - $arguments = $clientDefinition->getArguments(); - $this->assertCount(2, $arguments); - $this->assertEquals('test_token_123', $arguments[0]); + $this->assertDefinitionHasFactory($container, 'calliostro_discogs.discogs_client', + ['Calliostro\Discogs\DiscogsClientFactory', 'createWithPersonalAccessToken']); + $this->assertDefinitionArgumentCount($container, 'calliostro_discogs.discogs_client', 2); + $this->assertDefinitionArgumentEquals($container, 'calliostro_discogs.discogs_client', 0, 'test_token_123'); } public function testLoadWithCustomUserAgent(): void { - $container = new ContainerBuilder(); + $container = $this->createContainerBuilder(); $extension = new CalliostroDiscogsExtension(); $config = [ @@ -130,11 +146,11 @@ public function testLoadWithCustomUserAgent(): void $extension->load($config, $container); - $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); - $factory = $clientDefinition->getFactory(); - $this->assertEquals(['Calliostro\Discogs\ClientFactory', 'create'], $factory); + $this->assertDefinitionHasFactory($container, 'calliostro_discogs.discogs_client', + ['Calliostro\Discogs\DiscogsClientFactory', 'create']); - $arguments = $clientDefinition->getArguments(); + $definition = $container->getDefinition('calliostro_discogs.discogs_client'); + $arguments = $definition->getArguments(); $this->assertIsArray($arguments[0]); $this->assertArrayHasKey('headers', $arguments[0]); $this->assertEquals('CustomAgent/1.0', $arguments[0]['headers']['User-Agent']); @@ -142,7 +158,7 @@ public function testLoadWithCustomUserAgent(): void public function testLoadWithPersonalAccessTokenAndUserAgent(): void { - $container = new ContainerBuilder(); + $container = $this->createContainerBuilder(); $extension = new CalliostroDiscogsExtension(); $config = [ @@ -154,20 +170,45 @@ public function testLoadWithPersonalAccessTokenAndUserAgent(): void $extension->load($config, $container); - $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); - $factory = $clientDefinition->getFactory(); - $this->assertEquals(['Calliostro\Discogs\ClientFactory', 'createWithPersonalAccessToken'], $factory); + $this->assertDefinitionHasFactory($container, 'calliostro_discogs.discogs_client', + ['Calliostro\Discogs\DiscogsClientFactory', 'createWithPersonalAccessToken']); + $this->assertDefinitionArgumentCount($container, 'calliostro_discogs.discogs_client', 2); + $this->assertDefinitionArgumentEquals($container, 'calliostro_discogs.discogs_client', 0, 'test_token_123'); - // Check that arguments include token and options - $arguments = $clientDefinition->getArguments(); - $this->assertCount(2, $arguments); // Token + options - $this->assertEquals('test_token_123', $arguments[0]); - $this->assertIsArray($arguments[1]); // Options - - // Check that user_agent is embedded in options headers + $definition = $container->getDefinition('calliostro_discogs.discogs_client'); + $arguments = $definition->getArguments(); $options = $arguments[1]; $this->assertArrayHasKey('headers', $options); - $this->assertArrayHasKey('User-Agent', $options['headers']); $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[1] ?? []; // Second argument for personal access token factory + + // 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 index 5eee660..57383e3 100644 --- a/tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -1,5 +1,7 @@ configuration = new Configuration(); - $this->processor = new Processor(); - } - public function testEmptyConfiguration(): void { - $config = $this->processor->processConfiguration($this->configuration, []); + $configs = [[]]; - $expected = [ - 'user_agent' => null, - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 1000000, - ], - ]; + $config = $this->processor->processConfiguration($this->configuration, $configs); - $this->assertEquals($expected, $config); + $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 @@ -43,8 +37,7 @@ public function testConfigurationWithUserAgent(): void $config = $this->processor->processConfiguration($this->configuration, $configs); $this->assertEquals('MyApp/1.0', $config['user_agent']); - $this->assertTrue($config['throttle']['enabled']); - $this->assertEquals(1000000, $config['throttle']['microseconds']); + $this->assertArrayNotHasKey('throttle', $config); } public function testConfigurationWithConsumerCredentials(): void @@ -78,53 +71,18 @@ public function testConfigurationWithPersonalAccessToken(): void $this->assertArrayNotHasKey('consumer_secret', $config); } - public function testThrottleConfiguration(): void - { - $configs = [ - [ - 'throttle' => [ - 'enabled' => false, - 'microseconds' => 500000, - ], - ], - ]; - - $config = $this->processor->processConfiguration($this->configuration, $configs); - - $this->assertFalse($config['throttle']['enabled']); - $this->assertEquals(500000, $config['throttle']['microseconds']); - } - - public function testThrottleEnabledOnlyConfiguration(): void - { - $configs = [ - [ - 'throttle' => [ - 'enabled' => false, - ], - ], - ]; - - $config = $this->processor->processConfiguration($this->configuration, $configs); - - $this->assertFalse($config['throttle']['enabled']); - $this->assertEquals(1000000, $config['throttle']['microseconds']); // Default value - } - - public function testThrottleMicrosecondsOnlyConfiguration(): void + public function testRateLimiterBasicConfiguration(): void { $configs = [ [ - 'throttle' => [ - 'microseconds' => 250000, - ], + 'rate_limiter' => 'my_rate_limiter_service', ], ]; $config = $this->processor->processConfiguration($this->configuration, $configs); - $this->assertTrue($config['throttle']['enabled']); // Default value - $this->assertEquals(250000, $config['throttle']['microseconds']); + $this->assertEquals('my_rate_limiter_service', $config['rate_limiter']); + $this->assertArrayNotHasKey('throttle', $config); } public function testCompleteConfiguration(): void @@ -135,30 +93,20 @@ public function testCompleteConfiguration(): void 'consumer_key' => 'valid_consumer_key_12345', 'consumer_secret' => 'valid_consumer_secret_12345', 'personal_access_token' => 'BillieEilishToken2024_123456789', - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 750000, - ], + 'rate_limiter' => 'my_rate_limiter', ], ]; $config = $this->processor->processConfiguration($this->configuration, $configs); - $expected = [ - 'user_agent' => 'TestApp/2.0', - 'consumer_key' => 'valid_consumer_key_12345', - 'consumer_secret' => 'valid_consumer_secret_12345', - 'personal_access_token' => 'BillieEilishToken2024_123456789', - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 750000, - ], - ]; - - $this->assertEquals($expected, $config); + $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 testConfigurationMerging(): void + public function testMultipleConfigurationMerging(): void { $configs = [ [ @@ -168,9 +116,7 @@ public function testConfigurationMerging(): void [ 'user_agent' => 'SecondApp/2.0', 'consumer_secret' => 'second_secret', - 'throttle' => [ - 'enabled' => false, - ], + 'rate_limiter' => 'my_rate_limiter', ], ]; @@ -180,8 +126,22 @@ public function testConfigurationMerging(): void $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->assertFalse($config['throttle']['enabled']); // From second config - $this->assertEquals(1000000, $config['throttle']['microseconds']); // Default value + $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 @@ -190,4 +150,10 @@ public function testTreeBuilderReturnsCorrectRootName(): void $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 index 00b7331..89540d9 100644 --- a/tests/Unit/DependencyInjection/ConfigurationValidationTest.php +++ b/tests/Unit/DependencyInjection/ConfigurationValidationTest.php @@ -1,5 +1,7 @@ configuration = new Configuration(); - $this->processor = new Processor(); - } - public function testEmptyPersonalAccessTokenFails(): void { $this->expectException(InvalidConfigurationException::class); @@ -88,38 +84,6 @@ public function testEmptyConsumerSecretFails(): void $this->processor->processConfiguration($this->configuration, $configs); } - public function testNegativeThrottleMicrosecondsFails(): void - { - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('Throttle microseconds must be a positive integer'); - - $configs = [ - [ - 'throttle' => [ - 'microseconds' => -1000, - ], - ], - ]; - - $this->processor->processConfiguration($this->configuration, $configs); - } - - public function testExcessiveThrottleMicrosecondsFails(): void - { - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('Throttle microseconds cannot exceed 60 seconds'); - - $configs = [ - [ - 'throttle' => [ - 'microseconds' => 70000000, // 70 seconds - ], - ], - ]; - - $this->processor->processConfiguration($this->configuration, $configs); - } - public function testTooLongUserAgentFails(): void { $this->expectException(InvalidConfigurationException::class); @@ -140,10 +104,6 @@ public function testValidConfiguration(): void [ 'personal_access_token' => 'BillieEilishFan2024Token123456789', 'user_agent' => 'MyMusicApp/2.0 +https://example.com', - 'throttle' => [ - 'enabled' => true, - 'microseconds' => 500000, // 0.5 seconds - ], ], ]; @@ -151,65 +111,24 @@ public function testValidConfiguration(): void $this->assertEquals('BillieEilishFan2024Token123456789', $config['personal_access_token']); $this->assertEquals('MyMusicApp/2.0 +https://example.com', $config['user_agent']); - $this->assertTrue($config['throttle']['enabled']); - $this->assertEquals(500000, $config['throttle']['microseconds']); - } - - public function testZeroThrottleMicrosecondsIsValid(): void - { - $configs = [ - [ - 'throttle' => [ - 'microseconds' => 0, - ], - ], - ]; - - $config = $this->processor->processConfiguration($this->configuration, $configs); - - $this->assertEquals(0, $config['throttle']['microseconds']); - } - - public function testMaximumThrottleMicrosecondsIsValid(): void - { - $configs = [ - [ - 'throttle' => [ - 'microseconds' => 60000000, // Exactly 60 seconds - ], - ], - ]; - - $config = $this->processor->processConfiguration($this->configuration, $configs); - - $this->assertEquals(60000000, $config['throttle']['microseconds']); } - public function testInvalidBooleanThrottleEnabled(): void + public function testArrayAsScalarValue(): void { $this->expectException(InvalidConfigurationException::class); $configs = [ [ - 'throttle' => [ - 'enabled' => 'not_a_boolean', - ], + 'personal_access_token' => ['invalid' => 'array'], ], ]; $this->processor->processConfiguration($this->configuration, $configs); } - public function testArrayAsScalarValue(): void + protected function setUp(): void { - $this->expectException(InvalidConfigurationException::class); - - $configs = [ - [ - 'personal_access_token' => ['invalid' => 'array'], - ], - ]; - - $this->processor->processConfiguration($this->configuration, $configs); + $this->configuration = new Configuration(); + $this->processor = new Processor(); } } diff --git a/tests/Unit/DiscogsApiClientMockTest.php b/tests/Unit/DiscogsClientMockTest.php similarity index 72% rename from tests/Unit/DiscogsApiClientMockTest.php rename to tests/Unit/DiscogsClientMockTest.php index 3777703..7d99ae9 100644 --- a/tests/Unit/DiscogsApiClientMockTest.php +++ b/tests/Unit/DiscogsClientMockTest.php @@ -1,34 +1,21 @@ mockHandler = new MockHandler(); - $this->handlerStack = HandlerStack::create($this->mockHandler); - $this->httpClient = new Client(['handler' => $this->handlerStack]); - - // Use reflection to create a DiscogsApiClient with our mocked HTTP client - $this->client = new DiscogsApiClient($this->httpClient); - } + private DiscogsClient $client; public function testGetArtistSuccess(): void { @@ -40,10 +27,10 @@ public function testGetArtistSuccess(): void ]; $this->mockHandler->append( - new Response(200, ['Content-Type' => 'application/json'], json_encode($expectedResponse)) + new Response(200, ['Content-Type' => 'application/json'], json_encode($expectedResponse) ?: '{}') ); - $result = $this->client->getArtist(['id' => '1']); + $result = $this->client->getArtist(artistId: 1); $this->assertEquals($expectedResponse, $result); } @@ -56,7 +43,7 @@ public function testGetArtistNotFound(): void $this->expectException(\GuzzleHttp\Exception\ClientException::class); - $this->client->getArtist(['id' => '999999999']); + $this->client->getArtist(artistId: 999999999); } public function testSearchWithResults(): void @@ -77,10 +64,10 @@ public function testSearchWithResults(): void ]; $this->mockHandler->append( - new Response(200, ['Content-Type' => 'application/json'], json_encode($expectedResponse)) + new Response(200, ['Content-Type' => 'application/json'], json_encode($expectedResponse) ?: '{}') ); - $result = $this->client->search(['q' => 'Billie Eilish', 'type' => 'release']); + $result = $this->client->search(q: 'Billie Eilish', type: 'release'); $this->assertEquals($expectedResponse, $result); $this->assertCount(1, $result['results']); @@ -98,10 +85,10 @@ public function testSearchWithNoResults(): void ]; $this->mockHandler->append( - new Response(200, ['Content-Type' => 'application/json'], json_encode($expectedResponse)) + new Response(200, ['Content-Type' => 'application/json'], json_encode($expectedResponse) ?: '{}') ); - $result = $this->client->search(['q' => 'NonexistentModernArtist2024']); + $result = $this->client->search(q: 'NonexistentModernArtist2024'); $this->assertEquals($expectedResponse, $result); $this->assertEmpty($result['results']); @@ -109,21 +96,20 @@ public function testSearchWithNoResults(): void public function testRateLimitHandling(): void { - // First request gets rate limited, second succeeds + // Mock a rate limit response $this->mockHandler->append( - new Response(429, ['Retry-After' => '1'], '{"message": "You are making requests too quickly."}'), - new Response(200, ['Content-Type' => 'application/json'], '{"id": 1, "name": "Test Artist"}') + new Response(429, ['Retry-After' => '1'], '{"message": "You are making requests too quickly."}') ); - // Use the throttle handler stack for this test - $throttleStack = ThrottleHandlerStackFactory::factory(100000); // 0.1 seconds for fast testing - $throttleStack->setHandler($this->mockHandler); - $throttleClient = new Client(['handler' => $throttleStack, 'http_errors' => false]); - $apiClient = new DiscogsApiClient($throttleClient); + // Without rate limiter middleware, the client gets the 429 response directly + $client = new Client(['handler' => $this->handlerStack, 'http_errors' => false]); + $apiClient = new DiscogsClient($client); - $result = $apiClient->getArtist(['id' => '1']); + // This test verifies that rate limit responses are handled properly + $result = $apiClient->getArtist(artistId: '1'); - $this->assertEquals(['id' => 1, 'name' => 'Test Artist'], $result); + // The client should return the 429 rate limit response + $this->assertEquals(['message' => 'You are making requests too quickly.'], $result); } public function testMultipleRequestsWithMocking(): void @@ -136,13 +122,13 @@ public function testMultipleRequestsWithMocking(): void foreach ($responses as $response) { $this->mockHandler->append( - new Response(200, ['Content-Type' => 'application/json'], json_encode($response)) + new Response(200, ['Content-Type' => 'application/json'], json_encode($response) ?: '{}') ); } $results = []; for ($i = 1; $i <= 3; ++$i) { - $results[] = $this->client->getArtist(['id' => (string) $i]); + $results[] = $this->client->getArtist(artistId: $i); } $this->assertCount(3, $results); @@ -161,7 +147,7 @@ public function testInvalidJsonResponse(): void $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Invalid JSON response'); - $this->client->getArtist(['id' => '1']); + $this->client->getArtist(artistId: 1); } public function testServerError(): void @@ -172,7 +158,7 @@ public function testServerError(): void $this->expectException(\GuzzleHttp\Exception\ServerException::class); - $this->client->getArtist(['id' => '1']); + $this->client->getArtist(artistId: 1); } public function testClientWithCustomHeaders(): void @@ -191,8 +177,8 @@ public function testClientWithCustomHeaders(): void 'headers' => $customHeaders, ]); - $apiClient = new DiscogsApiClient($customClient); - $result = $apiClient->getArtist(['id' => '1']); + $apiClient = new DiscogsClient($customClient); + $result = $apiClient->getArtist(artistId: 1); $this->assertEquals(['id' => 1, 'name' => 'Test'], $result); @@ -200,4 +186,14 @@ public function testClientWithCustomHeaders(): void $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 index 7311a05..11ac823 100644 --- a/tests/Unit/FunctionalTest.php +++ b/tests/Unit/FunctionalTest.php @@ -1,105 +1,49 @@ kernel)) { - $this->kernel->cleanupCache(); - } - parent::tearDown(); - } - public function testServiceWiring(): void { - $this->kernel = TestKernel::createForFunctional(); - $this->kernel->boot(); - $container = $this->kernel->getContainer(); - - $discogsClient = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $discogsClient); + $container = $this->bootKernelAndGetContainer(); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); } public function testServiceWiringWithConfiguration(): void { - $this->kernel = TestKernel::createForFunctional([ - 'user_agent' => 'test', - ]); - $this->kernel->boot(); - $container = $this->kernel->getContainer(); + $container = $this->bootKernelAndGetContainer(['user_agent' => 'test']); $discogsClient = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $discogsClient); + $this->assertInstanceOf(DiscogsClient::class, $discogsClient); // Verify that the client is properly configured // The user agent configuration is handled internally by the bundle - $this->assertNotNull($discogsClient); + /* @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $discogsClient); } - public function testServiceWiringWithThrottleDisabled(): void + public function testServiceWiringWithMinimalConfig(): void { - $this->kernel = TestKernel::createForFunctional([ - 'throttle' => [ - 'enabled' => false, - ], - ]); - $this->kernel->boot(); - $container = $this->kernel->getContainer(); - - $discogsClient = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $discogsClient); + $config = []; + $container = $this->bootKernelAndGetContainer($config); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); } public function testServiceWiringWithConsumerCredentials(): void { - $this->kernel = TestKernel::createForFunctional([ - 'consumer_key' => 'test_key', - 'consumer_secret' => 'test_secret', - ]); - $this->kernel->boot(); - $container = $this->kernel->getContainer(); - - $discogsClient = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $discogsClient); + $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 { - $this->kernel = TestKernel::createForFunctional([ - 'personal_access_token' => 'test_token_123', - ]); - $this->kernel->boot(); - $container = $this->kernel->getContainer(); - - $discogsClient = $container->get('calliostro_discogs.discogs_client'); - $this->assertInstanceOf(DiscogsApiClient::class, $discogsClient); - } - - public function testMultipleKernelInstancesIsolation(): void - { - // Test that multiple kernel instances don't interfere - $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(DiscogsApiClient::class, $client1); - $this->assertInstanceOf(DiscogsApiClient::class, $client2); - $this->assertNotSame($client1, $client2); - - $kernel1->cleanupCache(); - $kernel2->cleanupCache(); + $container = $this->bootKernelAndGetContainer(['personal_access_token' => 'test_token_123']); + $this->assertServiceInstanceOf($container, 'calliostro_discogs.discogs_client', DiscogsClient::class); } } diff --git a/tests/Unit/ThrottleHandlerStackFactoryTest.php b/tests/Unit/ThrottleHandlerStackFactoryTest.php deleted file mode 100644 index 33e27e9..0000000 --- a/tests/Unit/ThrottleHandlerStackFactoryTest.php +++ /dev/null @@ -1,102 +0,0 @@ -assertInstanceOf(HandlerStack::class, $stack); - - // Test that it works without retry middleware - $client = new Client(['handler' => $stack]); - $mock = new MockHandler([new Response(200, [], 'test')]); - $stack->setHandler($mock); - - $response = $client->get('http://example.com'); - $this->assertEquals(200, $response->getStatusCode()); - } - - public function testFactoryWithMicroseconds(): void - { - $microseconds = 1000000; // 1 second - $stack = ThrottleHandlerStackFactory::factory($microseconds); - - /* @noinspection PhpConditionAlreadyCheckedInspection */ - $this->assertInstanceOf(HandlerStack::class, $stack); - - // Test successful request (no retry needed) - $mock = new MockHandler([new Response(200, [], 'success')]); - $stack->setHandler($mock); - $client = new Client(['handler' => $stack]); - - $response = $client->get('http://example.com'); - $this->assertEquals(200, $response->getStatusCode()); - } - - public function testRetryOn429Response(): void - { - $microseconds = 100000; // 0.1 seconds for fast testing - $stack = ThrottleHandlerStackFactory::factory($microseconds); - - // Mock: First request returns 429, second returns 200 - $mock = new MockHandler([ - new Response(429, [], 'Rate Limited'), - new Response(200, [], 'Success after retry'), - ]); - $stack->setHandler($mock); - $client = new Client(['handler' => $stack, 'http_errors' => false]); - - $response = $client->get('http://example.com'); - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('Success after retry', (string) $response->getBody()); - } - - public function testMaxRetriesExceeded(): void - { - $microseconds = 100000; // 0.1 seconds for fast testing - $stack = ThrottleHandlerStackFactory::factory($microseconds); - - // Mock: All requests return 429 (exceeds max retries) - $mock = new MockHandler([ - new Response(429, [], 'Rate Limited'), - new Response(429, [], 'Rate Limited'), - new Response(429, [], 'Rate Limited'), - new Response(429, [], 'Rate Limited'), // The final attempt also fails - ]); - $stack->setHandler($mock); - $client = new Client(['handler' => $stack, 'http_errors' => false]); - - $response = $client->get('http://example.com'); - $this->assertEquals(429, $response->getStatusCode()); - } - - public function testNoRetryOnOtherErrors(): void - { - $microseconds = 100000; - $stack = ThrottleHandlerStackFactory::factory($microseconds); - - // Mock: 500 error should not be retried - $mock = new MockHandler([ - new Response(500, [], 'Internal Server Error'), - ]); - $stack->setHandler($mock); - $client = new Client(['handler' => $stack, 'http_errors' => false]); - - $response = $client->get('http://example.com'); - $this->assertEquals(500, $response->getStatusCode()); - } -} 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(); + } +} From edd9961627d0b2d7b3cd52ea3acdecbb360c18ad Mon Sep 17 00:00:00 2001 From: calliostro Date: Sun, 14 Sep 2025 00:21:14 +0200 Subject: [PATCH 09/34] Remove standalone PHP tests from CI - bundle requires Symfony --- .github/workflows/ci.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99a63f7..6c1cf31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,16 +22,6 @@ 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' From 9f31ec52863dda93e33f8a03db6e9f46b9c17c19 Mon Sep 17 00:00:00 2001 From: calliostro Date: Sun, 14 Sep 2025 00:23:50 +0200 Subject: [PATCH 10/34] Remove standalone PHP dev stability tests - all tests require Symfony --- .github/workflows/ci.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c1cf31..13ed549 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,14 +80,6 @@ jobs: stability: 'dev' allowed-to-fail: true - # Development stability tests - - php: '8.4' - stability: 'dev' - allowed-to-fail: true - - php: '8.5' - stability: 'dev' - allowed-to-fail: true - name: "PHP ${{ matrix.php }}${{ matrix.symfony-version && format(' | Symfony {0}', matrix.symfony-version) || '' }}${{ matrix.stability && format(' | {0}', matrix.stability) || '' }}" continue-on-error: ${{ matrix.allowed-to-fail }} From 313e566df59ce32682af299aeff4d91f4b6a93c9 Mon Sep 17 00:00:00 2001 From: calliostro Date: Sun, 26 Oct 2025 15:00:43 +0100 Subject: [PATCH 11/34] Update changelog and composer.json for v4.0.0-beta.3 release --- CHANGELOG.md | 4 ++-- composer.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e670a4c..21e177e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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-beta.2](https://github.com/calliostro/discogs-bundle/releases/tag/v4.0.0-beta.2) โ€“ 2025-09-14 +## [4.0.0-beta.3](https://github.com/calliostro/discogs-bundle/releases/tag/v4.0.0-beta.3) โ€“ 2025-10-26 ### ๐Ÿš€ Complete Rewrite โ€” Fresh Start @@ -37,7 +37,7 @@ This version represents a complete architectural rewrite. v4.0.0 is essentially - **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` v4.0.0-beta.2 +- **Complete API Integration** now based on `calliostro/php-discogs-api` v4.0.0-beta.3 - **Code Standards** fully compliant with @Symfony and @Symfony:risky rules ### Removed diff --git a/composer.json b/composer.json index 435a7fd..9ab60b8 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ ], "require": { "php": "^8.1", - "calliostro/php-discogs-api": "v4.0.0-beta.2", + "calliostro/php-discogs-api": "v4.0.0-beta.3", "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", From 41b9c1a12205b09ab0bd3c02466d9cf5533e723b Mon Sep 17 00:00:00 2001 From: calliostro Date: Sun, 26 Oct 2025 16:23:35 +0100 Subject: [PATCH 12/34] Refactor CI configuration and service loading for Symfony compatibility --- .github/workflows/ci.yml | 14 +++++++++--- composer.json | 18 +++++++-------- phpstan/generic-baseline.neon | 5 ----- phpstan/symfony-legacy-baseline.neon | 7 ++++++ phpstan/symfony74-baseline.neon | 7 ++++++ phpstan/symfony8-baseline.neon | 7 ++++++ .../CalliostroDiscogsExtension.php | 19 +++++++++++----- src/DependencyInjection/Configuration.php | 1 - src/Resources/config/services.php | 22 +++++++++++++++++++ src/Resources/config/services.xml | 17 -------------- 10 files changed, 77 insertions(+), 40 deletions(-) delete mode 100644 phpstan/generic-baseline.neon create mode 100644 phpstan/symfony-legacy-baseline.neon create mode 100644 phpstan/symfony74-baseline.neon create mode 100644 phpstan/symfony8-baseline.neon create mode 100644 src/Resources/config/services.php delete mode 100644 src/Resources/config/services.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13ed549..1168de4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,10 +142,18 @@ jobs: - name: Run static analysis run: | - if [[ "${{ 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 - composer analyse-legacy - else + 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: Use legacy baseline for children() issue composer analyse + else + # Future versions: Try without baseline first + composer analyse-legacy fi - name: Run tests diff --git a/composer.json b/composer.json index 9ab60b8..58e3fac 100644 --- a/composer.json +++ b/composer.json @@ -26,20 +26,20 @@ ], "require": { "php": "^8.1", - "calliostro/php-discogs-api": "v4.0.0-beta.3", - "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" + "calliostro/php-discogs-api": "v4.0.0-beta.3" }, "suggest": { "symfony/rate-limiter": "For advanced rate limiting functionality" }, "require-dev": { - "symfony/phpunit-bridge": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "7.4.x-dev", "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^3.87", - "symfony/rate-limiter": "^6.4|^7.0|^8.0" + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/config": "7.4.x-dev", + "symfony/dependency-injection": "7.4.x-dev", + "symfony/http-kernel": "7.4.x-dev", + "symfony/security-core": "7.4.x-dev" }, "autoload": { "psr-4": { @@ -60,10 +60,10 @@ "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 --configuration phpstan/generic-baseline.neon", + "analyse": "vendor/bin/phpstan analyse src --level=8 --configuration phpstan/symfony-legacy-baseline.neon", "analyse-legacy": "vendor/bin/phpstan analyse src --level=8" }, - "minimum-stability": "stable", + "minimum-stability": "dev", "prefer-stable": true } diff --git a/phpstan/generic-baseline.neon b/phpstan/generic-baseline.neon deleted file mode 100644 index adb4d29..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/src/DependencyInjection/CalliostroDiscogsExtension.php b/src/DependencyInjection/CalliostroDiscogsExtension.php index 0ba4008..394617c 100644 --- a/src/DependencyInjection/CalliostroDiscogsExtension.php +++ b/src/DependencyInjection/CalliostroDiscogsExtension.php @@ -7,7 +7,7 @@ use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; -use Symfony\Component\DependencyInjection\Loader; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Reference; final class CalliostroDiscogsExtension extends Extension @@ -25,10 +25,8 @@ public function load(array $configs, ContainerBuilder $container): void $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); - $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); - $loader->load('services.xml'); - - // Configure client based on authentication method + // Load services configuration + $this->loadServices($container); // Configure client based on authentication method $this->configureClient($container, $config); } @@ -115,6 +113,17 @@ private function configureSymfonyRateLimiter(ContainerBuilder $container, string $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. diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 90ef5dd..bed870d 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -14,7 +14,6 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder = new TreeBuilder('calliostro_discogs'); $rootNode = $treeBuilder->getRootNode(); - // @phpstan-ignore-next-line: Symfony Config Builder has dynamic method resolution $rootNode ->children() ->scalarNode('personal_access_token') 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 c8a632b..0000000 --- a/src/Resources/config/services.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - From 536da3c022fd105b2fab5a1d7d3706b5845e8163 Mon Sep 17 00:00:00 2001 From: calliostro Date: Sat, 8 Nov 2025 16:24:12 +0100 Subject: [PATCH 13/34] Update code quality check instructions for Symfony 7.4 compatibility --- DEVELOPMENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6166690..0005053 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -112,7 +112,7 @@ vendor/bin/phpunit tests/Integration/ --testdox 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` or `composer analyse-legacy && composer cs` for Symfony < 7.3) +5. Check code quality (`composer analyse && composer cs` or `composer analyse-legacy && composer cs` for Symfony < 7.4) 6. Commit changes (`git commit -m 'Add feature'`) 7. Push to branch (`git push origin feature/name`) 8. Open Pull Request From a2b282e98c018a860fb41b697640244c699ad04d Mon Sep 17 00:00:00 2001 From: calliostro Date: Sat, 8 Nov 2025 16:56:49 +0100 Subject: [PATCH 14/34] Add Discogs API credentials to CI test environments for improved test coverage --- .github/workflows/ci.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1168de4..824b73d 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: @@ -32,7 +32,7 @@ jobs: - php: '8.4' symfony-version: '^6.4' allowed-to-fail: false - + # Symfony 7.x current versions - php: '8.2' symfony-version: '^7.0' @@ -158,6 +158,10 @@ jobs: - name: Run tests run: ./vendor/bin/simple-phpunit -v + 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 @@ -229,6 +233,10 @@ jobs: - name: Run tests with coverage run: ./vendor/bin/simple-phpunit --coverage-clover coverage.xml + 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 From bde09dad3cd993bcb83adff79ab81951a3fbd95b Mon Sep 17 00:00:00 2001 From: calliostro Date: Wed, 12 Nov 2025 18:51:40 +0100 Subject: [PATCH 15/34] Refactor configuration validation and client factory to enable runtime credential validation and support for environment variables. Update tests accordingly. --- CHANGELOG.md | 2 +- .../CalliostroDiscogsExtension.php | 33 ++---- src/DependencyInjection/Configuration.php | 16 --- .../DiscogsClientFactory.php | 98 +++++++++++++++++ tests/Unit/CalliostroDiscogsExtensionTest.php | 55 ++++++---- .../ConfigurationValidationTest.php | 62 ++++++----- .../RuntimeValidationTest.php | 102 ++++++++++++++++++ 7 files changed, 282 insertions(+), 86 deletions(-) create mode 100644 src/DependencyInjection/DiscogsClientFactory.php create mode 100644 tests/Unit/DependencyInjection/RuntimeValidationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 21e177e..cb7166d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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-beta.3](https://github.com/calliostro/discogs-bundle/releases/tag/v4.0.0-beta.3) โ€“ 2025-10-26 +## [4.0.0-beta.4](https://github.com/calliostro/discogs-bundle/releases/tag/v4.0.0-beta.4) โ€“ 2025-11-12 ### ๐Ÿš€ Complete Rewrite โ€” Fresh Start diff --git a/src/DependencyInjection/CalliostroDiscogsExtension.php b/src/DependencyInjection/CalliostroDiscogsExtension.php index 394617c..4081340 100644 --- a/src/DependencyInjection/CalliostroDiscogsExtension.php +++ b/src/DependencyInjection/CalliostroDiscogsExtension.php @@ -37,28 +37,17 @@ private function configureClient(ContainerBuilder $container, array $config): vo { $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); - if (!empty($config['personal_access_token'])) { - // Personal Access Token authentication (recommended for personal use) - $clientDefinition->setFactory(['Calliostro\\Discogs\\DiscogsClientFactory', 'createWithPersonalAccessToken']); - $clientDefinition->setArguments([ - $config['personal_access_token'], - $this->getClientOptions($container, $config), - ]); - } elseif (!empty($config['consumer_key']) && !empty($config['consumer_secret'])) { - // Consumer credentials authentication - $clientDefinition->setFactory(['Calliostro\\Discogs\\DiscogsClientFactory', 'createWithConsumerCredentials']); - $clientDefinition->setArguments([ - $config['consumer_key'], - $config['consumer_secret'], - $this->getClientOptions($container, $config), - ]); - } else { - // Anonymous client (rate-limited) - $clientDefinition->setFactory(['Calliostro\\Discogs\\DiscogsClientFactory', 'create']); - $clientDefinition->setArguments([ - $this->getClientOptions($container, $config), - ]); - } + // Create a factory service that will handle validation at runtime + $factoryDefinition = $container->register('calliostro_discogs.client_factory', 'Calliostro\\DiscogsBundle\\DependencyInjection\\DiscogsClientFactory'); + + // 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), + ]); } /** diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index bed870d..4e998e0 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -18,28 +18,12 @@ public function getConfigTreeBuilder(): TreeBuilder ->children() ->scalarNode('personal_access_token') ->info('Your personal access token (recommended - get from https://www.discogs.com/settings/developers)') - ->validate() - ->ifTrue(fn ($v) => \is_string($v) && '' === trim($v)) - ->thenInvalid('Personal access token cannot be empty') - ->end() - ->validate() - ->ifTrue(fn ($v) => \is_string($v) && '' !== trim($v) && \strlen(trim($v)) < 10) - ->thenInvalid('Personal access token must be at least 10 characters') - ->end() ->end() ->scalarNode('consumer_key') ->info('Your consumer key (alternative for OAuth applications)') - ->validate() - ->ifTrue(fn ($v) => \is_string($v) && '' === trim($v)) - ->thenInvalid('Consumer key cannot be empty') - ->end() ->end() ->scalarNode('consumer_secret') ->info('Your consumer secret (alternative for OAuth applications)') - ->validate() - ->ifTrue(fn ($v) => \is_string($v) && '' === trim($v)) - ->thenInvalid('Consumer secret cannot be empty') - ->end() ->end() ->scalarNode('user_agent') ->defaultNull() diff --git a/src/DependencyInjection/DiscogsClientFactory.php b/src/DependencyInjection/DiscogsClientFactory.php new file mode 100644 index 0000000..4551697 --- /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". + " - Add to your .env.local: 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". + " - Add to your .env.local:\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/tests/Unit/CalliostroDiscogsExtensionTest.php b/tests/Unit/CalliostroDiscogsExtensionTest.php index 8a53983..9008e97 100644 --- a/tests/Unit/CalliostroDiscogsExtensionTest.php +++ b/tests/Unit/CalliostroDiscogsExtensionTest.php @@ -93,11 +93,15 @@ public function testLoadWithConsumerKeyAndSecretOnly(): void $extension->load($config, $container); - $this->assertDefinitionHasFactory($container, 'calliostro_discogs.discogs_client', - ['Calliostro\Discogs\DiscogsClientFactory', 'createWithConsumerCredentials']); - $this->assertDefinitionArgumentCount($container, 'calliostro_discogs.discogs_client', 3); - $this->assertDefinitionArgumentEquals($container, 'calliostro_discogs.discogs_client', 0, 'test_key'); - $this->assertDefinitionArgumentEquals($container, 'calliostro_discogs.discogs_client', 1, 'test_secret'); + // 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 @@ -109,9 +113,11 @@ public function testLoadWithoutRateLimiter(): void $extension->load($config, $container); - // When no rate limiter is configured, the basic client factory should be used - $this->assertDefinitionHasFactory($container, 'calliostro_discogs.discogs_client', - ['Calliostro\Discogs\DiscogsClientFactory', 'create']); + // 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 @@ -127,9 +133,12 @@ public function testLoadWithPersonalAccessToken(): void $extension->load($config, $container); - $this->assertDefinitionHasFactory($container, 'calliostro_discogs.discogs_client', - ['Calliostro\Discogs\DiscogsClientFactory', 'createWithPersonalAccessToken']); - $this->assertDefinitionArgumentCount($container, 'calliostro_discogs.discogs_client', 2); + // 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'); } @@ -146,14 +155,16 @@ public function testLoadWithCustomUserAgent(): void $extension->load($config, $container); - $this->assertDefinitionHasFactory($container, 'calliostro_discogs.discogs_client', - ['Calliostro\Discogs\DiscogsClientFactory', 'create']); - + // 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(); - $this->assertIsArray($arguments[0]); - $this->assertArrayHasKey('headers', $arguments[0]); - $this->assertEquals('CustomAgent/1.0', $arguments[0]['headers']['User-Agent']); + $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 @@ -170,14 +181,14 @@ public function testLoadWithPersonalAccessTokenAndUserAgent(): void $extension->load($config, $container); - $this->assertDefinitionHasFactory($container, 'calliostro_discogs.discogs_client', - ['Calliostro\Discogs\DiscogsClientFactory', 'createWithPersonalAccessToken']); - $this->assertDefinitionArgumentCount($container, 'calliostro_discogs.discogs_client', 2); + // 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[1]; + $options = $arguments[3]; // Options are now at index 3 $this->assertArrayHasKey('headers', $options); $this->assertEquals('TestApp/1.0', $options['headers']['User-Agent']); } @@ -206,7 +217,7 @@ public function testRateLimiterIntegration(): void $definition = $container->getDefinition('calliostro_discogs.discogs_client'); $arguments = $definition->getArguments(); - $options = $arguments[1] ?? []; // Second argument for personal access token factory + $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/ConfigurationValidationTest.php b/tests/Unit/DependencyInjection/ConfigurationValidationTest.php index 89540d9..026f161 100644 --- a/tests/Unit/DependencyInjection/ConfigurationValidationTest.php +++ b/tests/Unit/DependencyInjection/ConfigurationValidationTest.php @@ -14,74 +14,69 @@ final class ConfigurationValidationTest extends TestCase private Configuration $configuration; private Processor $processor; - public function testEmptyPersonalAccessTokenFails(): void + public function testEmptyPersonalAccessTokenNowAllowed(): void { - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('Personal access token cannot be empty'); - + // This should now pass (no longer fails at compile time) $configs = [ [ 'personal_access_token' => '', ], ]; - $this->processor->processConfiguration($this->configuration, $configs); + $config = $this->processor->processConfiguration($this->configuration, $configs); + $this->assertEquals('', $config['personal_access_token']); } - public function testWhitespaceOnlyPersonalAccessTokenFails(): void + public function testWhitespaceOnlyPersonalAccessTokenNowAllowed(): void { - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('Personal access token cannot be empty'); - + // This should now pass (no longer fails at compile time) $configs = [ [ 'personal_access_token' => ' ', ], ]; - $this->processor->processConfiguration($this->configuration, $configs); + $config = $this->processor->processConfiguration($this->configuration, $configs); + $this->assertEquals(' ', $config['personal_access_token']); } - public function testShortPersonalAccessTokenFails(): void + public function testShortPersonalAccessTokenNowAllowed(): void { - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('Personal access token must be at least 10 characters'); - + // This should now pass (validation moved to runtime) $configs = [ [ 'personal_access_token' => 'short', ], ]; - $this->processor->processConfiguration($this->configuration, $configs); + $config = $this->processor->processConfiguration($this->configuration, $configs); + $this->assertEquals('short', $config['personal_access_token']); } - public function testEmptyConsumerKeyFails(): void + public function testEmptyConsumerKeyNowAllowed(): void { - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('Consumer key cannot be empty'); - + // This should now pass (no longer fails at compile time) $configs = [ [ 'consumer_key' => '', ], ]; - $this->processor->processConfiguration($this->configuration, $configs); + $config = $this->processor->processConfiguration($this->configuration, $configs); + $this->assertEquals('', $config['consumer_key']); } - public function testEmptyConsumerSecretFails(): void + public function testEmptyConsumerSecretNowAllowed(): void { - $this->expectException(InvalidConfigurationException::class); - $this->expectExceptionMessage('Consumer secret cannot be empty'); - + // This should now pass (no longer fails at compile time) $configs = [ [ 'consumer_secret' => '', ], ]; - $this->processor->processConfiguration($this->configuration, $configs); + $config = $this->processor->processConfiguration($this->configuration, $configs); + $this->assertEquals('', $config['consumer_secret']); } public function testTooLongUserAgentFails(): void @@ -113,6 +108,23 @@ public function testValidConfiguration(): void $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); 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(); + } +} From 62a2ed2ab6714c2cdd00a0cbcbc6e45e890cb394 Mon Sep 17 00:00:00 2001 From: calliostro Date: Wed, 12 Nov 2025 19:18:36 +0100 Subject: [PATCH 16/34] Clarify Discogs API credential setup instructions for improved consistency and readability. --- src/DependencyInjection/DiscogsClientFactory.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DependencyInjection/DiscogsClientFactory.php b/src/DependencyInjection/DiscogsClientFactory.php index 4551697..138b7cc 100644 --- a/src/DependencyInjection/DiscogsClientFactory.php +++ b/src/DependencyInjection/DiscogsClientFactory.php @@ -80,12 +80,12 @@ 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". - " - Add to your .env.local: DISCOGS_PERSONAL_ACCESS_TOKEN=your_token_here\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". - " - Add to your .env.local:\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". From 0eee8b040eef551758cfd1d9cf49be0c88da164c Mon Sep 17 00:00:00 2001 From: calliostro Date: Thu, 13 Nov 2025 22:47:22 +0100 Subject: [PATCH 17/34] Clarify Discogs API credential setup instructions for improved consistency and readability. --- .gitattributes | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..26b71c4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,24 @@ +# Normalize line endings +* text=auto + +# PHP files +*.php text eol=lf diff=php + +# Config files +*.json text eol=lf +*.xml text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.neon text eol=lf + +# Exclude from releases +.gitattributes export-ignore +.gitignore export-ignore +tests/ export-ignore +phpunit.xml* export-ignore +.phpunit.* export-ignore +codecov.yml export-ignore +phpstan/ export-ignore +coverage/ export-ignore +DEVELOPMENT.md export-ignore \ No newline at end of file From 24e323da48485534c16741e091572346ee3dbd3c Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 18:58:11 +0100 Subject: [PATCH 18/34] Update .gitattributes to refine export-ignore rules --- .gitattributes | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.gitattributes b/.gitattributes index 26b71c4..6376ac0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,19 +6,28 @@ # Config files *.json text eol=lf -*.xml text eol=lf *.md text eol=lf -*.yml text eol=lf -*.yaml 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 -tests/ export-ignore -phpunit.xml* export-ignore +.markdownlint.json export-ignore +.php-cs-fixer.php export-ignore +.phpstan/ export-ignore .phpunit.* export-ignore codecov.yml export-ignore -phpstan/ export-ignore coverage/ export-ignore -DEVELOPMENT.md export-ignore \ No newline at end of file +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 From 46de34efa4d261a6dcbf381ac1489e8f7f80d7d4 Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 19:03:58 +0100 Subject: [PATCH 19/34] Update CI configuration to remove 'allowed-to-fail' for PHP 8.4 and 8.5, ensuring stricter validation for Symfony compatibility --- .github/workflows/ci.yml | 19 +++++++------------ README.md | 1 - 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 824b73d..07f6c53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,28 +57,23 @@ jobs: allowed-to-fail: false - php: '8.4' symfony-version: '^7.4' - stability: 'dev' - allowed-to-fail: true + 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 + allowed-to-fail: false - # Future-ready: PHP 8.5 (alpha/dev) - when available + # PHP 8.5 with various Symfony versions - php: '8.5' symfony-version: '^7.3' - stability: 'dev' - allowed-to-fail: true + allowed-to-fail: false - php: '8.5' symfony-version: '^7.4' - stability: 'dev' - allowed-to-fail: true + allowed-to-fail: false - php: '8.5' symfony-version: '^8.0' - stability: 'dev' - allowed-to-fail: true + allowed-to-fail: false name: "PHP ${{ matrix.php }}${{ matrix.symfony-version && format(' | Symfony {0}', matrix.symfony-version) || '' }}${{ matrix.stability && format(' | {0}', matrix.stability) || '' }}" diff --git a/README.md b/README.md index 2193215..83db712 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,6 @@ $label = $client->getLabel(labelId: 12677); // Interscope Recor - **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 -- **Future-Ready** โ€“ PHP 8.5 and Symfony 8.0 compatible (beta/dev testing) - **Well Tested** โ€“ Comprehensive test coverage, Symfony coding standards - **Multiple Auth Methods** โ€“ Personal Access Token, OAuth 1.0a, Consumer Credentials, Anonymous From 1d1a30ae11d28627b5166273b38006de206c68e5 Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 19:07:13 +0100 Subject: [PATCH 20/34] Update composer.json to require stable versions of dependencies and improve compatibility with Symfony --- composer.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 58e3fac..6d35732 100644 --- a/composer.json +++ b/composer.json @@ -26,20 +26,20 @@ ], "require": { "php": "^8.1", - "calliostro/php-discogs-api": "v4.0.0-beta.3" + "calliostro/php-discogs-api": "^4.0" }, "suggest": { "symfony/rate-limiter": "For advanced rate limiting functionality" }, "require-dev": { - "symfony/phpunit-bridge": "7.4.x-dev", + "symfony/phpunit-bridge": "^6.4|^7.0|^8.0", "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^3.87", "symfony/rate-limiter": "^6.4|^7.0|^8.0", - "symfony/config": "7.4.x-dev", - "symfony/dependency-injection": "7.4.x-dev", - "symfony/http-kernel": "7.4.x-dev", - "symfony/security-core": "7.4.x-dev" + "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" }, "autoload": { "psr-4": { From 8c77d039384ef13d5ef7353920298fc18dc4b3f5 Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 19:14:08 +0100 Subject: [PATCH 21/34] Refactor CI workflow to use Composer commands for testing and coverage --- .github/workflows/ci.yml | 4 ++-- composer.json | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07f6c53..5e94bcd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,7 +152,7 @@ jobs: 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 }} @@ -227,7 +227,7 @@ 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 }} diff --git a/composer.json b/composer.json index 6d35732..07d5045 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,6 @@ }, "scripts": { "test": "vendor/bin/simple-phpunit --testsuite=\"Unit Tests\"", - "test-unit": "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", From 71881420b5e410c7a2cdb181a9015185195c8446 Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 19:47:48 +0100 Subject: [PATCH 22/34] Update DEVELOPMENT.md and README.md for improved testing and rate limiting documentation; refine composer.json dependencies for Symfony 8.0 compatibility; enhance MockOAuthToken class definition. --- CHANGELOG.md | 8 ++++---- DEVELOPMENT.md | 6 +++--- README.md | 9 +++++++++ composer.json | 14 +++++++------- tests/Unit/MockOAuthToken.php | 2 +- 5 files changed, 24 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb7166d..344e5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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-beta.4](https://github.com/calliostro/discogs-bundle/releases/tag/v4.0.0-beta.4) โ€“ 2025-11-12 +## [4.0.0](https://github.com/calliostro/discogs-bundle/releases/tag/v4.0.0) โ€“ 2025-12-01 ### ๐Ÿš€ Complete Rewrite โ€” Fresh Start @@ -37,7 +37,7 @@ This version represents a complete architectural rewrite. v4.0.0 is essentially - **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` v4.0.0-beta.3 +- **Complete API Integration** now based on `calliostro/php-discogs-api` - **Code Standards** fully compliant with @Symfony and @Symfony:risky rules ### Removed @@ -102,8 +102,8 @@ This version represents a complete architectural rewrite. v4.0.0 is essentially ### 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 index 0005053..e445f36 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -23,10 +23,10 @@ composer test-coverage ### Static Analysis & Code Quality ```bash -# Static analysis (PHPStan Level 8) - Default for Symfony 7.4+ +# Static analysis (PHPStan Level 8) - For Symfony 7.4+ / 8.0 composer analyse -# Static analysis without baseline (Symfony < 7.4) +# Static analysis for Symfony 6.4 / 7.0-7.3 composer analyse-legacy # Code style check (Symfony standards) @@ -112,7 +112,7 @@ vendor/bin/phpunit tests/Integration/ --testdox 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` or `composer analyse-legacy && composer cs` for Symfony < 7.4) +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 diff --git a/README.md b/README.md index 83db712..980cf66 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,15 @@ calliostro_discogs: - **Anonymous access:** Use 25/min (as shown above) - **Authenticated only:** Change limit to 60 for maximum performance +The bundle uses a Guzzle middleware that automatically handles rate limiting by: + +- 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. diff --git a/composer.json b/composer.json index 07d5045..034da7b 100644 --- a/composer.json +++ b/composer.json @@ -32,14 +32,14 @@ "symfony/rate-limiter": "For advanced rate limiting functionality" }, "require-dev": { - "symfony/phpunit-bridge": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "8.0", "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^3.87", "symfony/rate-limiter": "^6.4|^7.0|^8.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/config": "8.0", + "symfony/dependency-injection": "8.0", + "symfony/http-kernel": "8.0", + "symfony/security-core": "8.0" }, "autoload": { "psr-4": { @@ -59,8 +59,8 @@ "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 --configuration phpstan/symfony-legacy-baseline.neon", - "analyse-legacy": "vendor/bin/phpstan analyse src --level=8" + "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": "dev", "prefer-stable": true diff --git a/tests/Unit/MockOAuthToken.php b/tests/Unit/MockOAuthToken.php index 9c0f179..2cfa786 100644 --- a/tests/Unit/MockOAuthToken.php +++ b/tests/Unit/MockOAuthToken.php @@ -8,7 +8,7 @@ /** * Mock OAuth token for testing purposes. */ -final class MockOAuthToken implements TokenInterface +final class MockOAuthToken implements \Stringable, TokenInterface { /** * @param array $rawTokenData From 959b82617dbc61ae3313a208ef2199079a977391 Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 19:59:30 +0100 Subject: [PATCH 23/34] Refactor PHPStan analysis commands for Symfony version compatibility --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e94bcd..0933587 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,10 +145,10 @@ jobs: 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: Use legacy baseline for children() issue - composer analyse + composer analyse-legacy else # Future versions: Try without baseline first - composer analyse-legacy + composer analyse fi - name: Run tests From c0d9e3eaa748ac320c99795f230d921fc7165772 Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 20:08:40 +0100 Subject: [PATCH 24/34] Add PHPStan baseline configuration for Symfony 6.4 and update analysis commands --- .github/workflows/ci.yml | 7 +++++-- phpstan/symfony64-baseline.neon | 7 +++++++ 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 phpstan/symfony64-baseline.neon diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0933587..e649961 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,8 +143,11 @@ jobs: 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: Use legacy baseline for children() issue + elif [[ "${{ matrix.symfony-version }}" == "^6.4" ]]; then + # Symfony 6.4 LTS: Use dedicated baseline for children() issue + vendor/bin/phpstan analyse src --level=8 --configuration phpstan/symfony64-baseline.neon + elif [[ "${{ matrix.symfony-version }}" == "^7.0" || "${{ matrix.symfony-version }}" == "^7.1" || "${{ matrix.symfony-version }}" == "^7.2" || "${{ matrix.symfony-version }}" == "^7.3" ]]; then + # Symfony 7.0-7.3: Use legacy baseline for children() issue composer analyse-legacy else # Future versions: Try without baseline first diff --git a/phpstan/symfony64-baseline.neon b/phpstan/symfony64-baseline.neon new file mode 100644 index 0000000..a5b8502 --- /dev/null +++ b/phpstan/symfony64-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 From c136ef8a1ca9358b878db844fc968fdda6a320ec Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 20:11:57 +0100 Subject: [PATCH 25/34] Refactor PHPStan analysis for Symfony versions; remove obsolete baseline configuration for Symfony 6.4 --- .github/workflows/ci.yml | 8 ++++---- phpstan/symfony64-baseline.neon | 7 ------- 2 files changed, 4 insertions(+), 11 deletions(-) delete mode 100644 phpstan/symfony64-baseline.neon diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e649961..fdf8b7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,11 +144,11 @@ jobs: # 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" ]]; then - # Symfony 6.4 LTS: Use dedicated baseline for children() issue - vendor/bin/phpstan analyse src --level=8 --configuration phpstan/symfony64-baseline.neon - elif [[ "${{ matrix.symfony-version }}" == "^7.0" || "${{ matrix.symfony-version }}" == "^7.1" || "${{ matrix.symfony-version }}" == "^7.2" || "${{ matrix.symfony-version }}" == "^7.3" ]]; then - # Symfony 7.0-7.3: Use legacy baseline for children() issue + # Symfony 6.4 LTS: Use legacy baseline for children() issue composer analyse-legacy + elif [[ "${{ matrix.symfony-version }}" == "^7.0" || "${{ matrix.symfony-version }}" == "^7.1" || "${{ matrix.symfony-version }}" == "^7.2" || "${{ matrix.symfony-version }}" == "^7.3" ]]; then + # Symfony 7.0-7.3: Use symfony8 baseline for missingType.generics issue + composer analyse else # Future versions: Try without baseline first composer analyse diff --git a/phpstan/symfony64-baseline.neon b/phpstan/symfony64-baseline.neon deleted file mode 100644 index a5b8502..0000000 --- a/phpstan/symfony64-baseline.neon +++ /dev/null @@ -1,7 +0,0 @@ -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 From 728d34e4fc5aad0a524239307b01cdb2a990d471 Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 20:32:07 +0100 Subject: [PATCH 26/34] Update CI configuration for Symfony version compatibility; adjust PHPStan analysis commands and documentation for clarity --- .github/workflows/ci.yml | 33 +++++++++++++++------------------ DEVELOPMENT.md | 4 ++-- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdf8b7c..f73777e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,28 +35,28 @@ jobs: # 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' + symfony-version: '7.4.*' allowed-to-fail: false # Symfony 8.0 - Requires PHP 8.4+ @@ -66,13 +66,13 @@ jobs: # PHP 8.5 with various Symfony versions - php: '8.5' - symfony-version: '^7.3' + symfony-version: '7.3.*' allowed-to-fail: false - php: '8.5' - symfony-version: '^7.4' + symfony-version: '7.4.*' allowed-to-fail: false - php: '8.5' - symfony-version: '^8.0' + 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) || '' }}" @@ -137,18 +137,15 @@ jobs: - name: Run static analysis run: | - if [[ "${{ matrix.symfony-version }}" == "^8.0" ]]; then + 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 + 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" ]]; then - # Symfony 6.4 LTS: Use legacy baseline for children() issue + 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 - elif [[ "${{ matrix.symfony-version }}" == "^7.0" || "${{ matrix.symfony-version }}" == "^7.1" || "${{ matrix.symfony-version }}" == "^7.2" || "${{ matrix.symfony-version }}" == "^7.3" ]]; then - # Symfony 7.0-7.3: Use symfony8 baseline for missingType.generics issue - composer analyse else # Future versions: Try without baseline first composer analyse diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e445f36..434c18d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -23,10 +23,10 @@ composer test-coverage ### Static Analysis & Code Quality ```bash -# Static analysis (PHPStan Level 8) - For Symfony 7.4+ / 8.0 +# Static analysis (PHPStan Level 8) - Default for Symfony 7.4 / 8.0 composer analyse -# Static analysis for Symfony 6.4 / 7.0-7.3 +# Static analysis with legacy baseline (required for Symfony 6.4 - 7.3) composer analyse-legacy # Code style check (Symfony standards) From f923791ce8718bffff7bcb925edfc46c33b9d1dc Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 20:34:25 +0100 Subject: [PATCH 27/34] Fix Symfony version check in PHPStan analysis for legacy support --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f73777e..e77354c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,7 +143,7 @@ jobs: 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 + 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 From f12d64ed551b2a1aed820d86e4e4c80f9d45bf29 Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 20:37:59 +0100 Subject: [PATCH 28/34] Update Symfony version specification for CI compatibility --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e77354c..e5f83f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,13 @@ jobs: include: # 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 @@ -61,7 +61,7 @@ jobs: # Symfony 8.0 - Requires PHP 8.4+ - php: '8.4' - symfony-version: '^8.0' + symfony-version: '8.0.*' allowed-to-fail: false # PHP 8.5 with various Symfony versions From d1c85577683b7d82d941119172930a31d67a1e86 Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 23:01:44 +0100 Subject: [PATCH 29/34] Add comprehensive RateLimiterMiddleware unit tests for complete code coverage --- .../Middleware/RateLimiterMiddlewareTest.php | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 tests/Unit/Middleware/RateLimiterMiddlewareTest.php 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(); + } +} From 02e177efd1bda275822e71971d1ff42e2d441bd2 Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 23:05:01 +0100 Subject: [PATCH 30/34] Update system requirements to reflect stable v4.0.0 release --- UPGRADE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UPGRADE.md b/UPGRADE.md index dd36fed..27985e8 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -38,7 +38,7 @@ - **PHP**: 8.1+ - **Symfony**: 6.4+ | 7.x | 8.x -- **calliostro/php-discogs-api**: v4.0.0-beta.1+ +- **calliostro/php-discogs-api**: ^4.0 ## ๐Ÿ“ฆ Fresh Installation From 0e15fac60a9a927919b4d790943523d1da259358 Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 23:08:05 +0100 Subject: [PATCH 31/34] Update require-dev dependencies to support all compatible Symfony versions (6.4|7.x|8.x) --- composer.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 034da7b..f8de5fe 100644 --- a/composer.json +++ b/composer.json @@ -32,14 +32,14 @@ "symfony/rate-limiter": "For advanced rate limiting functionality" }, "require-dev": { - "symfony/phpunit-bridge": "8.0", + "symfony/phpunit-bridge": "^6.4|^7.0|^8.0", "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^3.87", "symfony/rate-limiter": "^6.4|^7.0|^8.0", - "symfony/config": "8.0", - "symfony/dependency-injection": "8.0", - "symfony/http-kernel": "8.0", - "symfony/security-core": "8.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" }, "autoload": { "psr-4": { From b90b6d90b2ebc3684dedf515b069b7d79d70bfb1 Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 23:11:27 +0100 Subject: [PATCH 32/34] Fix rate limiter retry logic to properly loop until request is accepted --- src/Middleware/RateLimiterMiddleware.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Middleware/RateLimiterMiddleware.php b/src/Middleware/RateLimiterMiddleware.php index ae4e6a0..7156ccc 100644 --- a/src/Middleware/RateLimiterMiddleware.php +++ b/src/Middleware/RateLimiterMiddleware.php @@ -28,10 +28,10 @@ public function __invoke(callable $handler): callable return function (RequestInterface $request, array $options) use ($handler): PromiseInterface { $rateLimiter = $this->rateLimiterFactory->create($this->limiterKey); - // Try to consume from rate limiter + // Try to consume from rate limiter with retry $limit = $rateLimiter->consume(1); - if (!$limit->isAccepted()) { + while (!$limit->isAccepted()) { // If rate limit exceeded, wait for the retry after time $retryAfter = $limit->getRetryAfter(); $waitTime = $retryAfter->getTimestamp() - time(); From 56d56276809310e6759b2a943b700e04aa0bdc0d Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 23:14:57 +0100 Subject: [PATCH 33/34] Add explicit Symfony bundle dependencies to require section --- composer.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index f8de5fe..3300312 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,10 @@ ], "require": { "php": "^8.1", - "calliostro/php-discogs-api": "^4.0" + "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" }, "suggest": { "symfony/rate-limiter": "For advanced rate limiting functionality" From c425d796384ee474996befd9e999603ca1012283 Mon Sep 17 00:00:00 2001 From: calliostro Date: Mon, 1 Dec 2025 23:18:13 +0100 Subject: [PATCH 34/34] Remove duplicate Symfony dependencies from require-dev section --- composer.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/composer.json b/composer.json index 3300312..d1d3073 100644 --- a/composer.json +++ b/composer.json @@ -39,9 +39,6 @@ "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^3.87", "symfony/rate-limiter": "^6.4|^7.0|^8.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" }, "autoload": {