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